Terminal file viewer/editor + per-window app-command lockdown #60
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user