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:
@@ -25,6 +25,9 @@
|
||||
*/
|
||||
|
||||
import net from 'node:net'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -103,6 +106,9 @@ function makeTestConfig(port: number, shellPath: string, maxPayloadBytes = 512 *
|
||||
IDLE_TTL: '86400',
|
||||
MAX_PAYLOAD_BYTES: String(maxPayloadBytes),
|
||||
USE_TMUX: '0', // default off so most cases don't spawn/leak tmux sessions
|
||||
// W1: /open-in-editor spawns EDITOR_CMD. Use the POSIX no-op so tests never
|
||||
// pop a real editor (and it's a non-goto basename → bare-file argv).
|
||||
EDITOR_CMD: 'true',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,6 +409,58 @@ describe('startServer — integration', () => {
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
// ── W1: POST /open-in-editor file mode + CSRF guard ────────────────────────
|
||||
// EDITOR_CMD is 'true' (see makeTestConfig) so no real editor is launched.
|
||||
// These don't need a PTY — they exercise the plain HTTP route + Origin guard.
|
||||
|
||||
it('W1: opens a FILE at a line with a valid Origin (204)', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oie-file-'))
|
||||
const file = path.join(dir, 'x.ts')
|
||||
await fs.writeFile(file, 'hi')
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` },
|
||||
body: JSON.stringify({ file, line: 3 }),
|
||||
})
|
||||
expect(res.status).toBe(204)
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('W1: rejects file mode from a foreign Origin (403 — CSRF guard covers the new branch)', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', Origin: 'http://evil.com' },
|
||||
body: JSON.stringify({ file: '/tmp/x.ts', line: 1 }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('W1: still supports directory mode (regression — Projects panel intact)', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'oie-dir-'))
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` },
|
||||
body: JSON.stringify({ path: dir }),
|
||||
})
|
||||
expect(res.status).toBe(204)
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('W1: 404 for a file that does not exist (validated server-side)', async () => {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/open-in-editor`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', Origin: `http://127.0.0.1:${port}` },
|
||||
body: JSON.stringify({ file: '/no/such/file/here.ts', line: 1 }),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
// ── ⑤ attach → attached → output (needs real PTY — sandbox-off) ──────────
|
||||
//
|
||||
// NOTE (sandbox-off): This test spawns a real shell process via node-pty.
|
||||
|
||||
Reference in New Issue
Block a user