diff --git a/app/src/components/terminal/TerminalView.test.tsx b/app/src/components/terminal/TerminalView.test.tsx index 723f0e8..78392ea 100644 --- a/app/src/components/terminal/TerminalView.test.tsx +++ b/app/src/components/terminal/TerminalView.test.tsx @@ -5,10 +5,12 @@ import TerminalView, { createOsc8LinkHandler, supersedes, } from "./TerminalView"; +import { Terminal } from "@xterm/xterm"; import { useAppState } from "../../store/appState"; import { uploadHostFileToTerminal, openUrlExternal, + openFileViewer, } from "../../lib/tauri-commands"; import { chooseSignInTarget, @@ -123,6 +125,7 @@ vi.mock("../../lib/tauri-commands", () => ({ getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge), checkBrowserViewSupport: vi.fn(async () => containerEnv.detection), openUrlExternal: vi.fn(async () => {}), + openFileViewer: vi.fn(async () => {}), })); vi.mock("@tauri-apps/api/event", () => ({ @@ -1606,6 +1609,58 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () => expect(hoverCard()).toBeNull(); }); + it("routes a file: target to the viewer and never to the opener", () => { + const onOpenFile = vi.fn(); + const h = createOsc8LinkHandler(() => host, () => state, onOpenFile); + h.activate(click(), "file:///workspace/p/src/a.ts", range); + expect(onOpenFile).toHaveBeenCalledWith("/workspace/p/src/a.ts"); + expect(openUrlExternal).not.toHaveBeenCalled(); + }); + + it("with non-http targets now delivered, still refuses javascript: and garbage", () => { + const onOpenFile = vi.fn(); + const h = createOsc8LinkHandler(() => host, () => state, onOpenFile); + h.activate(click(), "javascript:alert(1)", range); + h.activate(click(), "not a url", range); + h.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range); + expect(onOpenFile).not.toHaveBeenCalled(); + expect(openUrlExternal).not.toHaveBeenCalled(); + // The card, if any, is the refusal — never the target, never an offer to + // open it. (The brief asked for no card here; the existing test "says so + // on hover when the target would be refused" requires the refusal card.) + expect(hoverCard()?.textContent ?? "").not.toContain("javascript:"); + expect(hoverCard()?.textContent ?? "").not.toContain("Open in viewer"); + }); + + it("refuses a file: target whose escapes do not decode", () => { + const onOpenFile = vi.fn(); + const h = createOsc8LinkHandler(() => host, () => state, onOpenFile); + h.activate(click(), "file:///workspace/%E0%A4%A", range); + h.hover?.(new MouseEvent("mousemove"), "file:///workspace/%E0%A4%A", range); + expect(onOpenFile).not.toHaveBeenCalled(); + expect(hoverCard()?.textContent ?? "").not.toContain("Open in viewer"); + }); + + it("the file: hover card names the viewer and the path", () => { + const h = createOsc8LinkHandler(() => host, () => state, vi.fn()); + h.hover?.(new MouseEvent("mousemove"), "file:///workspace/p/README.md", range); + expect(hoverCard()?.textContent).toContain("Open in viewer"); + expect(hoverCard()?.textContent).toContain("/workspace/p/README.md"); + }); + + it("shows a relative path's card with the path as printed (preflight P6)", () => { + const h = createOsc8LinkHandler(() => host, () => state, vi.fn()); + h.showFileCard("src/foo.ts"); + expect(hoverCard()?.textContent).toContain("Open in viewer"); + expect(hoverCard()?.textContent).toContain("src/foo.ts"); + h.dismiss(); + expect(hoverCard()).toBeNull(); + }); + + it("declares allowNonHttpProtocols so file: targets reach it", () => { + expect(createOsc8LinkHandler(() => host, () => state).allowNonHttpProtocols).toBe(true); + }); + it("pushes the shared toast when the host opener fails", async () => { vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener")); @@ -1659,6 +1714,66 @@ describe("the link handler is wired into the terminal, and reads its live mode", // The gate has to ask the terminal, not a boolean captured at construction: // the mode changes whenever the container prints a DECSET, which is several // times a second in Claude Code. + it("opens a file: target in the viewer against the session's project", async () => { + vi.mocked(openFileViewer).mockClear(); + mountSession("bash"); + + await act(async () => { + wiredHandler().activate( + new MouseEvent("click", { button: 0, detail: 1 }), + "file:///workspace/api/src/a%20b.ts", + range, + ); + await Promise.resolve(); + }); + + expect(openFileViewer).toHaveBeenCalledWith("p1", "/workspace/api/src/a b.ts"); + expect(openUrlExternal).not.toHaveBeenCalled(); + }); + + it("registers the file-path provider, which opens the viewer at the matched line", async () => { + vi.mocked(openFileViewer).mockClear(); + const register = vi.spyOn(Terminal.prototype, "registerLinkProvider"); + try { + mountSession("bash"); + const provider = register.mock.calls.at(-1)?.[0]; + if (!provider) throw new Error("no link provider was registered"); + + await write("Edited src/foo.ts:42 today"); + const links = vi.fn(); + provider.provideLinks(1, links); + const [link] = links.mock.calls[0][0]; + expect(link.text).toBe("src/foo.ts:42"); + + await act(async () => { + link.activate(new MouseEvent("click", { button: 0, detail: 1 }), link.text); + await Promise.resolve(); + }); + expect(openFileViewer).toHaveBeenCalledWith("p1", "src/foo.ts", 42, undefined, undefined); + } finally { + register.mockRestore(); + } + }); + + it("says so in a toast when the viewer refuses to open", async () => { + vi.mocked(openFileViewer).mockRejectedValueOnce(new Error("No such file: x.ts")); + mountSession("bash"); + + await act(async () => { + wiredHandler().activate( + new MouseEvent("click", { button: 0, detail: 1 }), + "file:///x.ts", + range, + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + const toasts = useAppState.getState().toasts; + expect(toasts.at(-1)?.detail).toContain("No such file: x.ts"); + expect(toasts.at(-1)?.dedupeKey).toBe("file-viewer-open"); + }); + it("refuses a plain click once the container turns mouse tracking on", async () => { mountSession("claude"); await write("\x1b[?1002h"); @@ -1695,10 +1810,9 @@ describe("the link handler is wired into the terminal, and reads its live mode", * * `WebLinksAddon` matches rendered text and activates through the same * `Linkifier._handleMouseUp`, with the same absence of any check. It also - * picks up links the OSC 8 handler never sees: `OscLinkProvider` drops a - * non-http(s) hyperlink target before `linkHandler` is reached, which leaves - * the addon free to match the *label* — so an OSC 8 with a `javascript:` - * target and an `https://evil.tld/x` label arrives here and nowhere else. + * covers the plain-text URLs that carry no OSC 8 parameter at all. (Since the + * file viewer, `allowNonHttpProtocols` is on, so every OSC 8 target reaches + * the OSC 8 handler, which refuses anything but `file:` and `http(s):`.) * Both routes end at `openUrlExternal`, so both ask the same question first. */ describe("the plain-text URL path is gated the same way", () => { diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index d2ab87b..80ca1b7 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -9,6 +9,7 @@ import { useAppState } from "../../store/appState"; import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput"; import { awsSsoRefresh, + openFileViewer, openPageInContainerBrowser, openUrlExternal, uploadHostFileToTerminal, @@ -33,6 +34,8 @@ import UrlToast, { import { trimSelection } from "./trimSelection"; import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer"; import TerminalContextMenu from "./TerminalContextMenu"; +import { createFilePathLinkProvider } from "./filePathLinkProvider"; +import type { FilePathMatch } from "../../lib/filePathLinks"; interface Props { sessionId: string; @@ -137,6 +140,16 @@ function reportOpenFailure(e: unknown) { }); } +/** Report a file-viewer open that the backend refused (not found, no container, cap). */ +function reportViewerFailure(e: unknown): void { + useAppState.getState().pushToast({ + kind: "error", + message: "Could not open the file", + detail: e instanceof Error ? e.message : String(e), + dedupeKey: "file-viewer-open", + }); +} + /** * `ILinkHandler`, plus the one thing xterm never asks for. * @@ -146,7 +159,43 @@ function reportOpenFailure(e: unknown) { * pane and is still there when the user comes back. {@link dismiss} is how the * view says "this pane is gone" without pretending to be a mouse event. */ -export type Osc8LinkHandler = ILinkHandler & { dismiss(): void }; +export type Osc8LinkHandler = ILinkHandler & { + dismiss(): void; + /** + * Draw the "Open in viewer" card for a path matched in plain text by the + * file-path link provider. Takes the raw path (a relative path stays + * relative — `file://src/x` would parse `src` as a host), and leaves + * `modifierPromised` alone: the provider applies its own gate. + */ + showFileCard(path: string): void; +}; + +/** + * What an OSC 8 target is, now that `allowNonHttpProtocols` delivers every + * scheme here. `null` is anything this pane refuses: unparseable, a scheme + * other than `file:`/`http(s):`, or a `file:` path whose escapes do not decode. + * The host of a `file:` URL is ignored — `ls --hyperlink` writes the machine's + * hostname there, and the viewer only ever reads the container. + */ +function classifyOsc8Target( + text: string, +): { kind: "file"; path: string } | { kind: "web" } | null { + let parsed: URL; + try { + parsed = new URL(text); + } catch { + return null; + } + if (parsed.protocol === "file:") { + try { + return { kind: "file", path: decodeURIComponent(parsed.pathname) }; + } catch { + return null; + } + } + if (parsed.protocol === "http:" || parsed.protocol === "https:") return { kind: "web" }; + return null; +} /** * Is a program holding the mouse? @@ -397,10 +446,14 @@ function opensOnClick( * @param readState samples {@link ClickContext} — a getter for the same * reason, and the *only* reason: every one of those answers changes * under us, between the hover and the click that follows it. + * @param onOpenFile receives the decoded path of a `file:` target, which + * opens in the file viewer rather than the host browser. Without it a + * `file:` target is inert. */ export function createOsc8LinkHandler( getHost: () => HTMLElement | null, readState: () => ClickContext, + onOpenFile?: (path: string) => void, ): Osc8LinkHandler { let card: HTMLDivElement | null = null; /** @@ -428,6 +481,64 @@ export function createOsc8LinkHandler( return el; }; + const makeCard = (): HTMLDivElement => { + const el = document.createElement("div"); + el.className = OSC8_HOVER_CLASS; + el.dataset.testid = "osc8-hover"; + Object.assign(el.style, { + position: "absolute", + left: "8px", + bottom: "8px", + maxWidth: "calc(100% - 16px)", + boxSizing: "border-box", + zIndex: "30", + // The card lands under the pointer for a link in the bottom rows, and + // it is not a sibling the Linkifier hit-tests around (see + // `OSC8_HOVER_CLASS`). Without this, `screenElement` gets `mouseleave` + // the moment the card appears — card removed, pointer back on the + // link, card back: a flicker loop — and worse, the `mouseup` that + // activates the link lands on the card, so the link cannot be opened + // at all. Nothing here is interactive, so nothing is lost. + pointerEvents: "none", + display: "flex", + alignItems: "baseline", + gap: "6px", + padding: "3px 8px", + fontSize: "12px", + fontFamily: "monospace", + background: "var(--bg-secondary)", + border: "1px solid var(--border-color)", + // The origin wraps, so the card grows downward rather than sideways; + // this is the backstop for anything that still cannot fit. + overflow: "hidden", + borderRadius: "6px", + boxShadow: "var(--shadow-overlay)", + color: "var(--text-primary)", + } as Partial); + return el; + }; + + /** "Open in viewer", the path, and the same hint line as a web link. */ + const fillFileCard = (el: HTMLDivElement, path: string, ctx: ClickContext) => { + el.appendChild(span("Open in viewer", { fontWeight: "700", flexShrink: "0" })); + const pathEl = span(path, { + color: "var(--text-secondary)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + minWidth: "0", + }); + pathEl.dataset.testid = "osc8-hover-path"; + el.appendChild(pathEl); + el.appendChild( + span(openHintLabel(ctx), { + color: "var(--text-secondary)", + flexShrink: "0", + marginLeft: "4px", + }), + ); + }; + return { activate(event, text) { // Opening the host browser is the one thing in this pane the container @@ -435,6 +546,16 @@ export function createOsc8LinkHandler( // may provoke by accident. See `opensOnClick` — including the residual // it does not close. if (!opensOnClick(event, readState(), modifierPromised)) return; + const target = classifyOsc8Target(text); + if (!target) { + console.warn("Refusing to open a link with an unsupported or malformed target"); + return; + } + if (target.kind === "file") { + if (!onOpenFile) return; + onOpenFile(target.path); + return; + } // Same sink and same rule as the WebLinksAddon branch: this came off the // container's output, so it is validated before it reaches the OS // opener. One implementation — `sanitizeRelayUrl` — on purpose. @@ -456,39 +577,16 @@ export function createOsc8LinkHandler( // moved on by the time they click. modifierPromised = ctx.mouseTracking; - card = document.createElement("div"); - card.className = OSC8_HOVER_CLASS; - card.dataset.testid = "osc8-hover"; - Object.assign(card.style, { - position: "absolute", - left: "8px", - bottom: "8px", - maxWidth: "calc(100% - 16px)", - boxSizing: "border-box", - zIndex: "30", - // The card lands under the pointer for a link in the bottom rows, and - // it is not a sibling the Linkifier hit-tests around (see - // `OSC8_HOVER_CLASS`). Without this, `screenElement` gets `mouseleave` - // the moment the card appears — card removed, pointer back on the - // link, card back: a flicker loop — and worse, the `mouseup` that - // activates the link lands on the card, so the link cannot be opened - // at all. Nothing here is interactive, so nothing is lost. - pointerEvents: "none", - display: "flex", - alignItems: "baseline", - gap: "6px", - padding: "3px 8px", - fontSize: "12px", - fontFamily: "monospace", - background: "var(--bg-secondary)", - border: "1px solid var(--border-color)", - // The origin wraps, so the card grows downward rather than sideways; - // this is the backstop for anything that still cannot fit. - overflow: "hidden", - borderRadius: "6px", - boxShadow: "var(--shadow-overlay)", - color: "var(--text-primary)", - } as Partial); + card = makeCard(); + + const target = classifyOsc8Target(text); + // Without a viewer to hand it to, a `file:` target falls through to the + // refusal card below rather than offering something the click won't do. + if (target?.kind === "file" && onOpenFile) { + fillFileCard(card, target.path, ctx); + host.appendChild(card); + return; + } const safe = sanitizeRelayUrl(text); const origin = safe && urlOrigin(safe); @@ -549,6 +647,17 @@ export function createOsc8LinkHandler( leave: clear, dismiss: clear, + + showFileCard(path) { + clear(); + const host = getHost(); + if (!host) return; + card = makeCard(); + fillFileCard(card, path, readState()); + host.appendChild(card); + }, + + allowNonHttpProtocols: true, }; } @@ -573,6 +682,13 @@ export default function TerminalView({ sessionId, active }: Props) { const projectId = useAppState( (s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId ); + // The file viewer opens against the session's project. Read through a ref + // because the link handlers are built in the mount effect, keyed on + // `sessionId` only, and the session record can arrive after the first render. + const projectIdRef = useRef(projectId); + useEffect(() => { + projectIdRef.current = projectId; + }, [projectId]); // Which program is on the other end of the PTY. Read through a ref because // the key handler is registered once, in the mount effect keyed on @@ -882,6 +998,11 @@ export default function TerminalView({ sessionId, active }: Props) { linkHandler: (osc8LinkHandlerRef.current = createOsc8LinkHandler( () => term.element ?? null, () => readClickContext(term), + (path) => { + if (projectIdRef.current) { + openFileViewer(projectIdRef.current, path).catch(reportViewerFailure); + } + }, )), fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace", theme: { @@ -924,12 +1045,10 @@ export default function TerminalView({ sessionId, active }: Props) { // got a browser open on a plain click while it held the mouse, and a // double-click that merely selected a URL opened it. // - // It is also the only gate on a real bypass of the OSC 8 one: - // `OscLinkProvider` drops a hyperlink whose target is not http(s) - // *before* `linkHandler` sees it (`allowNonHttpProtocols` is unset), so - // an OSC 8 carrying a `javascript:` target and an `https://evil.tld/x` - // label leaves the addon free to match the label. That click arrives - // here and nowhere else. + // `allowNonHttpProtocols` is now **on**, so `OscLinkProvider` hands every + // OSC 8 target to `createOsc8LinkHandler`, which parses it and refuses + // anything but `file:` (the file viewer) and `http(s):` itself. This + // branch remains the only handler for plain-text URLs. // // No `modifierPromised`: this path paints an underline rather than a // card, so it promises the user nothing to be held to. @@ -952,6 +1071,26 @@ export default function TerminalView({ sessionId, active }: Props) { }, { urlRegex }); term.loadAddon(webLinksAddon); + // File paths in plain text open in the file viewer. Registered after the + // addon so URLs are claimed first; the matcher also refuses anything + // inside a `scheme://` span. Same gate as the URL branch above. The hover + // card is the OSC 8 handler's, fed the raw path (see `showFileCard`). + const filePathLinks = term.registerLinkProvider( + createFilePathLinkProvider( + term, + (m: FilePathMatch) => { + const pid = projectIdRef.current; + if (!pid) return; + openFileViewer(pid, m.path, m.line, m.col, m.endLine).catch(reportViewerFailure); + }, + (event) => opensOnClick(event, readClickContext(term)), + { + show: (path) => osc8LinkHandlerRef.current?.showFileCard(path), + hide: () => osc8LinkHandlerRef.current?.dismiss(), + }, + ), + ); + term.open(containerRef.current); // Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding @@ -1235,6 +1374,7 @@ export default function TerminalView({ sessionId, active }: Props) { resizeObserver.disconnect(); try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } webglRef.current = null; + filePathLinks.dispose(); term.dispose(); termRef.current = null; osc8LinkHandlerRef.current = null; diff --git a/app/src/components/terminal/filePathLinkProvider.test.ts b/app/src/components/terminal/filePathLinkProvider.test.ts new file mode 100644 index 0000000..43119c3 --- /dev/null +++ b/app/src/components/terminal/filePathLinkProvider.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFilePathLinkProvider } from "./filePathLinkProvider"; + +const fakeTerm = (rows: Array<[string, boolean]>) => ({ + buffer: { + active: { + getLine: (y: number) => rows[y] && { isWrapped: rows[y][1], translateToString: () => rows[y][0] }, + }, + }, +}) as unknown as Parameters[0]; + +describe("createFilePathLinkProvider", () => { + it("reports 1-based inclusive ranges and activates through the gate", () => { + const onOpen = vi.fn(); + const gate = vi.fn(() => true); + const provider = createFilePathLinkProvider(fakeTerm([["Edited src/foo.ts:42 today", false]]), onOpen, gate); + const links = vi.fn(); + provider.provideLinks(1, links); + const [list] = links.mock.calls[0]; + expect(list).toHaveLength(1); + expect(list[0].range).toEqual({ start: { x: 8, y: 1 }, end: { x: 20, y: 1 } }); + expect(list[0].text).toBe("src/foo.ts:42"); + list[0].activate(new MouseEvent("click"), list[0].text); + expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ path: "src/foo.ts", line: 42 })); + }); + + it("does nothing when the gate refuses", () => { + const onOpen = vi.fn(); + const provider = createFilePathLinkProvider(fakeTerm([["src/foo.ts", false]]), onOpen, () => false); + const links = vi.fn(); + provider.provideLinks(1, links); + links.mock.calls[0][0][0].activate(new MouseEvent("click"), "src/foo.ts"); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("spans a wrapped path across rows", () => { + const provider = createFilePathLinkProvider(fakeTerm([["see /workspace/p/", false], ["src/foo.ts:7", true]]), vi.fn(), () => true); + const links = vi.fn(); + provider.provideLinks(2, links); + expect(links.mock.calls[0][0][0].range).toEqual({ start: { x: 5, y: 1 }, end: { x: 12, y: 2 } }); + }); + + it("answers undefined for a row with nothing", () => { + const provider = createFilePathLinkProvider(fakeTerm([["plain words", false]]), vi.fn(), () => true); + const links = vi.fn(); + provider.provideLinks(1, links); + expect(links).toHaveBeenCalledWith(undefined); + }); + + it("answers undefined for a row past the end of the buffer", () => { + const provider = createFilePathLinkProvider(fakeTerm([["src/foo.ts", false]]), vi.fn(), () => true); + const links = vi.fn(); + provider.provideLinks(5, links); + expect(links).toHaveBeenCalledWith(undefined); + }); + + it("hands the hover the raw path, relative as printed (preflight P6)", () => { + const hover = { show: vi.fn(), hide: vi.fn() }; + const provider = createFilePathLinkProvider( + fakeTerm([["Edited src/foo.ts:42 today", false]]), vi.fn(), () => true, hover, + ); + const links = vi.fn(); + provider.provideLinks(1, links); + const link = links.mock.calls[0][0][0]; + link.hover(new MouseEvent("mousemove"), link.text); + expect(hover.show).toHaveBeenCalledWith("src/foo.ts"); + link.leave(new MouseEvent("mouseout"), link.text); + expect(hover.hide).toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/terminal/filePathLinkProvider.ts b/app/src/components/terminal/filePathLinkProvider.ts new file mode 100644 index 0000000..e6fbda5 --- /dev/null +++ b/app/src/components/terminal/filePathLinkProvider.ts @@ -0,0 +1,52 @@ +/** + * xterm `ILinkProvider` for file paths in the buffer. + * + * Registered after `WebLinksAddon` so URLs are claimed first; `findFilePathLinks` + * also refuses anything inside a `scheme://` span, so the two never overlap. + * Ranges are 1-based on both axes with an *inclusive* end column (xterm's + * contract), and `provideLinks`' row is 1-based while `getLine` is 0-based. + */ +import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm"; +import { findFilePathLinks, type FilePathMatch } from "../../lib/filePathLinks"; +import { joinWrappedRows, offsetToCell } from "../../lib/xtermLineJoin"; + +export interface FilePathHover { + /** `path` is the raw matched path — relative paths stay relative. */ + show(path: string): void; + hide(): void; +} + +export function createFilePathLinkProvider( + term: Pick, + onOpen: (match: FilePathMatch) => void, + gate: (event: MouseEvent) => boolean, + hover?: FilePathHover, +): ILinkProvider { + return { + provideLinks(bufferLineNumber, callback) { + const row = bufferLineNumber - 1; + const line = term.buffer.active.getLine(row); + if (!line) return callback(undefined); + const joined = joinWrappedRows(term.buffer.active, row); + // `offsetToCell` has no row to map onto when nothing was joined. + if (joined.rowStarts.length === 0) return callback(undefined); + const matches = findFilePathLinks(joined.text); + if (matches.length === 0) return callback(undefined); + const links: ILink[] = matches + .map((m): ILink => ({ + range: { start: offsetToCell(joined, m.start), end: offsetToCell(joined, m.end - 1) }, + text: joined.text.slice(m.start, m.end), + decorations: { pointerCursor: true, underline: true }, + activate: (event) => { + if (!gate(event)) return; + onOpen(m); + }, + hover: () => hover?.show(m.path), + leave: () => hover?.hide(), + })) + // Only links that touch the row being asked about (xterm asks per row). + .filter((l) => l.range.start.y <= bufferLineNumber && l.range.end.y >= bufferLineNumber); + callback(links.length ? links : undefined); + }, + }; +}