Terminal file viewer/editor + per-window app-command lockdown #60

Merged
jknapp merged 37 commits from feat/terminal-file-viewer into main 2026-09-23 17:05:50 +00:00
4 changed files with 420 additions and 44 deletions
Showing only changes of commit f970c4e895 - Show all commits
@@ -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", () => {
+180 -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,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<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 +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<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 +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<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 +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;
@@ -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);
},
};
}