Terminal output URLs and file paths (e.g. src/app.ts:42) are now tappable — the
walk-away device is a phone, so this removes soft-keyboard copy gymnastics.
- public/link-paths.ts (new, pure): findPathMatches — links tokens with a '/',
a :line suffix, or a code extension (src/app.ts:42, /abs/main.rs:10, README.md)
while rejecting example.com / v1.2.3 / 12:34 / URL tails.
- terminal-session.ts: hardened URL handler (scheme allowlist http/https/mailto +
window.open noopener,noreferrer, blocks javascript:/data:/file:) replacing the
addon default; a path link provider → openPath() POSTs {file,line} to
/open-in-editor (resolves rel paths against the OSC-7 cwd; in-flight guard).
- src/http/editor.ts: additive openFileInEditor + isGotoEditor (--goto file:line
only for goto-capable editors; execFile, no shell). openInEditor untouched.
- src/server.ts: /open-in-editor branches on body.file vs body.path (same CSRF guard).
Deviation from the "no server change" brief: an additive openFileInEditor was
required because the existing route only opens directories, not file:line (Option A
in docs/plans/w1-clickable-links.md). Backward-compatible; src/types.ts untouched.
Verified independently: typecheck + build:web clean, 1661 tests pass (link-paths
95%+ cov). Note: xterm buffer-row indexing (getLine(n-1)/range.y=n) is asserted in
jsdom but merits a real-browser smoke check.
92 lines
3.8 KiB
TypeScript
92 lines
3.8 KiB
TypeScript
/**
|
|
* public/link-paths.ts — pure file-path matcher for the terminal link provider (W1).
|
|
*
|
|
* Finds `src/app.ts:42`, `README.md`, `../lib/util.rs:10:5` etc. in a line of
|
|
* terminal text so the terminal-session link provider can turn them into
|
|
* clickable "open in editor" links. Deliberately DOM-free / xterm-free so it is
|
|
* unit-testable in node and reusable by the diff/approval-preview views later.
|
|
*
|
|
* A candidate token is: an optional `./`, `../` or `dir/…/` prefix, a filename
|
|
* with a dot-extension, and an optional `:line` / `:line:col` suffix. A candidate
|
|
* is only linked when it is *probably* a real path — it has a `/` separator, OR a
|
|
* `:line` suffix, OR its extension is in the CODE_EXT allowlist. This links code
|
|
* paths while ignoring `example.com`, `v1.2.3`, `foo.bar`, and `12:34`.
|
|
*/
|
|
|
|
/** File extensions that mark a bare (slash-less, line-less) token as a code path. */
|
|
export const CODE_EXT: ReadonlySet<string> = new Set([
|
|
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
|
|
'py', 'go', 'rs', 'rb', 'java', 'kt',
|
|
'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'php', 'swift',
|
|
'css', 'scss', 'html', 'json', 'yaml', 'yml', 'toml',
|
|
'md', 'txt', 'sh', 'sql', 'vue', 'svelte',
|
|
])
|
|
|
|
export interface PathMatch {
|
|
/** Exact matched substring, incl. any `:line[:col]` suffix (e.g. "src/app.ts:42"). */
|
|
text: string
|
|
/** The file-path portion only, without the line/column suffix (e.g. "src/app.ts"). */
|
|
path: string
|
|
/** 1-based line number, when a `:line` suffix was present. */
|
|
line?: number
|
|
/** 1-based column, when a `:line:col` suffix was present. */
|
|
column?: number
|
|
/** 1-based column of the first char (maps to an xterm range.start.x). */
|
|
startX: number
|
|
/** 1-based column of the last char, inclusive (maps to an xterm range.end.x). */
|
|
endX: number
|
|
}
|
|
|
|
// A path candidate. The leading negative lookbehind rejects tokens glued to a
|
|
// preceding word char, '.', '/', ':', '@' or '-' — this keeps the matcher from
|
|
// grabbing the `path.html` tail of a `https://host/path.html` URL (which the
|
|
// WebLinksAddon owns) or the middle of a larger identifier. The optional prefix
|
|
// admits an absolute `/`, a `./` or a `../` before the dir segments.
|
|
// group 1 = the path (prefix + dirs + filename.ext), no line/col
|
|
// group 2 = line digits (optional)
|
|
// group 3 = column digits (optional)
|
|
const PATH_RE =
|
|
/(?<![\w./:@-])((?:\/|\.{1,2}\/)?(?:[\w.-]+\/)*[\w.-]*\.[A-Za-z0-9]+)(?::(\d+)(?::(\d+))?)?/g
|
|
|
|
/** Lower-cased extension of a path (after the last dot), or null when there is none. */
|
|
function extensionOf(pathPart: string): string | null {
|
|
const dot = pathPart.lastIndexOf('.')
|
|
if (dot < 0 || dot === pathPart.length - 1) return null
|
|
return pathPart.slice(dot + 1).toLowerCase()
|
|
}
|
|
|
|
/** A candidate is a real path iff it has a dir separator, a :line suffix, or a code extension. */
|
|
function isLikelyPath(pathPart: string, hasLine: boolean): boolean {
|
|
if (pathPart.includes('/')) return true
|
|
if (hasLine) return true
|
|
const ext = extensionOf(pathPart)
|
|
return ext !== null && CODE_EXT.has(ext)
|
|
}
|
|
|
|
/**
|
|
* Find every clickable path in a single line of terminal text. Ranges are
|
|
* 1-based columns to match xterm's IBufferRange (startX = index+1, endX inclusive).
|
|
*/
|
|
export function findPathMatches(lineText: string): PathMatch[] {
|
|
const matches: PathMatch[] = []
|
|
PATH_RE.lastIndex = 0
|
|
let m: RegExpExecArray | null
|
|
while ((m = PATH_RE.exec(lineText)) !== null) {
|
|
const full = m[0]
|
|
const pathPart = m[1] as string
|
|
const hasLine = m[2] !== undefined
|
|
if (!isLikelyPath(pathPart, hasLine)) continue
|
|
|
|
const startIndex = m.index
|
|
matches.push({
|
|
text: full,
|
|
path: pathPart,
|
|
...(hasLine ? { line: Number(m[2]) } : {}),
|
|
...(m[3] !== undefined ? { column: Number(m[3]) } : {}),
|
|
startX: startIndex + 1,
|
|
endX: startIndex + full.length,
|
|
})
|
|
}
|
|
return matches
|
|
}
|