refactor(chat): share native chat href routing

This commit is contained in:
Brennan Benson
2026-08-03 20:06:54 -07:00
parent ecd921b173
commit 7c1af0be05
4 changed files with 141 additions and 155 deletions
+5 -78
View File
@@ -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) }
}
@@ -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)
}
@@ -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' })
})
})
+74
View File
@@ -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' }
}