Files
orca/mobile/src/components/markdown-file-path-detection.ts
T
Brennan Benson 9ee359550b fix(mobile): make native-chat file links and path citations tappable (STA-3331) (#12364)
* fix(mobile): make native-chat file links and path citations tappable (STA-3331)

- Linkify POSIX absolute paths in chat prose (leading-/ regex alternative;
  URL guard now keys off the char before the matched slash)
- Parse agent-style path:line(:col) citations in prose, code spans, and the
  open flow; line/column ride into the mobile file preview route
- Route non-web markdown hrefs (file: URIs, relative/absolute paths) to the
  file opener instead of silently dropping them; unknown schemes stay dead
- Resolve chat paths against the worktree root, not the terminal's live cwd
- Reuse the terminal tap-to-open flow for chat taps (haptic, preview route,
  tab activation with retries) via a shared identity-stable hook, and toast
  on misses instead of silent no-ops
- Keep snake_case paths whole (intraword underscores are literal text),
  scan bold/italic/strike spans for paths, split trailing punctuation off
  autolinks, and let taps land while the composer keyboard is up

* fix(mobile): harden chat file tap handling

* refactor(chat): share native chat href routing

* fix(mobile): detect files directly under path roots

* fix(mobile): keep inline tokens and dunder paths intact around emphasis

Review follow-ups on the chat file-link work:

- A rejected intraword `_` token left the scan index past its closing
  underscore, so every inline token between two snake_case words was
  swallowed and rendered as literal source — including markdown links,
  which became untappable. Rescan from just past the opening delimiter.
- Treat a path separator as an intraword flank so `src/__init__.py` and
  `a/__tests__/x.ts` stay whole; previously they rendered as bold plus a
  remnant that the new absolute-root pattern turned into a tap on `/x.ts`.
- Bound the `:line(:col)` tail so `src/app.ts:1e3` and `:80%` no longer
  parse a line number, while a cited range still opens its first line.
- Route chat tap failures through the composer banner (toast fallback):
  chat taps happen with the keyboard up, which covers the toast.
- Drop the tap-handler mirror's dep list; the call site rebuilds its
  accessors every render, so it could never skip on a route that
  rerenders per keystroke.

* Revert "fix(mobile): keep inline tokens and dunder paths intact around emphasis"

This reverts commit 308bfaf22b.

* fix(mobile): preserve chat file-link parsing and feedback
2026-08-04 15:46:08 -07:00

242 lines
7.1 KiB
TypeScript

// Conservative detection of file-path-like tokens inside an inline markdown text
// run, so the chat view can render them as tappable (opening the mobile file
// viewer). We deliberately favor precision over recall: a missed path is a minor
// annoyance, but a false positive on prose or a version number is a broken tap.
export type FilePathSegment =
| { type: 'text'; value: string }
| { type: 'file'; value: string; path: string }
// Common source/code/config extensions we treat as openable file paths. Kept
// explicit (rather than "any extension") so prose like "etc." or "e.g." and
// domain-ish tokens like "example.com" don't get matched.
const FILE_EXTENSIONS = [
'ts',
'tsx',
'js',
'jsx',
'mjs',
'cjs',
'json',
'jsonc',
'css',
'scss',
'sass',
'less',
'html',
'htm',
'md',
'mdx',
'markdown',
'txt',
'py',
'rb',
'go',
'rs',
'java',
'kt',
'kts',
'swift',
'c',
'h',
'cc',
'cpp',
'hpp',
'cs',
'php',
'sh',
'bash',
'zsh',
'fish',
'yml',
'yaml',
'toml',
'ini',
'cfg',
'conf',
'env',
'lock',
'sql',
'graphql',
'gql',
'proto',
'xml',
'svg',
'vue',
'svelte',
'astro',
'dart',
'ex',
'exs',
'erl',
'lua',
'pl',
'r',
'scala',
'clj',
'gradle',
'dockerfile',
'gitignore',
'npmrc'
] as const
const EXTENSION_SET = new Set<string>(FILE_EXTENSIONS)
// Accept the host's native separator because transcript paths originate on the
// connected runtime, which may be Windows even when the phone is not. Leading
// alternatives cover Windows drives, UNC, and POSIX absolute roots; the optional
// tail captures only bounded agent-style :line(:col) citations.
const CANDIDATE_PATTERN =
/(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\w.@~+-]+[\\/])*|(?:[\w.@~+-]+[\\/])+)[\w.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![\w@%]))?/g
// A path candidate in chat prose is short; a much longer run can't hold one worth
// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we
// skip detection entirely above this cap.
const MAX_DETECTION_LENGTH = 2000
// A mid-token '@' (one preceded by a non-separator) marks an email or git URL such
// as git@github.com; a segment-leading '@' is a scoped package dir (@scope/…) and
// stays eligible.
function hasMidTokenAt(candidate: string): boolean {
return /[^\\/]@/.test(candidate)
}
const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/
/**
* Split an agent-style `path:line(:col)` citation into its parts. Windows drive
* colons are safe: only a trailing all-digit suffix is treated as a line ref.
*/
export function splitFilePathLineSuffix(pathText: string): {
path: string
line: number | null
column: number | null
} {
const match = LINE_SUFFIX_PATTERN.exec(pathText)
if (!match) {
return { path: pathText, line: null, column: null }
}
return {
path: match[1]!,
line: Number.parseInt(match[2]!, 10),
column: match[3] ? Number.parseInt(match[3], 10) : null
}
}
function isOpenablePath(pathText: string): boolean {
// A :line(:col) tail is part of the citation, not the file name.
const { path: candidate } = splitFilePathLineSuffix(pathText)
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
return false
}
// Must contain a separator (a bare "file.ts" is too ambiguous in prose).
if (!/[\\/]/.test(candidate)) {
return false
}
const lastSeparator = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\'))
const lastSegment = candidate.slice(lastSeparator + 1)
const dot = lastSegment.lastIndexOf('.')
// A leading-dot dotfile in the final segment (e.g. ".env") has no extension to
// anchor on; require a real name.ext shape.
if (dot <= 0) {
return false
}
const ext = lastSegment.slice(dot + 1).toLowerCase()
if (!EXTENSION_SET.has(ext)) {
return false
}
// Guard against version-number-ish tails ("1.2.3" style) where the "extension"
// is purely numeric — those aren't files.
if (/^\d+$/.test(ext)) {
return false
}
return true
}
// Strip the leading ./ marker so callers receive a clean worktree-relative path,
// while keeping ../ (which is meaningful) intact.
export function normalizeFilePath(path: string): string {
return path.replace(/^\.[\\/]/, '')
}
/**
* Given a plain inline text run, return ordered segments marking which substrings
* are openable file paths. Non-path text is preserved verbatim so the renderer can
* reassemble the run exactly. Returns a single text segment when nothing matches.
*/
export function detectFilePathSegments(text: string): FilePathSegment[] {
// Every real match ends in a name.ext, so a dot is mandatory; skip the regex when
// there is none, or when the run is too long to hold a chat path but long enough
// to drive CANDIDATE_PATTERN into super-linear backtracking.
if (text.length > MAX_DETECTION_LENGTH || !text.includes('.')) {
return [{ type: 'text', value: text }]
}
const segments: FilePathSegment[] = []
let lastIndex = 0
let match: RegExpExecArray | null
CANDIDATE_PATTERN.lastIndex = 0
while ((match = CANDIDATE_PATTERN.exec(text))) {
const candidate = match[0]
// Skip candidates that are part of a URL: a scheme colon, a domain tail, or
// a preceding slash (the leading slash of an absolute path is part of the
// match itself, so prev '/' means a '://' or '//' remainder, not a path).
const prev = match.index > 0 ? text[match.index - 1]! : ''
if (prev === ':' || prev === '/' || prev === '\\' || /[\w.@]/.test(prev)) {
continue
}
if (!isOpenablePath(candidate)) {
continue
}
if (match.index > lastIndex) {
segments.push({ type: 'text', value: text.slice(lastIndex, match.index) })
}
segments.push({ type: 'file', value: candidate, path: normalizeFilePath(candidate) })
lastIndex = match.index + candidate.length
}
if (lastIndex < text.length) {
segments.push({ type: 'text', value: text.slice(lastIndex) })
}
// Collapse to a single text segment for the common no-match case.
if (segments.length === 0) {
return [{ type: 'text', value: text }]
}
return segments
}
/**
* True when an inline-code span's entire content is a single openable file path
* (e.g. `src/app/Main.tsx`). Code spans are a strong signal, so we also accept a
* bare `file.ts` here even without a slash.
*/
export function isFilePathCodeSpan(code: string): boolean {
const trimmed = code.trim()
if (!trimmed || /\s/.test(trimmed)) {
return false
}
if (trimmed.includes('://') || hasMidTokenAt(trimmed)) {
return false
}
if (isOpenablePath(trimmed)) {
return true
}
// Separator-less code span: accept a clean name.ext (with an optional
// :line(:col) citation tail) and a known extension.
const { path } = splitFilePathLineSuffix(trimmed)
if (/[\\/]/.test(path)) {
return false
}
const dot = path.lastIndexOf('.')
if (dot <= 0) {
return false
}
const name = path.slice(0, dot)
const ext = path.slice(dot + 1).toLowerCase()
if (/[^\w.@+-]/.test(name)) {
return false
}
return EXTENSION_SET.has(ext)
}