Files
orca/src/shared/native-chat-href-routing.ts
T
Brennan Benson 845db9e5e2 fix(native-chat): underline only file links a click can act on (#22370)
* fix(native-chat): underline only file links a click can act on

A chat message could underline a bare file name such as `deck.md` that
resolved nowhere, and clicking it did nothing, so it read as a broken link.

- Inline code and quoted text become file links only when they name a path
  (contain a `/` or `\`), matching plain prose; a bare file name stays plain code.
- Every file link click now answers: it opens, or says the file was not found,
  that the host could not be checked, or that the path could not be resolved.
- Explicit links like [x](README.md:5) route as files, and linked text keeps
  `#`, `?` and `%XX` literally instead of re-parsing them as URL syntax.

* fix(native-chat): wrap the parsed file location so file URIs in chat text still open

Linkified prose, quoted text and inline code wrapped their display text, which the
literal wrapped-href route no longer URL-parses, so file:///... resolved as a relative
path under the worktree. Wrap pathText[:line[:col]] from the parsed link instead.
2026-09-23 13:29:24 -07:00

119 lines
3.8 KiB
TypeScript

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+.-]*:/
// Why: `README.md:5` is a file location; the scheme pattern alone reads `README.md:` as a scheme.
const BARE_FILE_LOCATION_PATTERN = /^[^\s:/\\?#]+\.[\p{L}\p{N}_+-]+:\d+(?::\d+)?$/u
export const NATIVE_CHAT_FILE_HREF_PREFIX = '#orca-native-chat-file='
const MAX_NATIVE_CHAT_FILE_HREF_DECODES = 4
/** Wraps literal file-location text (`path`, `path:line[:col]`); routing never re-parses it as a URL. */
export function createNativeChatFileHref(pathText: string): string {
return `${NATIVE_CHAT_FILE_HREF_PREFIX}${encodeURIComponent(pathText)}`
}
function decodeNativeChatFileHref(href: string): string | null {
if (!href.startsWith(NATIVE_CHAT_FILE_HREF_PREFIX)) {
return null
}
try {
const decoded = decodeURIComponent(href.slice(NATIVE_CHAT_FILE_HREF_PREFIX.length))
return decoded && !decoded.startsWith(NATIVE_CHAT_FILE_HREF_PREFIX) ? decoded : null
} catch {
return null
}
}
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 {
let trimmed = href?.trim()
if (!trimmed) {
return { kind: 'none' }
}
let isLiteralFileLocation = false
for (let depth = 0; depth < MAX_NATIVE_CHAT_FILE_HREF_DECODES; depth += 1) {
const encodedFileHref = decodeNativeChatFileHref(trimmed)
if (!encodedFileHref) {
break
}
trimmed = encodedFileHref.trim()
isLiteralFileLocation = true
}
if (!trimmed || trimmed.startsWith(NATIVE_CHAT_FILE_HREF_PREFIX)) {
return { kind: 'none' }
}
if (isLiteralFileLocation) {
// Why: `#`, `?` and `%XX` are legal filename characters, not URL syntax, in wrapped text.
return { kind: 'file', pathText: trimmed, line: null }
}
if (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) &&
!BARE_FILE_LOCATION_PATTERN.test(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' }
}