14 KiB
Clickable URLs & file paths in the terminal
Status of premise (read first). Two independent capabilities are bundled here:
-
URLs — already 90% shipped.
public/terminal-session.ts:148already doesthis.term.loadAddon(new WebLinksAddon()), and@xterm/addon-web-links@^0.12.0is already inpackage.json. Remaining work is a security hardening pass on link activation (scheme allowlist +noopener). Pure frontend. -
File paths → editor — needs a custom link provider and a small, additive server change. The task brief says "POST to the existing
/open-in-editor… no server change," but the existing route cannot open a file at a line:openInEditor(src/http/editor.ts:31) rejects anything that is not an absolute existing directory (lines 35, 45–47) and spawnscode <dir>with no--goto/line (line 51). Feeding itsrc/app.ts:42fails three validators at once. See the Decision callout below — I recommend a tiny additiveopenFileInEditoralongside the untouchedopenInEditor. A strict "frontend-only" fallback exists but degrades to "open the containing folder, no line jump."
src/types.ts is not touched — this is an HTTP JSON body, not a WS protocol message; no shared-contract change. The byte-shuttle terminal stream is untouched.
Decision: how file-path clicks reach the editor
| Option | What clicking src/app.ts:42 does |
Server change | Recommendation |
|---|---|---|---|
A — additive openFileInEditor (recommended) |
Opens the file at line 42 (code --goto /abs/src/app.ts:42) |
+~35 lines in src/http/editor.ts, +1 branch in the server.ts:384 route. Backward-compatible; openInEditor + its tests untouched |
Yes. Only option that delivers the actual feature (jump to file:line). Additive and low-risk. |
| B — strict frontend-only | Resolves to the file's parent dir and calls existing openInEditor → opens the repo/folder, no file, no line |
None | Fallback only. Poor UX; loses the whole point (line jump). Document but don't ship as primary. |
The rest of this plan assumes Option A. The frontend work is identical either way; only the POST body and server branch differ.
Contract
Route (extended, backward-compatible)
POST /open-in-editor (src/server.ts:384) — unchanged guards: express.json({ limit: '4kb' }), requireAllowedOrigin (CSRF, src/server.ts:352). New branch on body shape:
- Existing (directory) —
{ "path": "<abs dir>" }→openInEditor(cfg, body.path)(unchanged; Projects panel keeps working). - New (file+line) —
{ "file": "<abs file>", "line": <int?>, "column": <int?> }→openFileInEditor(cfg, body.file, body.line). - Response envelope unchanged:
204on success;{ error }+4xx/5xxon failure (mirrorssrc/server.ts:388–392).
Body is a discriminated request: file present ⇒ file mode; else path mode. If neither present → 400 { error: 'path or file is required' }.
New server function (src/http/editor.ts)
export async function openFileInEditor(
cfg: Config, rawFile: unknown, rawLine?: unknown
): Promise<OpenEditorResult>
Reuses the existing OpenEditorResult type (editor.ts:20). Validates: string + non-empty (400), path.isAbsolute (400), fs.stat exists (404), stat.isFile() (400 'path is not a file'), and line (when present) is an integer in 1..1_000_000 else 400. Spawns via execFile(cfg.editorCmd, args) (no shell, same as line 51) where:
args = isGotoEditor(cfg.editorCmd) && line !== undefined ? ['--goto',${file}:${line}] : [file]isGotoEditor= basename ∈{code, code-insiders, codium, cursor, windsurf}(the editors that accept--goto). Unknown editors open the bare file (never pass a bogus--gotoargv).
New frontend module (public/link-paths.ts) — pure, node-testable
export interface PathMatch {
text: string // exact matched substring (e.g. "src/app.ts:42")
path: string // "src/app.ts"
line?: number
column?: number
startX: number // 1-based column of first char (xterm range.start.x)
endX: number // 1-based column of last char (xterm range.end.x, inclusive)
}
export function findPathMatches(lineText: string): PathMatch[]
Matching rules (concrete): a token is a path candidate iff it has a filename with a dot-extension, optionally preceded by ./, ../, or dir/…/ segments, optionally suffixed :line and :line:col. A candidate becomes a match iff (has a / separator) OR (has a :line suffix) OR (extension ∈ CODE_EXT allowlist) — this links src/app.ts:42, README.md, main.rs:10 while rejecting example.com, v1.2, foo.bar. Reject candidates immediately preceded by / or : (avoids grabbing the tail of a https://host/path.html URL, which WebLinksAddon owns). CODE_EXT = a named const 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 …).
Env vars
None new. EDITOR_CMD (default 'code') already exists (src/config.ts:49, src/types.ts:42) and is reused.
WS protocol / src/types.ts
No change. No new client→server or server→client message types.
Files to change
| Path | Change |
|---|---|
public/link-paths.ts |
New. Pure findPathMatches + CODE_EXT const + PathMatch type. No DOM. (Keeps matcher unit-testable in node and file <150 lines.) |
public/terminal-session.ts |
Replace bare new WebLinksAddon() at line 148 with a hardened handler (scheme allowlist + window.open(uri,'_blank','noopener,noreferrer')). After term.open (line 149) register a path link provider via this.term.registerLinkProvider(...); store the returned IDisposable. Add private openPath(m: PathMatch) (resolve rel→abs via this.cwdValue, in-flight guard, fetch('/open-in-editor', …), error→statusLine toast). Dispose the provider in dispose() (line 452). Small helpers openWebLink, makePathLinkProvider. |
src/http/editor.ts |
Add openFileInEditor + isGotoEditor helper. openInEditor unchanged (Projects panel + its tests keep passing). |
src/server.ts |
In the /open-in-editor handler (line 384–393) branch: body.file present → openFileInEditor(cfg, body.file, body.line); else existing openInEditor(cfg, body.path). |
src/types.ts |
Not touched — noted here only to confirm no shared-contract edit is required. |
TDD steps (ordered)
Run with
npm test(vitest). New frontend-logic tests are node (pure matcher); DOM-wiring tests reuse the existing jsdom harness intest/terminal-session.test.ts.
-
test/link-paths.test.ts(new, node). RED→GREEN forfindPathMatches:'see src/app.ts:42 for'→ one match,path:'src/app.ts', line:42,startX/endXcorrect (1-based,startX = index+1,endX = index+len).'./a/b.tsx:10:5'→line:10, column:5.'README.md'(allowlisted ext, no slash) → matched;'example.com'and'v1.2.3'→ no match.- URL guard:
'https://host/path.html'→ no path match (preceded-by-/rule). - Two paths on one line → two matches with disjoint ranges.
Then implement
public/link-paths.tsto green.
-
test/editor.test.ts(extend, node — mirror existing spawn-a-harmless-process style,editorCmd:'true'). Add adescribe('openFileInEditor'):- relative →
400; missing →404; directory →400 'is not a file'; non-int/0/1e9+line →400. - success on a real temp file (editorCmd
'true') →status 204. - argv assertion: write a tiny recorder script into the temp dir (
#!/bin/sh; printf '%s\n' "$@" > "$ARGS_OUT"), seteditorCmdto it, call withline:42, assert the recorded argv is--goto,<file>:42; call an unknown editor name → argv is just<file>(no--goto). Then implementopenFileInEditor+isGotoEditorto green.
- relative →
-
test/integration/server.test.ts(extend; harness at line 229). Boot the app,POST /open-in-editor:{file:<abs tmp file>, line:3}with a validOrigin→204.- foreign/missing
Origin→403(proves the CSRF guard still covers the new branch). {path:<abs tmp dir>}still204(regression: directory mode intact). Wire theserver.tsbranch to green.
-
test/terminal-session.test.ts(extend, jsdom). ExtendFakeTerminal(line 14) withregisterLinkProvider = vi.fn(p => { this.captured = p; return {dispose:vi.fn()} })and abuffer = { active: { getLine: (y)=>({ translateToString:()=> this.lineText }) } }. Keep theWebLinksAddonmock (line 48) but assert the constructor received a handler fn. Tests:- after construct, a link provider is registered; feeding a line with
src/app.ts:42→provideLinkscallback yields oneILinkwhosetext/rangematchfindPathMatches. - calling
link.activate(mouseEvent, text)whencwdis set (drive an OSC-7 via the captured handler, or set via a resolved path) →fetch(stub viavi.stubGlobal('fetch', …)as intest/preview-grid.test.ts:140) called once with'/open-in-editor', methodPOST, body{file:<abs>, line:42}. - relative path with null cwd → no fetch; a
statusLineis written to the terminal (assertterm.write). - in-flight guard: two rapid
activatecalls → one fetch. openWebLink('javascript:alert(1)')→window.opennot called;openWebLink('https://x')→window.open('https://x','_blank','noopener,noreferrer')called. Wireterminal-session.tsto green.
- after construct, a link provider is registered; feeding a line with
Coverage: the matcher (branch-heavy) is fully covered by the node test; the DOM wiring by jsdom; the server branch + validators by editor/integration tests. This keeps the 80% gate comfortably.
Edge cases & failure modes
- No cwd yet (OSC-7 never fired,
this.cwdValue === null,terminal-session.ts:97) → relative paths are unresolvable → skip activation, write a one-linestatusLine('cannot open <path>: working dir unknown'). Absolute paths still work. - Path doesn't exist / is a dir → server returns
404/400; frontend shows a non-blockingstatusLinetoast, no throw (mirror the existingopenProjectInEditorcatch that onlyconsole.errors). - Wide (CJK) glyphs before a path shift xterm columns vs JS string index. Paths are ASCII, but a preceding CJK run offsets
startX. Acceptable v1 caveat; note it. (Fixable later by walking cells; YAGNI now.) - URL/path overlap (
https://host/a.ts:5) —WebLinksAddonlinks the URL; the/-preceded guard stops the path provider from double-linking the tail. Verify the two providers don't both underline. provideLinksline indexing — passterminal.buffer.active.getLine(bufferLineNumber - 1)and setrange.{start,end}.y = bufferLineNumber(xterm gives a 1-based buffer row). Verify once in a real browser — the single indexing footgun.- Rapid clicks spawn N detached GUI processes on the host → in-flight boolean guard (+ optional 500 ms cooldown) on the frontend.
- Non-
codeeditor without--gotosupport →isGotoEditorreturns false → open bare file (never inject a stray--gotoargv the editor would treat as a filename). - Line-only false positives like
12:34(a timestamp) — filtered because the token needs a filename-with-extension before the:line.
Security
- Origin/CSRF: unchanged.
/open-in-editorstays behindrequireAllowedOrigin(src/server.ts:385,:352). The frontend POST is same-origin, so the browser sendsOriginand passes; a foreign page's no-preflight POST is rejected403. Integration test #3 asserts this on the new branch. - No shell / no injection:
openFileInEditorusesexecFilewith an argv array (aseditor.ts:51); the file path and<file>:<line>are argv elements, never a command line.lineis validated to an integer before interpolation, so${file}:${line}can't smuggle shell metacharacters via the line field. - Path containment: the resolved path is validated absolute + existing +
isFile()server-side. Terminal output is attacker-influenced (a malicious repo could print../../etc/hosts:1), but opening a file the user could alreadycatin the shell this app already grants adds no privilege (threat model: LAN, no auth, full shell). Optional hardening (note, not required v1): reject resolved paths that escape the sessioncwdroot. - URL activation hardening (the real new surface): custom
WebLinksAddonhandler (replacing the default at line 148) allowlists schemes tohttp:/https:/mailto:and opens withwindow.open(uri, '_blank', 'noopener,noreferrer')— blocksjavascript:/data:/file:URIs and prevents reverse-tabnabbing. Activation is gated on the click (a genuine user gesture); no hover/auto-open. - Rate-limit: frontend in-flight guard caps editor-spawn fan-out; the route's
express.json({limit:'4kb'})bounds body size. No new secrets, no logging of paths beyond the existingconsole.erroron failure.
Effort & dependencies
- Effort: ~1.5–2 days. Matcher + tests (0.5d), frontend wiring + jsdom tests + hardened URL handler (0.5d), server
openFileInEditor+ editor/integration tests (0.5d), manual browser verify of link indexing/overlap (0.25d). - Depends on: nothing — OSC-7
cwdcapture (terminal-session.ts:155–159), the/open-in-editorroute, and theWebLinksAddondependency all already exist. Self-contained, W1. - Unlocks / synergy: the Approval preview (W1, task #7) and diff viewer (W4, task #13) can reuse
findPathMatches+openFileInEditorto make paths in a diff/command preview clickable.link-paths.tsis deliberately a standalone pure module for that reuse. - Scope flag for the orchestrator: Option A adds a ~35-line server function (contradicting the brief's "no server change"). It is additive and backward-compatible, but it is a deviation — record it in
PROGRESS_LOG.md. If the "no server change" constraint is hard, ship Option B (folder-open fallback) and defer file:line jump.