Files
orca/src/shared/native-chat-href-routing.ts
T
Brennan Benson 3e27e62f43 Native chat: open files from tool rows through the terminal's file link menu (#26511)
* Native chat: open tool-row files through the terminal's file link popover

Tool rows that name a file (Read, Write, Edit, edit cards) now link the
file name. Chat file links, in tool rows and in reply text, show the same
popover the terminal shows for file paths, built from one shared function.

* Fix native chat filename activation and path resolution
2026-10-08 10:47:55 -07:00

135 lines
4.4 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; pathKind?: 'literal' }
| { 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 NATIVE_CHAT_LITERAL_FILE_HREF_PREFIX = '#orca-native-chat-file-path='
const MAX_NATIVE_CHAT_FILE_HREF_DECODES = 4
/** Location text keeps reply parsing; known tool paths retain every filename character. */
export function createNativeChatFileHref(
pathText: string,
pathKind: 'location' | 'literal' = 'location'
): string {
const prefix =
pathKind === 'literal' ? NATIVE_CHAT_LITERAL_FILE_HREF_PREFIX : NATIVE_CHAT_FILE_HREF_PREFIX
return `${prefix}${encodeURIComponent(pathText)}`
}
function decodeNativeChatFileHref(
href: string,
prefix = NATIVE_CHAT_FILE_HREF_PREFIX
): string | null {
if (!href.startsWith(prefix)) {
return null
}
try {
const decoded = decodeURIComponent(href.slice(prefix.length))
if (prefix === NATIVE_CHAT_LITERAL_FILE_HREF_PREFIX) {
return decoded || null
}
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' }
}
if (trimmed.startsWith(NATIVE_CHAT_LITERAL_FILE_HREF_PREFIX)) {
const pathText = decodeNativeChatFileHref(trimmed, NATIVE_CHAT_LITERAL_FILE_HREF_PREFIX)
return pathText ? { kind: 'file', pathText, line: null, pathKind: 'literal' } : { 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' }
}