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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user