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:
91
public/link-paths.ts
Normal file
91
public/link-paths.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import type { ITheme } from '@xterm/xterm'
|
||||
import type { ITheme, ILink, ILinkProvider, IDisposable } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
StatusTelemetry,
|
||||
} from '../src/types.js'
|
||||
import { folderFromTitle, cwdFromOsc7 } from './title-util.js'
|
||||
import { findPathMatches, type PathMatch } from './link-paths.js'
|
||||
|
||||
// Delay after the shell is ready before typing a session's initial command
|
||||
// (e.g. `claude --resume …`), giving the shell time to finish its prompt.
|
||||
@@ -47,6 +48,53 @@ function buildWsUrl(): string {
|
||||
return `${scheme}://${location.host}/term`
|
||||
}
|
||||
|
||||
/** W1: only these URL schemes may be opened from a clicked terminal link. */
|
||||
const WEB_LINK_SCHEMES: ReadonlySet<string> = new Set(['http:', 'https:', 'mailto:'])
|
||||
|
||||
/**
|
||||
* Open a URL clicked in the terminal (W1 hardening). Allowlists the scheme —
|
||||
* blocking `javascript:` / `data:` / `file:` URIs — and opens with
|
||||
* `noopener,noreferrer` to prevent reverse-tabnabbing. Exported for unit tests.
|
||||
*/
|
||||
export function openWebLink(uri: string): void {
|
||||
let protocol: string
|
||||
try {
|
||||
protocol = new URL(uri).protocol
|
||||
} catch {
|
||||
return // not a parseable absolute URL → ignore
|
||||
}
|
||||
if (!WEB_LINK_SCHEMES.has(protocol)) return
|
||||
window.open(uri, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/** POSIX absolute path check (host is macOS/Linux — see out-of-scope notes). */
|
||||
function isAbsolutePath(p: string): boolean {
|
||||
return p.startsWith('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path emitted in terminal output to an absolute path. Absolute paths
|
||||
* pass through; relative paths are joined onto `cwd` (from OSC-7) and normalized
|
||||
* (`.`/`..` collapsed). Returns null when the path is relative but no cwd is
|
||||
* known yet — the caller then declines to open and shows a status line.
|
||||
*/
|
||||
function resolvePath(cwd: string | null, rawPath: string): string | null {
|
||||
if (isAbsolutePath(rawPath)) return rawPath
|
||||
if (cwd === null) return null
|
||||
const base = cwd.endsWith('/') ? cwd.slice(0, -1) : cwd
|
||||
const segments = base.split('/')
|
||||
for (const seg of rawPath.split('/')) {
|
||||
if (seg === '' || seg === '.') continue
|
||||
if (seg === '..') {
|
||||
if (segments.length > 1) segments.pop()
|
||||
continue
|
||||
}
|
||||
segments.push(seg)
|
||||
}
|
||||
const joined = segments.join('/')
|
||||
return joined.startsWith('/') ? joined : `/${joined}`
|
||||
}
|
||||
|
||||
/** Connection state, surfaced as a colored status dot on the tab. */
|
||||
export type SessionStatus = 'connecting' | 'connected' | 'reconnecting' | 'exited'
|
||||
|
||||
@@ -112,6 +160,8 @@ export class TerminalSession {
|
||||
private resizeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private initialInputTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private exitListener: { dispose(): void } | null = null
|
||||
private linkProviderDisposable: IDisposable | null = null
|
||||
private openPathInFlight = false
|
||||
private isConnecting = false
|
||||
private disposed = false
|
||||
private lastCols = 0
|
||||
@@ -145,8 +195,13 @@ export class TerminalSession {
|
||||
this.term.loadAddon(this.fitAddon)
|
||||
this.searchAddon = new SearchAddon()
|
||||
this.term.loadAddon(this.searchAddon)
|
||||
this.term.loadAddon(new WebLinksAddon()) // M2: tap a URL Claude prints to open it
|
||||
// M2/W1: tap a URL Claude prints to open it — hardened handler (scheme
|
||||
// allowlist + noopener,noreferrer) instead of the addon's default activation.
|
||||
this.term.loadAddon(new WebLinksAddon((_event, uri) => openWebLink(uri)))
|
||||
this.term.open(this.el)
|
||||
// W1: clickable file paths (`src/app.ts:42`) → jump to file:line in the host
|
||||
// editor. Registered after open() so the linkifier service is available.
|
||||
this.linkProviderDisposable = this.term.registerLinkProvider(this.makePathLinkProvider())
|
||||
|
||||
this.term.onData((data) => this.send(data))
|
||||
// Tab title = current folder, derived from the shell's OSC title.
|
||||
@@ -378,6 +433,69 @@ export class TerminalSession {
|
||||
this.sendMsg({ type: 'input', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* W1: a link provider that turns file paths in terminal output into clickable
|
||||
* links. xterm passes a 1-based buffer row; getLine wants a 0-based index, and
|
||||
* the range's y is the same 1-based row.
|
||||
*/
|
||||
private makePathLinkProvider(): ILinkProvider {
|
||||
return {
|
||||
provideLinks: (bufferLineNumber, callback) => {
|
||||
const line = this.term.buffer.active.getLine(bufferLineNumber - 1)
|
||||
if (!line) {
|
||||
callback(undefined)
|
||||
return
|
||||
}
|
||||
const matches = findPathMatches(line.translateToString(true))
|
||||
if (matches.length === 0) {
|
||||
callback(undefined)
|
||||
return
|
||||
}
|
||||
const links: ILink[] = matches.map((match) => ({
|
||||
text: match.text,
|
||||
range: {
|
||||
start: { x: match.startX, y: bufferLineNumber },
|
||||
end: { x: match.endX, y: bufferLineNumber },
|
||||
},
|
||||
activate: () => this.openPath(match),
|
||||
}))
|
||||
callback(links)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a clicked file path in the host editor (W1). Relative paths are resolved
|
||||
* against the OSC-7 cwd; if no cwd is known the click is declined with a status
|
||||
* line. An in-flight guard stops rapid clicks from spawning many editor
|
||||
* processes. Failures are shown as a non-blocking status line, never thrown.
|
||||
*/
|
||||
private openPath(match: PathMatch): void {
|
||||
const abs = resolvePath(this.cwdValue, match.path)
|
||||
if (abs === null) {
|
||||
this.term.write(statusLine(`cannot open ${match.path}: working dir unknown`))
|
||||
return
|
||||
}
|
||||
if (this.openPathInFlight) return
|
||||
this.openPathInFlight = true
|
||||
const body: { file: string; line?: number } =
|
||||
match.line !== undefined ? { file: abs, line: match.line } : { file: abs }
|
||||
fetch('/open-in-editor', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) this.term.write(statusLine(`cannot open ${match.path} (${res.status})`))
|
||||
})
|
||||
.catch(() => {
|
||||
this.term.write(statusLine(`cannot open ${match.path}: request failed`))
|
||||
})
|
||||
.finally(() => {
|
||||
this.openPathInFlight = false
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve a held PermissionRequest (H3). Optionally relay a permission-mode
|
||||
* change (B4): mode is included only when explicitly provided so the server
|
||||
* can distinguish "approve with default" from "approve, keep existing mode". */
|
||||
@@ -464,6 +582,10 @@ export class TerminalSession {
|
||||
this.initialInputTimer = null
|
||||
}
|
||||
this.resizeObserver.disconnect()
|
||||
if (this.linkProviderDisposable !== null) {
|
||||
this.linkProviderDisposable.dispose()
|
||||
this.linkProviderDisposable = null
|
||||
}
|
||||
if (this.ws !== null) {
|
||||
try {
|
||||
this.ws.close()
|
||||
|
||||
Reference in New Issue
Block a user