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
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:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user