mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): open relative Markdown file links (#31298)
Open relative Markdown file links in the mobile file preview with workspace and execution-host scoping. Thanks to @tomasklime for the report and diagnosis in #30368. Covers read-mode links; rich-editor links remain separate. Validation: focused tests, iPhone simulator proof, and all CI checks passed.
This commit is contained in:
@@ -159,6 +159,21 @@ describe('MobileFileMarkdownPreview', () => {
|
||||
expect(writeText.mock.calls).toEqual([[source], [source]])
|
||||
})
|
||||
|
||||
it('opens file links from the rendered document', async () => {
|
||||
const onOpenFile = vi.fn()
|
||||
renderer = await renderPreview({
|
||||
relativePath: 'docs/notes.md',
|
||||
content: '[Details](details.md)',
|
||||
truncated: false,
|
||||
byteLength: 21,
|
||||
onOpenFile
|
||||
})
|
||||
await act(async () => {
|
||||
renderer?.root.findByType('MobileMarkdown').props.onOpenFile('details.md')
|
||||
})
|
||||
expect(onOpenFile).toHaveBeenCalledWith('details.md')
|
||||
})
|
||||
|
||||
it('resets the selected mode for a new file or line target without remounting the preview', async () => {
|
||||
const baseProps: PreviewProps = {
|
||||
relativePath: 'notes/first.md',
|
||||
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
initialLine?: number
|
||||
onOpenFile?: (pathText: string) => void
|
||||
}
|
||||
|
||||
export function MobileFileMarkdownPreview({
|
||||
@@ -23,7 +24,8 @@ export function MobileFileMarkdownPreview({
|
||||
content,
|
||||
truncated,
|
||||
byteLength,
|
||||
initialLine
|
||||
initialLine,
|
||||
onOpenFile
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview'))
|
||||
const [previousRelativePath, setPreviousRelativePath] = useState(relativePath)
|
||||
@@ -77,7 +79,7 @@ export function MobileFileMarkdownPreview({
|
||||
{mode === 'preview' ? (
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.markdownContent}>
|
||||
{truncated ? <MobileFilePreviewTruncatedNote byteLength={byteLength} /> : null}
|
||||
<MobileMarkdown content={content} />
|
||||
<MobileMarkdown content={content} onOpenFile={onOpenFile} />
|
||||
</ScrollView>
|
||||
) : (
|
||||
<MobileFilePreviewSourceText
|
||||
|
||||
@@ -23,6 +23,7 @@ type Props = {
|
||||
imageHeight: number
|
||||
onDraftChange: (content: string) => void
|
||||
onImageError: () => void
|
||||
onOpenFile?: (pathText: string) => void
|
||||
/** Null where the only retry is a re-dial this document cannot make. */
|
||||
onRetry: (() => void) | null
|
||||
}
|
||||
@@ -88,6 +89,7 @@ export function MobileFilePreviewBody({ preview, ...options }: Props) {
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
initialLine={options.lineColumn?.line}
|
||||
onOpenFile={options.onOpenFile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { Alert, Pressable, Text, View } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaFrame } from 'react-native-safe-area-context'
|
||||
import { ChevronLeft, Save } from 'lucide-react-native'
|
||||
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult
|
||||
} from './mobile-file-preview-editability'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
import { mobileMarkdownFileLink } from './mobile-markdown-file-link'
|
||||
import { useMobileFilePreviewBack } from './use-mobile-file-preview-back'
|
||||
|
||||
type Props = {
|
||||
@@ -277,6 +278,14 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false })
|
||||
}
|
||||
onRetry={retry}
|
||||
onOpenFile={(pathText) => {
|
||||
const href = previewParams && mobileMarkdownFileLink(previewParams, pathText)
|
||||
if (href) {
|
||||
router.push(href)
|
||||
} else {
|
||||
Alert.alert('Unable to open file', 'Choose a file inside this workspace.')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={confirmingDiscard}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const push = vi.hoisted(() => vi.fn())
|
||||
const transport = vi.hoisted(
|
||||
(): {
|
||||
forceReconnect: ((hostId: string) => Promise<void>) | null
|
||||
@@ -32,10 +33,12 @@ vi.mock('react-native-safe-area-context', () => ({
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', Save: 'Icon' }))
|
||||
vi.mock('../navigation/route-handoff', () => ({
|
||||
useRouteHandoff: () => ({ back: () => {}, canGoBack: () => false })
|
||||
useRouteHandoff: () => ({ back: () => {}, canGoBack: () => false, push })
|
||||
}))
|
||||
vi.mock('../components/ConfirmModal', () => ({ ConfirmModal: () => null }))
|
||||
vi.mock('./MobileFileMarkdownPreview', () => ({ MobileFileMarkdownPreview: () => null }))
|
||||
vi.mock('./MobileFileMarkdownPreview', () => ({
|
||||
MobileFileMarkdownPreview: 'MobileFileMarkdownPreview'
|
||||
}))
|
||||
vi.mock('./MobileFilePreviewSourceText', () => ({ MobileFilePreviewSourceText: () => null }))
|
||||
// A host the shell has not reached by default: no client, so the screen settles on `waiting`.
|
||||
vi.mock('../transport/host-client-hooks', () => ({
|
||||
@@ -111,3 +114,30 @@ describe('the file preview Retry while the host is unreachable', () => {
|
||||
expect(sendRequest).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('wires rendered file links through the preview screen to navigation', async () => {
|
||||
transport.connection = {
|
||||
state: 'connected',
|
||||
client: {
|
||||
sendRequest: vi.fn(async () => ({
|
||||
ok: true,
|
||||
id: '1',
|
||||
_meta: { runtimeId: 'runtime-1' },
|
||||
result: { content: '[Details](docs/details.md)', isBinary: false, truncated: false }
|
||||
}))
|
||||
}
|
||||
}
|
||||
const rendered = await render()
|
||||
const markdown = rendered.root.find((node) => String(node.type) === 'MobileFileMarkdownPreview')
|
||||
await act(async () => markdown.props.onOpenFile('docs/details.md'))
|
||||
expect(push).toHaveBeenCalledWith({
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
params: {
|
||||
hostId: 'host-a',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeName: undefined,
|
||||
source: 'worktree',
|
||||
relativePath: 'docs/details.md'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { routeMarkdownHref } from '../components/markdown-href-routing'
|
||||
import { mobileMarkdownFileLink } from './mobile-markdown-file-link'
|
||||
|
||||
const params = {
|
||||
hostId: 'remote-host',
|
||||
worktreeId: 'folder-workspace',
|
||||
worktreeName: 'Project',
|
||||
relativePath: 'docs/notes.md'
|
||||
}
|
||||
|
||||
describe('Markdown preview file links', () => {
|
||||
it.each([
|
||||
['details.md', 'docs/details.md'],
|
||||
['./nested/details.md', 'docs/nested/details.md'],
|
||||
['../details.md', 'details.md'],
|
||||
['../docs/details.md', 'docs/details.md'],
|
||||
['nested\\details.md', 'docs/nested/details.md']
|
||||
])('opens %s relative to the current document', (path, relativePath) => {
|
||||
expect(mobileMarkdownFileLink(params, path)).toEqual({
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
params: {
|
||||
hostId: 'remote-host',
|
||||
worktreeId: 'folder-workspace',
|
||||
worktreeName: 'Project',
|
||||
source: 'worktree',
|
||||
relativePath
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves decoded filenames and line targets from rendered links', () => {
|
||||
const link = routeMarkdownHref('nested/a%20b.md#L12')
|
||||
expect(link.kind).toBe('file')
|
||||
if (link.kind !== 'file') {
|
||||
throw new Error('Expected file link')
|
||||
}
|
||||
expect(mobileMarkdownFileLink(params, link.pathText)?.params).toMatchObject({
|
||||
relativePath: 'docs/nested/a b.md',
|
||||
line: '12'
|
||||
})
|
||||
expect(mobileMarkdownFileLink(params, '../app.ts:3:4')?.params).toMatchObject({
|
||||
relativePath: 'app.ts',
|
||||
line: '3',
|
||||
column: '4'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['../../private.md', '/private.md', 'C:\\private.md', '\\\\host\\file.md', '', 'a\0b'])(
|
||||
'refuses paths outside the workspace: %s',
|
||||
(path) => expect(mobileMarkdownFileLink(params, path)).toBeNull()
|
||||
)
|
||||
|
||||
it('does not reuse terminal artifact grants for another file', () => {
|
||||
expect(mobileMarkdownFileLink({ ...params, source: 'terminalArtifact' }, 'other.md')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
isWindowsAbsolutePathLike,
|
||||
relativePathInsideRoot,
|
||||
resolveRuntimePath
|
||||
} from '../../../src/shared/cross-platform-path'
|
||||
import { splitFilePathLineSuffix } from '../components/markdown-file-path-detection'
|
||||
import {
|
||||
createMobileFilePreviewHref,
|
||||
type MobileFilePreviewRouteParams
|
||||
} from './mobile-file-preview-route'
|
||||
|
||||
export function mobileMarkdownFileLink(params: MobileFilePreviewRouteParams, pathText: string) {
|
||||
if (params.source === 'terminalArtifact' || !params.relativePath) {
|
||||
return null
|
||||
}
|
||||
const { path, line, column } = splitFilePathLineSuffix(pathText)
|
||||
if (!path || /^[\\/]/.test(path) || isWindowsAbsolutePathLike(path) || path.includes('\0')) {
|
||||
return null
|
||||
}
|
||||
// Resolve under a virtual root; the host still authorizes the actual workspace read.
|
||||
const root = '/workspace'
|
||||
const source = params.relativePath.replaceAll('\\', '/')
|
||||
const directory = source.slice(0, source.lastIndexOf('/') + 1)
|
||||
const target = resolveRuntimePath(`${root}/${directory}`, path)
|
||||
const relativePath = relativePathInsideRoot(root, target)
|
||||
if (!relativePath) {
|
||||
return null
|
||||
}
|
||||
return createMobileFilePreviewHref({
|
||||
hostId: params.hostId,
|
||||
worktreeId: params.worktreeId,
|
||||
worktreeName: params.worktreeName,
|
||||
source: 'worktree',
|
||||
relativePath,
|
||||
...(line !== null ? { line: String(line) } : {}),
|
||||
...(column !== null ? { column: String(column) } : {})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user