Terminal file viewer/editor + per-window app-command lockdown (#60)
Build App / compute-version (push) Successful in 7s
Secret Scan / scan (push) Successful in 8s
Build App / build-macos (push) Successful in 2m53s
Build App / build-linux (push) Successful in 5m12s
Build App / build-windows (push) Successful in 5m15s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 1m5s

Clicking a file path in Claude's terminal output now opens the file in its own window with a CodeMirror 6 editor. The editor highlights the target line, live-reloads while the file changes, and saves explicitly with hash-based conflict detection. The viewer commands are gated by window label.

Every app command is now ACL-gated per window through a Tauri AppManifest. build.rs checks the handler list against the capability files and fails the build on any mismatch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #60.
This commit is contained in:
2026-09-23 17:05:50 +00:00
co-authored by Claude Opus 5.5
parent 3537b234d8
commit 8305c96e20
65 changed files with 15043 additions and 108 deletions
@@ -3,6 +3,7 @@ import {
IMAGE_PREVIEW_LIMIT,
TEXT_PREVIEW_LIMIT,
decodeBase64,
encodeBase64,
extensionOf,
imageMimeFor,
looksBinary,
@@ -76,3 +77,22 @@ describe("decodeBase64 / looksBinary", () => {
expect(looksBinary(bytes)).toBe(false);
});
});
describe("encodeBase64", () => {
it("matches btoa on a small input", () => {
expect(encodeBase64(new Uint8Array([0xff, 0xd8, 0x00, 0x41]))).toBe(btoa("\xff\xd8\x00\x41"));
});
it("round-trips 1 MiB without overflowing the call stack", () => {
// Spreading a 1 MiB array into String.fromCharCode throws RangeError in V8.
const bytes = new Uint8Array(TEXT_PREVIEW_LIMIT);
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 31 + 7) & 0xff;
const back = decodeBase64(encodeBase64(bytes));
expect(back.length).toBe(bytes.length);
expect(back.every((b, i) => b === bytes[i])).toBe(true);
});
it("encodes an empty input as the empty string", () => {
expect(encodeBase64(new Uint8Array(0))).toBe("");
});
});
@@ -96,6 +96,19 @@ export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
return bytes;
}
/**
* Bytes → base64. Built 32 KiB at a time: spreading a whole buffer into
* `String.fromCharCode` overflows the argument limit well below 1 MiB.
*/
export function encodeBase64(bytes: Uint8Array): string {
const CHUNK = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
/**
* The classic heuristic: a NUL byte early on means this is not text. Cheap,
* and it is what `git` and `grep` use to decide the same question.
@@ -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,78 @@ 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 refusal card (ruling (a)): present, no origin, never the target,
// never an offer to open it.
const card = hoverCard();
expect(card).not.toBeNull();
expect(card!.querySelector('[data-testid="osc8-hover-origin"]')).toBeNull();
expect(card!.textContent).toContain("will not be opened");
expect(card!.textContent).not.toContain("javascript:");
expect(card!.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();
const card = hoverCard();
expect(card).not.toBeNull();
expect(card!.querySelector('[data-testid="osc8-hover-origin"]')).toBeNull();
expect(card!.textContent).toContain("will not be opened");
expect(card!.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("holds a file-path click to the modifier its card promised", () => {
const h = createOsc8LinkHandler(() => host, () => state, vi.fn());
state.mouseTracking = true;
h.showFileCard("src/foo.ts");
expect(hoverCard()?.textContent).toContain("Shift+click");
state.mouseTracking = false;
expect(h.opensFileLink(click())).toBe(false);
expect(h.opensFileLink(click({ shiftKey: true }))).toBe(true);
// A card drawn with nothing tracking promises nothing.
h.showFileCard("src/foo.ts");
expect(h.opensFileLink(click())).toBe(true);
});
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"));
@@ -1656,6 +1731,124 @@ describe("the link handler is wired into the terminal, and reads its live mode",
expect(typeof handler.hover).toBe("function");
});
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", undefined, undefined, undefined,
);
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("holds a plain-text file link to the modifier its hover card promised", async () => {
vi.mocked(openFileViewer).mockClear();
const platform = navigator.platform;
Object.defineProperty(navigator, "platform", { value: "Linux x86_64", configurable: true });
const register = vi.spyOn(Terminal.prototype, "registerLinkProvider");
try {
mountSession("claude");
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];
await write("\x1b[?1002h");
link.hover?.(new MouseEvent("mousemove"), link.text);
expect(document.body.textContent).toContain("Shift+click to open");
await write("\x1b[?1002l");
await act(async () => {
link.activate(new MouseEvent("click", { button: 0, detail: 1 }), link.text);
await Promise.resolve();
});
expect(openFileViewer).not.toHaveBeenCalled();
await act(async () => {
link.activate(
new MouseEvent("click", { button: 0, detail: 1, shiftKey: true }),
link.text,
);
await Promise.resolve();
});
expect(openFileViewer).toHaveBeenCalledWith("p1", "src/foo.ts", 42, undefined, undefined);
} finally {
register.mockRestore();
Object.defineProperty(navigator, "platform", { value: platform, configurable: true });
}
});
it("says the project is not ready rather than doing nothing", async () => {
vi.mocked(openFileViewer).mockClear();
useAppState.setState({ sessions: [] });
render(<TerminalView sessionId="s-unknown" active />);
wiredHandler().activate(
new MouseEvent("click", { button: 0, detail: 1 }),
"file:///workspace/api/README.md",
range,
);
expect(openFileViewer).not.toHaveBeenCalled();
const toasts = useAppState.getState().toasts;
expect(toasts.at(-1)?.detail).toContain("not ready yet");
expect(toasts.at(-1)?.dedupeKey).toBe("file-viewer-open");
});
// 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.
@@ -1695,10 +1888,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", () => {
+213 -40
View File
@@ -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,40 @@ function reportOpenFailure(e: unknown) {
});
}
/**
* Open a path in the file viewer for this terminal's project, or say why not.
* The session record (and so the project) can arrive after the first render;
* a click in that window gets a toast rather than silently doing nothing.
*/
function openInViewer(
projectId: string | undefined,
path: string,
line?: number,
col?: number,
endLine?: number,
): void {
if (!projectId) {
useAppState.getState().pushToast({
kind: "error",
message: "Could not open the file",
detail: "This terminal's project is not ready yet",
dedupeKey: "file-viewer-open",
});
return;
}
openFileViewer(projectId, path, line, col, endLine).catch(reportViewerFailure);
}
/** 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 +183,50 @@ 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). Records the
* card's modifier promise exactly as `hover` does, so
* {@link opensFileLink} can hold the click to it.
*/
showFileCard(path: string): void;
/**
* The file-path provider's click gate: {@link opensOnClick} with whatever
* the card on screen promised. There is only ever one card, and `clear()`
* resets the promise with it, so sharing the flag with OSC 8 links is exact.
*/
opensFileLink(event: MouseEvent): boolean;
};
/**
* 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 +477,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 +512,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<CSSStyleDeclaration>);
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 +577,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 +608,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<CSSStyleDeclaration>);
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 +678,25 @@ export function createOsc8LinkHandler(
leave: clear,
dismiss: clear,
showFileCard(path) {
clear();
const host = getHost();
if (!host) return;
const ctx = readState();
// Same promise as `hover`: the card names a modifier, so the click is
// held to it even if the container drops tracking before it lands.
modifierPromised = ctx.mouseTracking;
card = makeCard();
fillFileCard(card, path, ctx);
host.appendChild(card);
},
opensFileLink(event) {
return opensOnClick(event, readState(), modifierPromised);
},
allowNonHttpProtocols: true,
};
}
@@ -573,6 +721,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<string | undefined>(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 +1037,7 @@ export default function TerminalView({ sessionId, active }: Props) {
linkHandler: (osc8LinkHandlerRef.current = createOsc8LinkHandler(
() => term.element ?? null,
() => readClickContext(term),
(path) => openInViewer(projectIdRef.current, path),
)),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
theme: {
@@ -924,12 +1080,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 +1106,24 @@ 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. The hover card is the OSC 8 handler's, fed the
// raw path, and the click gate honours the modifier that card promised.
const filePathLinks = term.registerLinkProvider(
createFilePathLinkProvider(
term,
(m: FilePathMatch) =>
openInViewer(projectIdRef.current, m.path, m.line, m.col, m.endLine),
// Held to what the card promised, like OSC 8 links (see `showFileCard`).
(event) => osc8LinkHandlerRef.current?.opensFileLink(event) ?? false,
{
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 +1407,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;
@@ -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<typeof createFilePathLinkProvider>[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();
});
});
@@ -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<Terminal, "buffer">,
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);
},
};
}