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:
123
test/link-paths.test.ts
Normal file
123
test/link-paths.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* test/link-paths.test.ts — pure findPathMatches matcher (W1).
|
||||
*
|
||||
* Node environment (no DOM): the matcher is deliberately DOM-free so it can be
|
||||
* unit-tested directly and reused by later diff/preview views.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { findPathMatches, CODE_EXT } from '../public/link-paths.js'
|
||||
|
||||
describe('findPathMatches — file:line paths', () => {
|
||||
it('matches a src/app.ts:42 path with correct 1-based range', () => {
|
||||
const line = 'see src/app.ts:42 for'
|
||||
const matches = findPathMatches(line)
|
||||
expect(matches).toHaveLength(1)
|
||||
const m = matches[0]!
|
||||
expect(m.text).toBe('src/app.ts:42')
|
||||
expect(m.path).toBe('src/app.ts')
|
||||
expect(m.line).toBe(42)
|
||||
expect(m.column).toBeUndefined()
|
||||
// 'see ' = 4 chars → startX = 5 (1-based), endX inclusive = 5 + 13 - 1 = 17.
|
||||
expect(m.startX).toBe(5)
|
||||
expect(m.endX).toBe(17)
|
||||
expect(line.slice(m.startX - 1, m.endX)).toBe('src/app.ts:42')
|
||||
})
|
||||
|
||||
it('parses :line:col into line + column', () => {
|
||||
const matches = findPathMatches('./a/b.tsx:10:5')
|
||||
expect(matches).toHaveLength(1)
|
||||
const m = matches[0]!
|
||||
expect(m.path).toBe('./a/b.tsx')
|
||||
expect(m.line).toBe(10)
|
||||
expect(m.column).toBe(5)
|
||||
})
|
||||
|
||||
it('matches a bare filename with an allowlisted extension (README.md)', () => {
|
||||
const matches = findPathMatches('open README.md please')
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]!.path).toBe('README.md')
|
||||
expect(matches[0]!.line).toBeUndefined()
|
||||
})
|
||||
|
||||
it('matches main.rs:10 via its :line suffix', () => {
|
||||
const matches = findPathMatches('at main.rs:10')
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]!.path).toBe('main.rs')
|
||||
expect(matches[0]!.line).toBe(10)
|
||||
})
|
||||
|
||||
it('matches an absolute path with a leading slash', () => {
|
||||
const matches = findPathMatches('Error at /home/u/app.ts:42 now')
|
||||
expect(matches).toHaveLength(1)
|
||||
const m = matches[0]!
|
||||
expect(m.path).toBe('/home/u/app.ts')
|
||||
expect(m.line).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findPathMatches — non-paths are rejected', () => {
|
||||
it('does not match a bare domain (example.com)', () => {
|
||||
expect(findPathMatches('visit example.com today')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not match a version string (v1.2.3)', () => {
|
||||
expect(findPathMatches('version v1.2.3 released')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not match a foo.bar token (unknown extension, no slash/line)', () => {
|
||||
expect(findPathMatches('the foo.bar thing')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not match a bare timestamp (12:34)', () => {
|
||||
expect(findPathMatches('at 12:34 today')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findPathMatches — URL guard', () => {
|
||||
it('does not double-link the path tail of an http URL (WebLinksAddon owns it)', () => {
|
||||
expect(findPathMatches('https://host/path.html')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not match a path glued to a URL host (a.ts inside http://x/a.ts)', () => {
|
||||
expect(findPathMatches('http://example.com/src/a.ts')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findPathMatches — multiple + relative paths', () => {
|
||||
it('returns two disjoint matches for two paths on one line', () => {
|
||||
const matches = findPathMatches('edit src/a.ts:1 and lib/b.rs:2')
|
||||
expect(matches).toHaveLength(2)
|
||||
expect(matches[0]!.path).toBe('src/a.ts')
|
||||
expect(matches[1]!.path).toBe('lib/b.rs')
|
||||
// Ranges are disjoint and ordered.
|
||||
expect(matches[0]!.endX).toBeLessThan(matches[1]!.startX)
|
||||
})
|
||||
|
||||
it('keeps a ../ prefix in the path portion', () => {
|
||||
const matches = findPathMatches('see ../lib/util.ts:7')
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]!.path).toBe('../lib/util.ts')
|
||||
expect(matches[0]!.line).toBe(7)
|
||||
})
|
||||
|
||||
it('matches a nested config.test.ts filename (multi-dot) with the final extension', () => {
|
||||
const matches = findPathMatches('run test/config.test.ts')
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]!.path).toBe('test/config.test.ts')
|
||||
})
|
||||
|
||||
it('returns [] for a line with no paths', () => {
|
||||
expect(findPathMatches('just some plain words here')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CODE_EXT allowlist', () => {
|
||||
it('contains common code extensions and not arbitrary ones', () => {
|
||||
expect(CODE_EXT.has('ts')).toBe(true)
|
||||
expect(CODE_EXT.has('md')).toBe(true)
|
||||
expect(CODE_EXT.has('rs')).toBe(true)
|
||||
expect(CODE_EXT.has('com')).toBe(false)
|
||||
expect(CODE_EXT.has('bar')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user