feat(viewer): clickable file paths and file: hyperlinks in the terminal
Registers a file-path ILinkProvider (after WebLinksAddon) that opens the file viewer at the matched line, and turns on allowNonHttpProtocols so OSC 8 file: targets reach createOsc8LinkHandler, which now parses every target and refuses anything but file: and http(s):. The hover card gains an "Open in viewer" variant, exposed as showFileCard(rawPath) so relative paths are shown as printed (preflight P6). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,12 @@ import TerminalView, {
|
|||||||
createOsc8LinkHandler,
|
createOsc8LinkHandler,
|
||||||
supersedes,
|
supersedes,
|
||||||
} from "./TerminalView";
|
} from "./TerminalView";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import {
|
import {
|
||||||
uploadHostFileToTerminal,
|
uploadHostFileToTerminal,
|
||||||
openUrlExternal,
|
openUrlExternal,
|
||||||
|
openFileViewer,
|
||||||
} from "../../lib/tauri-commands";
|
} from "../../lib/tauri-commands";
|
||||||
import {
|
import {
|
||||||
chooseSignInTarget,
|
chooseSignInTarget,
|
||||||
@@ -123,6 +125,7 @@ vi.mock("../../lib/tauri-commands", () => ({
|
|||||||
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
|
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
|
||||||
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
|
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
|
||||||
openUrlExternal: vi.fn(async () => {}),
|
openUrlExternal: vi.fn(async () => {}),
|
||||||
|
openFileViewer: vi.fn(async () => {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/event", () => ({
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
@@ -1606,6 +1609,58 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
|||||||
expect(hoverCard()).toBeNull();
|
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 () => {
|
it("pushes the shared toast when the host opener fails", async () => {
|
||||||
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
|
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 gate has to ask the terminal, not a boolean captured at construction:
|
||||||
// the mode changes whenever the container prints a DECSET, which is several
|
// the mode changes whenever the container prints a DECSET, which is several
|
||||||
// times a second in Claude Code.
|
// 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 () => {
|
it("refuses a plain click once the container turns mouse tracking on", async () => {
|
||||||
mountSession("claude");
|
mountSession("claude");
|
||||||
await write("\x1b[?1002h");
|
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
|
* `WebLinksAddon` matches rendered text and activates through the same
|
||||||
* `Linkifier._handleMouseUp`, with the same absence of any check. It also
|
* `Linkifier._handleMouseUp`, with the same absence of any check. It also
|
||||||
* picks up links the OSC 8 handler never sees: `OscLinkProvider` drops a
|
* covers the plain-text URLs that carry no OSC 8 parameter at all. (Since the
|
||||||
* non-http(s) hyperlink target before `linkHandler` is reached, which leaves
|
* file viewer, `allowNonHttpProtocols` is on, so every OSC 8 target reaches
|
||||||
* the addon free to match the *label* — so an OSC 8 with a `javascript:`
|
* the OSC 8 handler, which refuses anything but `file:` and `http(s):`.)
|
||||||
* target and an `https://evil.tld/x` label arrives here and nowhere else.
|
|
||||||
* Both routes end at `openUrlExternal`, so both ask the same question first.
|
* Both routes end at `openUrlExternal`, so both ask the same question first.
|
||||||
*/
|
*/
|
||||||
describe("the plain-text URL path is gated the same way", () => {
|
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 { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
|
||||||
import {
|
import {
|
||||||
awsSsoRefresh,
|
awsSsoRefresh,
|
||||||
|
openFileViewer,
|
||||||
openPageInContainerBrowser,
|
openPageInContainerBrowser,
|
||||||
openUrlExternal,
|
openUrlExternal,
|
||||||
uploadHostFileToTerminal,
|
uploadHostFileToTerminal,
|
||||||
@@ -33,6 +34,8 @@ import UrlToast, {
|
|||||||
import { trimSelection } from "./trimSelection";
|
import { trimSelection } from "./trimSelection";
|
||||||
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||||
import TerminalContextMenu from "./TerminalContextMenu";
|
import TerminalContextMenu from "./TerminalContextMenu";
|
||||||
|
import { createFilePathLinkProvider } from "./filePathLinkProvider";
|
||||||
|
import type { FilePathMatch } from "../../lib/filePathLinks";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
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.
|
* `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
|
* 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.
|
* 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?
|
* Is a program holding the mouse?
|
||||||
@@ -397,10 +446,14 @@ function opensOnClick(
|
|||||||
* @param readState samples {@link ClickContext} — a getter for the same
|
* @param readState samples {@link ClickContext} — a getter for the same
|
||||||
* reason, and the *only* reason: every one of those answers changes
|
* reason, and the *only* reason: every one of those answers changes
|
||||||
* under us, between the hover and the click that follows it.
|
* 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(
|
export function createOsc8LinkHandler(
|
||||||
getHost: () => HTMLElement | null,
|
getHost: () => HTMLElement | null,
|
||||||
readState: () => ClickContext,
|
readState: () => ClickContext,
|
||||||
|
onOpenFile?: (path: string) => void,
|
||||||
): Osc8LinkHandler {
|
): Osc8LinkHandler {
|
||||||
let card: HTMLDivElement | null = null;
|
let card: HTMLDivElement | null = null;
|
||||||
/**
|
/**
|
||||||
@@ -428,6 +481,64 @@ export function createOsc8LinkHandler(
|
|||||||
return el;
|
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 {
|
return {
|
||||||
activate(event, text) {
|
activate(event, text) {
|
||||||
// Opening the host browser is the one thing in this pane the container
|
// 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
|
// may provoke by accident. See `opensOnClick` — including the residual
|
||||||
// it does not close.
|
// it does not close.
|
||||||
if (!opensOnClick(event, readState(), modifierPromised)) return;
|
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
|
// 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
|
// container's output, so it is validated before it reaches the OS
|
||||||
// opener. One implementation — `sanitizeRelayUrl` — on purpose.
|
// opener. One implementation — `sanitizeRelayUrl` — on purpose.
|
||||||
@@ -456,39 +577,16 @@ export function createOsc8LinkHandler(
|
|||||||
// moved on by the time they click.
|
// moved on by the time they click.
|
||||||
modifierPromised = ctx.mouseTracking;
|
modifierPromised = ctx.mouseTracking;
|
||||||
|
|
||||||
card = document.createElement("div");
|
card = makeCard();
|
||||||
card.className = OSC8_HOVER_CLASS;
|
|
||||||
card.dataset.testid = "osc8-hover";
|
const target = classifyOsc8Target(text);
|
||||||
Object.assign(card.style, {
|
// Without a viewer to hand it to, a `file:` target falls through to the
|
||||||
position: "absolute",
|
// refusal card below rather than offering something the click won't do.
|
||||||
left: "8px",
|
if (target?.kind === "file" && onOpenFile) {
|
||||||
bottom: "8px",
|
fillFileCard(card, target.path, ctx);
|
||||||
maxWidth: "calc(100% - 16px)",
|
host.appendChild(card);
|
||||||
boxSizing: "border-box",
|
return;
|
||||||
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>);
|
|
||||||
|
|
||||||
const safe = sanitizeRelayUrl(text);
|
const safe = sanitizeRelayUrl(text);
|
||||||
const origin = safe && urlOrigin(safe);
|
const origin = safe && urlOrigin(safe);
|
||||||
@@ -549,6 +647,17 @@ export function createOsc8LinkHandler(
|
|||||||
leave: clear,
|
leave: clear,
|
||||||
|
|
||||||
dismiss: 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(
|
const projectId = useAppState(
|
||||||
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
|
(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
|
// 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
|
// 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(
|
linkHandler: (osc8LinkHandlerRef.current = createOsc8LinkHandler(
|
||||||
() => term.element ?? null,
|
() => term.element ?? null,
|
||||||
() => readClickContext(term),
|
() => readClickContext(term),
|
||||||
|
(path) => {
|
||||||
|
if (projectIdRef.current) {
|
||||||
|
openFileViewer(projectIdRef.current, path).catch(reportViewerFailure);
|
||||||
|
}
|
||||||
|
},
|
||||||
)),
|
)),
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
||||||
theme: {
|
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
|
// got a browser open on a plain click while it held the mouse, and a
|
||||||
// double-click that merely selected a URL opened it.
|
// double-click that merely selected a URL opened it.
|
||||||
//
|
//
|
||||||
// It is also the only gate on a real bypass of the OSC 8 one:
|
// `allowNonHttpProtocols` is now **on**, so `OscLinkProvider` hands every
|
||||||
// `OscLinkProvider` drops a hyperlink whose target is not http(s)
|
// OSC 8 target to `createOsc8LinkHandler`, which parses it and refuses
|
||||||
// *before* `linkHandler` sees it (`allowNonHttpProtocols` is unset), so
|
// anything but `file:` (the file viewer) and `http(s):` itself. This
|
||||||
// an OSC 8 carrying a `javascript:` target and an `https://evil.tld/x`
|
// branch remains the only handler for plain-text URLs.
|
||||||
// label leaves the addon free to match the label. That click arrives
|
|
||||||
// here and nowhere else.
|
|
||||||
//
|
//
|
||||||
// No `modifierPromised`: this path paints an underline rather than a
|
// No `modifierPromised`: this path paints an underline rather than a
|
||||||
// card, so it promises the user nothing to be held to.
|
// card, so it promises the user nothing to be held to.
|
||||||
@@ -952,6 +1071,26 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}, { urlRegex });
|
}, { urlRegex });
|
||||||
term.loadAddon(webLinksAddon);
|
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);
|
term.open(containerRef.current);
|
||||||
|
|
||||||
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
|
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
|
||||||
@@ -1235,6 +1374,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
webglRef.current = null;
|
webglRef.current = null;
|
||||||
|
filePathLinks.dispose();
|
||||||
term.dispose();
|
term.dispose();
|
||||||
termRef.current = null;
|
termRef.current = null;
|
||||||
osc8LinkHandlerRef.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