feat(links): clickable URLs + file:line paths in the terminal (W1)

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.
This commit is contained in:
Yaojia Wang
2026-07-12 19:44:40 +02:00
parent 3e49e36806
commit debf47d99e
9 changed files with 814 additions and 9 deletions

View File

@@ -61,3 +61,86 @@ export async function openInEditor(cfg: Config, rawPath: unknown): Promise<OpenE
return { ok: false, status: 500, error: err instanceof Error ? err.message : String(err) }
}
}
// ── W1: open a specific FILE at a line (clickable terminal paths) ──────────────
//
// Additive to openInEditor (which only opens directories). The frontend link
// provider POSTs {file, line} here so a click on `src/app.ts:42` jumps straight
// to that line in the host's editor. Same execFile (no shell) safety model.
/** Editors whose CLI accepts `--goto <file>:<line>` to jump to a line. */
const GOTO_EDITORS: ReadonlySet<string> = new Set([
'code', 'code-insiders', 'codium', 'vscodium', 'cursor', 'windsurf',
])
const MIN_LINE = 1
const MAX_LINE = 1_000_000
/**
* True iff `editorCmd`'s basename is an editor that understands `--goto file:line`.
* Unknown editors get the bare file (never a stray `--goto` argv they'd misread
* as a filename). A trailing .cmd/.exe/.bat (Windows shim) is stripped first.
*/
export function isGotoEditor(editorCmd: string): boolean {
const base = path.basename(editorCmd).toLowerCase().replace(/\.(cmd|exe|bat)$/, '')
return GOTO_EDITORS.has(base)
}
/**
* Validate `rawFile` (absolute, existing regular file) and optional `rawLine`
* (integer 1..1_000_000), then launch the editor at that file:line. Never throws
* — returns a structured result the route maps to an HTTP response. The line is
* validated to an integer before interpolation into `${file}:${line}`, so it can
* never smuggle shell metacharacters (and there's no shell anyway — execFile argv).
*/
export async function openFileInEditor(
cfg: Config,
rawFile: unknown,
rawLine?: unknown,
): Promise<OpenEditorResult> {
if (typeof rawFile !== 'string' || rawFile.trim() === '') {
return { ok: false, status: 400, error: 'file is required' }
}
if (!path.isAbsolute(rawFile)) {
return { ok: false, status: 400, error: 'file must be absolute' }
}
let line: number | undefined
if (rawLine !== undefined && rawLine !== null) {
if (
typeof rawLine !== 'number' ||
!Number.isInteger(rawLine) ||
rawLine < MIN_LINE ||
rawLine > MAX_LINE
) {
return { ok: false, status: 400, error: `line must be an integer ${MIN_LINE}..${MAX_LINE}` }
}
line = rawLine
}
let stat
try {
stat = await fs.stat(rawFile)
} catch {
return { ok: false, status: 404, error: 'file not found' }
}
if (!stat.isFile()) {
return { ok: false, status: 400, error: 'path is not a file' }
}
const args =
isGotoEditor(cfg.editorCmd) && line !== undefined
? ['--goto', `${rawFile}:${line}`]
: [rawFile]
try {
const child = execFile(cfg.editorCmd, args, { windowsHide: true })
child.on('error', (err) => {
console.error('[editor] failed to launch', JSON.stringify(cfg.editorCmd), '-', err.message)
})
child.unref()
return { ok: true, status: 204 }
} catch (err) {
return { ok: false, status: 500, error: err instanceof Error ? err.message : String(err) }
}
}