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:
Neil
2026-10-20 19:31:10 -07:00
committed by GitHub
parent 513c24bc16
commit a237b031df
7 changed files with 158 additions and 5 deletions
@@ -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}
/>
)
}
+10 -1
View File
@@ -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) } : {})
})
}