diff --git a/mobile/src/files/MobileFileMarkdownPreview.test.ts b/mobile/src/files/MobileFileMarkdownPreview.test.ts index 187ac421ee6..64cac75282a 100644 --- a/mobile/src/files/MobileFileMarkdownPreview.test.ts +++ b/mobile/src/files/MobileFileMarkdownPreview.test.ts @@ -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', diff --git a/mobile/src/files/MobileFileMarkdownPreview.tsx b/mobile/src/files/MobileFileMarkdownPreview.tsx index d01673bd22f..30fac1556ee 100644 --- a/mobile/src/files/MobileFileMarkdownPreview.tsx +++ b/mobile/src/files/MobileFileMarkdownPreview.tsx @@ -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' ? ( {truncated ? : null} - + ) : ( 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} /> ) } diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index afc490abcb3..69c0bc5e8d9 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -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.') + } + }} /> vi.fn()) const transport = vi.hoisted( (): { forceReconnect: ((hostId: string) => Promise) | 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' + } + }) +}) diff --git a/mobile/src/files/mobile-markdown-file-link.test.ts b/mobile/src/files/mobile-markdown-file-link.test.ts new file mode 100644 index 00000000000..34d926ef441 --- /dev/null +++ b/mobile/src/files/mobile-markdown-file-link.test.ts @@ -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() + }) +}) diff --git a/mobile/src/files/mobile-markdown-file-link.ts b/mobile/src/files/mobile-markdown-file-link.ts new file mode 100644 index 00000000000..c5f8ea0552b --- /dev/null +++ b/mobile/src/files/mobile-markdown-file-link.ts @@ -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) } : {}) + }) +}