From a237b031df33a80b915d97e0446b86999bfcdaf1 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 20 Oct 2026 19:31:10 -0700
Subject: [PATCH] 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.
---
.../files/MobileFileMarkdownPreview.test.ts | 15 +++++
.../src/files/MobileFileMarkdownPreview.tsx | 6 +-
mobile/src/files/MobileFilePreviewBody.tsx | 2 +
mobile/src/files/MobileFilePreviewScreen.tsx | 11 +++-
.../files/mobile-file-preview-retry.test.tsx | 34 ++++++++++-
.../files/mobile-markdown-file-link.test.ts | 57 +++++++++++++++++++
mobile/src/files/mobile-markdown-file-link.ts | 38 +++++++++++++
7 files changed, 158 insertions(+), 5 deletions(-)
create mode 100644 mobile/src/files/mobile-markdown-file-link.test.ts
create mode 100644 mobile/src/files/mobile-markdown-file-link.ts
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) } : {})
+ })
+}