diff --git a/mobile/src/components/markdown-href-routing.ts b/mobile/src/components/markdown-href-routing.ts index a8384bda78e..61531f937a5 100644 --- a/mobile/src/components/markdown-href-routing.ts +++ b/mobile/src/components/markdown-href-routing.ts @@ -1,91 +1,18 @@ -// Decides what a tapped markdown href should do on mobile: open the system -// browser/mail client, open a file in the worktree, or nothing. Mirrors the -// desktop native-chat contract where hrefs are explicit agent-authored links: -// file: URIs and scheme-less (relative or absolute) hrefs are file targets. -import { fileUriToFilesystemPath } from '../../../src/shared/file-uri-path' -import { isWindowsAbsolutePathLike } from '../../../src/shared/cross-platform-path' +import { routeNativeChatHref } from '../../../src/shared/native-chat-href-routing' export type MarkdownHrefRoute = | { kind: 'web'; url: string } | { kind: 'file'; pathText: string } | { kind: 'none' } -const WEB_SCHEME_PATTERN = /^(?:https?|mailto):/i -const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/ - -function parseLineFragment(hash: string): number | null { - if (!hash) { - return null - } - let decoded = hash - try { - decoded = decodeURIComponent(hash) - } catch { - // Keep the raw fragment when decoding fails. - } - const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded) - return match ? Number.parseInt(match[1]!, 10) : null -} - -function stripQueryAndHash(value: string): { pathText: string; line: number | null } { - const hashIndex = value.indexOf('#') - const queryIndex = value.indexOf('?') - const suffixIndex = - hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex) - const pathText = suffixIndex === -1 ? value : value.slice(0, suffixIndex) - const hash = - hashIndex === -1 - ? '' - : value.slice(hashIndex + 1, queryIndex > hashIndex ? queryIndex : undefined) - return { pathText, line: parseLineFragment(hash) } -} - -function maybeDecodeHrefPath(value: string): string { - try { - return decodeURIComponent(value) - } catch { - return value - } -} - -// The open flow parses a trailing :line back out via splitFilePathLineSuffix. function withLineSuffix(pathText: string, line: number | null): string { return line === null ? pathText : `${pathText}:${line}` } export function routeMarkdownHref(href: string): MarkdownHrefRoute { - const trimmed = href.trim() - if (!trimmed || trimmed.startsWith('#')) { - return { kind: 'none' } + const route = routeNativeChatHref(href) + if (route.kind !== 'file') { + return route } - if (WEB_SCHEME_PATTERN.test(trimmed)) { - return { kind: 'web', url: trimmed } - } - if (/^file:/i.test(trimmed)) { - let url: URL - try { - url = new URL(trimmed) - } catch { - return { kind: 'none' } - } - const filePath = fileUriToFilesystemPath(url) - if (!filePath) { - return { kind: 'none' } - } - return { - kind: 'file', - pathText: withLineSuffix(filePath, parseLineFragment(url.hash.slice(1))) - } - } - // Other schemes (editor:, javascript:, data:, …) are dropped; a Windows drive - // prefix only looks like a scheme. - if (!isWindowsAbsolutePathLike(trimmed) && SCHEME_PATTERN.test(trimmed)) { - return { kind: 'none' } - } - const { pathText, line } = stripQueryAndHash(trimmed) - const decoded = maybeDecodeHrefPath(pathText) - if (!decoded) { - return { kind: 'none' } - } - return { kind: 'file', pathText: withLineSuffix(decoded, line) } + return { kind: 'file', pathText: withLineSuffix(route.pathText, route.line) } } diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts index f6531adaf35..eee08ceae2b 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -1,5 +1,4 @@ -import { fileUriToFilesystemPath } from '../../../../shared/file-uri-path' -import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing' import type { Worktree } from '../../../../shared/types' import { parseExplicitFileLinkTarget, @@ -82,50 +81,6 @@ export function resolveNativeChatFileLinkContext( } } -function maybeDecodeHrefPath(value: string): string { - try { - return decodeURIComponent(value) - } catch { - return value - } -} - -function stripQueryAndHash(value: string): { pathText: string; line: number | null } { - const hashIndex = value.indexOf('#') - const queryIndex = value.indexOf('?') - const suffixIndex = - hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex) - const pathText = suffixIndex === -1 ? value : value.slice(0, suffixIndex) - const hash = - hashIndex === -1 - ? '' - : value.slice(hashIndex + 1, queryIndex > hashIndex ? queryIndex : undefined) - const line = parseLineFragment(hash) - return { pathText, line } -} - -function parseLineFragment(hash: string): number | null { - if (!hash) { - return null - } - let decoded = hash - try { - decoded = decodeURIComponent(hash) - } catch { - decoded = hash - } - const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded) - return match ? Number.parseInt(match[1], 10) : null -} - -function hasNonFileUriProtocol(value: string): boolean { - if (isWindowsAbsolutePathLike(value)) { - return false - } - const match = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(value) - return Boolean(match && match[0].toLowerCase() !== 'file:') -} - function resolvePathText( pathText: string, fallbackLine: number | null, @@ -148,43 +103,16 @@ function resolvePathText( } } -function resolveFileUriLink( - href: string, - context: NativeChatFileLinkContext -): NativeChatResolvedFileLink | null { - let url: URL - try { - url = new URL(href) - } catch { - return null - } - if (url.protocol !== 'file:') { - return null - } - const filePath = fileUriToFilesystemPath(url) - if (!filePath) { - return null - } - return resolvePathText(filePath, parseLineFragment(url.hash.replace(/^#/, '')), context) -} - export function resolveNativeChatFileLink( href: string | undefined, context: NativeChatFileLinkContext | null ): NativeChatResolvedFileLink | null { - const rawHref = href?.trim() - if (!rawHref || rawHref.startsWith('#') || !context) { + if (!context) { return null } - - if (rawHref.toLowerCase().startsWith('file:')) { - return resolveFileUriLink(rawHref, context) - } - if (hasNonFileUriProtocol(rawHref)) { + const route = routeNativeChatHref(href) + if (route.kind !== 'file') { return null } - - const { pathText, line } = stripQueryAndHash(rawHref) - const decodedPathText = maybeDecodeHrefPath(pathText) - return resolvePathText(decodedPathText, line, context) + return resolvePathText(route.pathText, route.line, context) } diff --git a/src/shared/native-chat-href-routing.test.ts b/src/shared/native-chat-href-routing.test.ts new file mode 100644 index 00000000000..1b7c276ffd3 --- /dev/null +++ b/src/shared/native-chat-href-routing.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { routeNativeChatHref } from './native-chat-href-routing' + +describe('routeNativeChatHref', () => { + it('classifies web and mail links', () => { + expect(routeNativeChatHref('https://example.com/docs')).toEqual({ + kind: 'web', + url: 'https://example.com/docs' + }) + expect(routeNativeChatHref(' mailto:dev@example.com ')).toEqual({ + kind: 'web', + url: 'mailto:dev@example.com' + }) + }) + + it('parses relative file hrefs and line fragments', () => { + expect(routeNativeChatHref('docs/plan.md?plain=1#line-7')).toEqual({ + kind: 'file', + pathText: 'docs/plan.md', + line: 7 + }) + expect(routeNativeChatHref('docs/plan.md#usage')).toEqual({ + kind: 'file', + pathText: 'docs/plan.md', + line: null + }) + }) + + it('decodes relative and file URI paths', () => { + expect(routeNativeChatHref('docs/release%20notes.md')).toEqual({ + kind: 'file', + pathText: 'docs/release notes.md', + line: null + }) + expect(routeNativeChatHref('file:///Users/me/wt/My%20File.tsx#L12')).toEqual({ + kind: 'file', + pathText: '/Users/me/wt/My File.tsx', + line: 12 + }) + }) + + it('keeps Windows drive paths out of the scheme filter', () => { + expect(routeNativeChatHref(String.raw`C:\repo\src\index.ts`)).toEqual({ + kind: 'file', + pathText: String.raw`C:\repo\src\index.ts`, + line: null + }) + }) + + it('drops anchors, unknown schemes, malformed file URIs, and empty hrefs', () => { + expect(routeNativeChatHref('#section')).toEqual({ kind: 'none' }) + expect(routeNativeChatHref(undefined)).toEqual({ kind: 'none' }) + expect(routeNativeChatHref('editor://file/x.ts')).toEqual({ kind: 'none' }) + expect(routeNativeChatHref('javascript:alert(1)')).toEqual({ kind: 'none' }) + expect(routeNativeChatHref('file:///tmp/%E0%A4%A.txt')).toEqual({ kind: 'none' }) + }) +}) diff --git a/src/shared/native-chat-href-routing.ts b/src/shared/native-chat-href-routing.ts new file mode 100644 index 00000000000..005ab957f65 --- /dev/null +++ b/src/shared/native-chat-href-routing.ts @@ -0,0 +1,74 @@ +import { isWindowsAbsolutePathLike } from './cross-platform-path' +import { fileUriToFilesystemPath } from './file-uri-path' + +export type NativeChatHrefRoute = + | { kind: 'web'; url: string } + | { kind: 'file'; pathText: string; line: number | null } + | { kind: 'none' } + +const WEB_SCHEME_PATTERN = /^(?:https?|mailto):/i +const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/ + +function parseLineFragment(hash: string): number | null { + if (!hash) { + return null + } + let decoded = hash + try { + decoded = decodeURIComponent(hash) + } catch { + // Keep the raw fragment when decoding fails. + } + const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded) + return match ? Number.parseInt(match[1]!, 10) : null +} + +function stripQueryAndHash(value: string): { pathText: string; line: number | null } { + const hashIndex = value.indexOf('#') + const queryIndex = value.indexOf('?') + const suffixIndex = + hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex) + const pathText = suffixIndex === -1 ? value : value.slice(0, suffixIndex) + const hash = + hashIndex === -1 + ? '' + : value.slice(hashIndex + 1, queryIndex > hashIndex ? queryIndex : undefined) + return { pathText, line: parseLineFragment(hash) } +} + +function maybeDecodeHrefPath(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +export function routeNativeChatHref(href: string | null | undefined): NativeChatHrefRoute { + const trimmed = href?.trim() + if (!trimmed || trimmed.startsWith('#')) { + return { kind: 'none' } + } + if (WEB_SCHEME_PATTERN.test(trimmed)) { + return { kind: 'web', url: trimmed } + } + if (/^file:/i.test(trimmed)) { + let url: URL + try { + url = new URL(trimmed) + } catch { + return { kind: 'none' } + } + const pathText = fileUriToFilesystemPath(url) + if (!pathText) { + return { kind: 'none' } + } + return { kind: 'file', pathText, line: parseLineFragment(url.hash.slice(1)) } + } + if (!isWindowsAbsolutePathLike(trimmed) && SCHEME_PATTERN.test(trimmed)) { + return { kind: 'none' } + } + const { pathText, line } = stripQueryAndHash(trimmed) + const decodedPathText = maybeDecodeHrefPath(pathText) + return decodedPathText ? { kind: 'file', pathText: decodedPathText, line } : { kind: 'none' } +}