Make links in Claude's output clickable #59
@@ -1,6 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, fireEvent, cleanup, act } from "@testing-library/react";
|
import { render, fireEvent, cleanup, act } from "@testing-library/react";
|
||||||
import TerminalView, { supersedes } from "./TerminalView";
|
import TerminalView, {
|
||||||
|
OSC8_HOVER_CLASS,
|
||||||
|
createOsc8LinkHandler,
|
||||||
|
supersedes,
|
||||||
|
} from "./TerminalView";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import {
|
import {
|
||||||
uploadHostFileToTerminal,
|
uploadHostFileToTerminal,
|
||||||
@@ -1074,3 +1078,140 @@ describe("TerminalView — releasing a captured mouse", () => {
|
|||||||
expect(terminalInput).not.toHaveBeenCalled();
|
expect(terminalInput).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the hover hint names the key that actually works", () => {
|
||||||
|
const platform = (value: string) =>
|
||||||
|
Object.defineProperty(navigator, "platform", { value, configurable: true });
|
||||||
|
const original = navigator.platform;
|
||||||
|
afterEach(() => platform(original));
|
||||||
|
|
||||||
|
// xterm gates this on its own `isMac`; if the hint and the gate disagree the
|
||||||
|
// user is told to press a key that does nothing.
|
||||||
|
it("says Option on a Mac, because that is xterm's force-selection modifier there", () => {
|
||||||
|
platform("MacIntel");
|
||||||
|
const host = document.createElement("div");
|
||||||
|
createOsc8LinkHandler(() => host).hover?.(
|
||||||
|
new MouseEvent("mousemove"),
|
||||||
|
"https://example.com/x",
|
||||||
|
{ start: { x: 1, y: 1 }, end: { x: 1, y: 1 } },
|
||||||
|
);
|
||||||
|
expect(host.textContent).toContain("Option+click");
|
||||||
|
expect(host.textContent).not.toContain("Shift+click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says Shift everywhere else", () => {
|
||||||
|
platform("Linux x86_64");
|
||||||
|
const host = document.createElement("div");
|
||||||
|
createOsc8LinkHandler(() => host).hover?.(
|
||||||
|
new MouseEvent("mousemove"),
|
||||||
|
"https://example.com/x",
|
||||||
|
{ start: { x: 1, y: 1 }, end: { x: 1, y: 1 } },
|
||||||
|
);
|
||||||
|
expect(host.textContent).toContain("Shift+click");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createOsc8LinkHandler — clicking a link Claude Code printed", () => {
|
||||||
|
/**
|
||||||
|
* The handler is exercised directly rather than through a rendered terminal.
|
||||||
|
*
|
||||||
|
* xterm decides *when* to call it from cell geometry, and jsdom gives every
|
||||||
|
* element a zero-sized box — so a test driving the mouse over the pane would
|
||||||
|
* be asserting that jsdom's layout engine exists, not that this app validates
|
||||||
|
* what it opens. What xterm hands over is the OSC 8 parameter verbatim, which
|
||||||
|
* is exactly what these arguments are.
|
||||||
|
*/
|
||||||
|
const range = {
|
||||||
|
start: { x: 1, y: 1 },
|
||||||
|
end: { x: 80, y: 1 },
|
||||||
|
} as unknown as Parameters<
|
||||||
|
NonNullable<ReturnType<typeof createOsc8LinkHandler>["hover"]>
|
||||||
|
>[2];
|
||||||
|
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let handler: ReturnType<typeof createOsc8LinkHandler>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
handler = createOsc8LinkHandler(() => host);
|
||||||
|
});
|
||||||
|
|
||||||
|
function hoverCard(): HTMLElement | null {
|
||||||
|
return host.querySelector<HTMLElement>(`.${OSC8_HOVER_CLASS}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("refuses a target that fails validation, without reaching the opener", () => {
|
||||||
|
// The visible text can be anything; the parameter is what gets opened, and
|
||||||
|
// a container is free to put a scheme in it that the host must never hand
|
||||||
|
// to an OS-level opener.
|
||||||
|
handler.activate(new MouseEvent("click"), "javascript:alert(1)", range);
|
||||||
|
handler.activate(new MouseEvent("click"), "file:///etc/passwd", range);
|
||||||
|
handler.activate(
|
||||||
|
new MouseEvent("click"),
|
||||||
|
"https://claude.ai@evil.tld/authorize",
|
||||||
|
range,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a valid target through the one sink", async () => {
|
||||||
|
const url =
|
||||||
|
"https://claude.ai/oauth/authorize?code=true&client_id=abc123&scope=user%3Ainference";
|
||||||
|
await act(async () => {
|
||||||
|
handler.activate(new MouseEvent("click"), url, range);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openUrlExternal).toHaveBeenCalledWith(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the real origin on hover, not the text on screen", () => {
|
||||||
|
// The point of the affordance. OSC 8 decouples label from target: the row
|
||||||
|
// can read `https://claude.ai` while the parameter points anywhere.
|
||||||
|
handler.hover?.(
|
||||||
|
new MouseEvent("mousemove"),
|
||||||
|
"https://evil.example.com/claude.ai/oauth/authorize?code=true",
|
||||||
|
range,
|
||||||
|
);
|
||||||
|
|
||||||
|
const card = hoverCard();
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
const origin = card!.querySelector('[data-testid="osc8-hover-origin"]');
|
||||||
|
expect(origin?.textContent).toBe("https://evil.example.com");
|
||||||
|
// Whole origin or nothing — a truncated one is the spoof this prevents.
|
||||||
|
expect(origin?.textContent).not.toContain("…");
|
||||||
|
expect(card!.textContent).not.toContain("https://claude.ai");
|
||||||
|
|
||||||
|
handler.leave?.(new MouseEvent("mouseout"), "https://evil.example.com/", range);
|
||||||
|
expect(hoverCard()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so on hover when the target would be refused", () => {
|
||||||
|
handler.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range);
|
||||||
|
|
||||||
|
const card = hoverCard();
|
||||||
|
expect(card).not.toBeNull();
|
||||||
|
expect(card!.querySelector('[data-testid="osc8-hover-origin"]')).toBeNull();
|
||||||
|
// Never echo the rejected target: it is untrusted text on its way to a DOM
|
||||||
|
// node, and the only thing worth saying is that clicking does nothing.
|
||||||
|
expect(card!.textContent).not.toContain("javascript:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pushes the shared toast when the host opener fails", async () => {
|
||||||
|
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
handler.activate(new MouseEvent("click"), "https://example.com/x", range);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const toasts = useAppState.getState().toasts;
|
||||||
|
expect(toasts).toHaveLength(1);
|
||||||
|
expect(toasts[0].kind).toBe("error");
|
||||||
|
expect(toasts[0].detail).toContain("no opener");
|
||||||
|
// Same card as every other dead-opener report in this view.
|
||||||
|
expect(toasts[0].dedupeKey).toBe("host-open-failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal, type ILinkHandler } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
extendsUrl,
|
extendsUrl,
|
||||||
parseUrlRelayOsc,
|
parseUrlRelayOsc,
|
||||||
sanitizeRelayUrl,
|
sanitizeRelayUrl,
|
||||||
|
urlOrigin,
|
||||||
} from "../../lib/urlRelay";
|
} from "../../lib/urlRelay";
|
||||||
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
|
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
|
||||||
import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget";
|
import { useSignInOpenTarget } from "../../hooks/useSignInOpenTarget";
|
||||||
@@ -106,6 +107,205 @@ export function supersedes(
|
|||||||
return extendsUrl(next.url, current.url);
|
return extendsUrl(next.url, current.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class xterm requires on an element that floats over the terminal.
|
||||||
|
*
|
||||||
|
* Not decoration: `ILinkHandler.hover`'s contract is that the hover element
|
||||||
|
* lives inside `Terminal.element` and carries this class, otherwise xterm's
|
||||||
|
* own hit-testing does not know to stop at it and mouse events fall through to
|
||||||
|
* whatever link is underneath.
|
||||||
|
*/
|
||||||
|
export const OSC8_HOVER_CLASS = "xterm-hover";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a failed handoff to the host's browser.
|
||||||
|
*
|
||||||
|
* One sink, one card. See the long note on `handleOpenUrl` for what this catch
|
||||||
|
* does *not* catch on Linux; a click that appears to do nothing is the
|
||||||
|
* complaint either way, so every route that opens a URL says the same thing in
|
||||||
|
* the same place.
|
||||||
|
*/
|
||||||
|
function reportOpenFailure(e: unknown) {
|
||||||
|
useAppState.getState().pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not open that link in your browser",
|
||||||
|
detail: String(e),
|
||||||
|
// A dead opener fails for every link in the buffer. One card.
|
||||||
|
dedupeKey: "host-open-failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes OSC 8 hyperlinks clickable, and shows where they actually go.
|
||||||
|
*
|
||||||
|
* ## Why xterm's own link matching is not enough
|
||||||
|
*
|
||||||
|
* `WebLinksAddon` matches *rendered text*, row by row. Claude Code prints its
|
||||||
|
* links as OSC 8 hyperlinks whose visible text is hard-wrapped into
|
||||||
|
* terminal-width pieces — measured against 2.1.226, a 346-character sign-in
|
||||||
|
* URL arrives as five emissions, each carrying the whole URL in its OSC 8
|
||||||
|
* parameter and about 80 characters of it on screen (see `lib/urlDetector.ts`,
|
||||||
|
* which had to grow the same second branch). So the addon matches a fragment
|
||||||
|
* or nothing at all, which is the entire reason the URL toast exists. xterm
|
||||||
|
* hands `linkHandler` the complete parameter instead, however the label was
|
||||||
|
* sliced, so this covers exactly the case the addon cannot — and the addon
|
||||||
|
* stays, because it covers the plain-text URLs in ordinary shell output that
|
||||||
|
* carry no OSC 8 at all.
|
||||||
|
*
|
||||||
|
* ## The gesture is Shift+click (Option+click on macOS)
|
||||||
|
*
|
||||||
|
* Claude holds mouse tracking (`?1000`/`?1002`/`?1003`), and xterm's mousedown
|
||||||
|
* handler cancels the event before the link layer whenever tracking is on —
|
||||||
|
* *unless* `shouldForceSelection(e)` is true, which is `e.shiftKey`, or
|
||||||
|
* `e.altKey` on macOS with `macOptionClickForcesSelection` set (this view sets
|
||||||
|
* it). So the modifier that already exists for selecting text is the one that
|
||||||
|
* reaches a link, and no new key handling is involved. A plain click keeps
|
||||||
|
* going to the program, which is what a TUI needs.
|
||||||
|
*
|
||||||
|
* ## The hover card is the security half, not a nicety
|
||||||
|
*
|
||||||
|
* OSC 8 fully decouples the visible text from the target: the container can
|
||||||
|
* print `https://claude.ai` and link it anywhere. That is strictly worse than
|
||||||
|
* the userinfo spoofing `sanitizeRelayUrl` already rejects, because here
|
||||||
|
* nothing in the painted row is even *derived* from the destination. So the
|
||||||
|
* origin of the real target is shown before the user commits, the same way the
|
||||||
|
* URL toast shows it and for the same reason ({@link urlOrigin}'s note): the
|
||||||
|
* origin decides where the user's credentials end up, so it is rendered in
|
||||||
|
* full and the *remainder* is the only part an ellipsis may eat.
|
||||||
|
*
|
||||||
|
* The card sits at the bottom of the pane rather than beside the pointer —
|
||||||
|
* where a browser puts it, and never underneath the cursor, so it cannot
|
||||||
|
* flicker the link out from under the hover that summoned it.
|
||||||
|
*
|
||||||
|
* @param getHost returns `Terminal.element`, which does not exist until
|
||||||
|
* `term.open()` has run — hence a getter rather than the element.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The modifier that opens an OSC 8 link, named the way the user's platform
|
||||||
|
* names it.
|
||||||
|
*
|
||||||
|
* This is not our choice and it must not drift: xterm only lets a click reach
|
||||||
|
* the link layer while a program holds the mouse when its own
|
||||||
|
* `shouldForceSelection` says so, and that is
|
||||||
|
* `isMac ? e.altKey && macOptionClickForcesSelection : e.shiftKey`. The list
|
||||||
|
* below is xterm's `isMac` verbatim (`common/Platform.ts`) so the hint cannot
|
||||||
|
* disagree with the behaviour it describes — a hint that names the wrong key
|
||||||
|
* is worse than no hint, because the user concludes the link is broken.
|
||||||
|
*
|
||||||
|
* `macOptionClickForcesSelection` is set on the terminal, so the Mac branch is
|
||||||
|
* live rather than theoretical.
|
||||||
|
*/
|
||||||
|
function openModifierLabel(): string {
|
||||||
|
const platform = typeof navigator === "undefined" ? "" : navigator.platform;
|
||||||
|
const isMac = ["Macintosh", "MacIntel", "MacPPC", "Mac68K"].includes(platform);
|
||||||
|
return isMac ? "Option+click to open" : "Shift+click to open";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOsc8LinkHandler(
|
||||||
|
getHost: () => HTMLElement | null,
|
||||||
|
): ILinkHandler {
|
||||||
|
let card: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
card?.remove();
|
||||||
|
card = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const span = (text: string, style: Partial<CSSStyleDeclaration>) => {
|
||||||
|
const el = document.createElement("span");
|
||||||
|
el.textContent = text;
|
||||||
|
Object.assign(el.style, style);
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activate(_event, text) {
|
||||||
|
// 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.
|
||||||
|
const safe = sanitizeRelayUrl(text);
|
||||||
|
if (!safe) {
|
||||||
|
console.warn("Refusing to open a link that failed validation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openUrlExternal(safe).catch(reportOpenFailure);
|
||||||
|
},
|
||||||
|
|
||||||
|
hover(_event, text) {
|
||||||
|
clear();
|
||||||
|
const host = getHost();
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
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)",
|
||||||
|
zIndex: "30",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "3px 8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: "var(--shadow-overlay)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
} as Partial<CSSStyleDeclaration>);
|
||||||
|
|
||||||
|
const safe = sanitizeRelayUrl(text);
|
||||||
|
const origin = safe && urlOrigin(safe);
|
||||||
|
if (!safe || !origin) {
|
||||||
|
// Nothing of the rejected target is echoed into the DOM — it is
|
||||||
|
// untrusted text, and the only useful thing to say is that the click
|
||||||
|
// will not do anything.
|
||||||
|
card.appendChild(
|
||||||
|
span("This link will not be opened — it is not a web address", {
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const rest = safe.startsWith(origin) ? safe.slice(origin.length) : safe;
|
||||||
|
const originEl = span(origin, {
|
||||||
|
fontWeight: "700",
|
||||||
|
// The part that decides where the credentials go. Never truncated:
|
||||||
|
// truncating it *is* the spoof.
|
||||||
|
flexShrink: "0",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
});
|
||||||
|
originEl.dataset.testid = "osc8-hover-origin";
|
||||||
|
card.appendChild(originEl);
|
||||||
|
|
||||||
|
const restEl = span(rest, {
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
minWidth: "0",
|
||||||
|
});
|
||||||
|
restEl.dataset.testid = "osc8-hover-rest";
|
||||||
|
card.appendChild(restEl);
|
||||||
|
|
||||||
|
const hint = span(openModifierLabel(), {
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
flexShrink: "0",
|
||||||
|
marginLeft: "4px",
|
||||||
|
});
|
||||||
|
card.appendChild(hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
host.appendChild(card);
|
||||||
|
},
|
||||||
|
|
||||||
|
leave: clear,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function TerminalView({ sessionId, active }: Props) {
|
export default function TerminalView({ sessionId, active }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -410,7 +610,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
const term = new Terminal({
|
// Annotated because `linkHandler` below refers to `term` (for the element
|
||||||
|
// it must anchor its hover card to, which does not exist until
|
||||||
|
// `term.open()`), and TypeScript cannot infer a type it is already using.
|
||||||
|
const term: Terminal = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
// Let the user select text even while a program holds the mouse.
|
// Let the user select text even while a program holds the mouse.
|
||||||
@@ -420,6 +623,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// the only way to copy from a mouse-driven TUI is to take the mouse back
|
// the only way to copy from a mouse-driven TUI is to take the mouse back
|
||||||
// first. `SelectionService.shouldForceSelection`.
|
// first. `SelectionService.shouldForceSelection`.
|
||||||
macOptionClickForcesSelection: true,
|
macOptionClickForcesSelection: true,
|
||||||
|
// OSC 8 hyperlinks — the form Claude Code prints its links in, and the
|
||||||
|
// one `WebLinksAddon` structurally cannot match. See
|
||||||
|
// `createOsc8LinkHandler`, including why the gesture is the same
|
||||||
|
// Shift/Option the line above is about.
|
||||||
|
linkHandler: createOsc8LinkHandler(() => term.element ?? null),
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
||||||
theme: {
|
theme: {
|
||||||
background: "#0d1117",
|
background: "#0d1117",
|
||||||
@@ -454,27 +662,24 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
|
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
|
||||||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||||||
// Same sink, same rule: what xterm matched came off the container's
|
// Same sink, same rule as `createOsc8LinkHandler`: what xterm matched
|
||||||
// output, so it is validated before it reaches the OS opener. A click
|
// came off the container's output, so it is validated before it reaches
|
||||||
// here is a deliberate act on visible text, but "visible" is exactly
|
// the OS opener.
|
||||||
// what a userinfo-spoofed URL subverts.
|
//
|
||||||
|
// This branch is the one where the click really is an act on visible
|
||||||
|
// text — the match *is* the painted characters — so the spoof it has to
|
||||||
|
// survive is a userinfo-spoofed URL, which `sanitizeRelayUrl` rejects.
|
||||||
|
// An OSC 8 link is not like that at all: its label and its target are
|
||||||
|
// unrelated strings, which is why that handler shows the target's origin
|
||||||
|
// on hover before a click can happen. Neither branch replaces the other:
|
||||||
|
// this one covers plain-text URLs in ordinary shell output, which carry
|
||||||
|
// no hyperlink parameter for xterm to hand over.
|
||||||
const safe = sanitizeRelayUrl(uri);
|
const safe = sanitizeRelayUrl(uri);
|
||||||
if (!safe) {
|
if (!safe) {
|
||||||
console.warn("Refusing to open a link that failed validation");
|
console.warn("Refusing to open a link that failed validation");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Same failure reporting as the toast's Open button — see the long note
|
openUrlExternal(safe).catch(reportOpenFailure);
|
||||||
// on `handleOpenUrl`, including what this catch does *not* catch on
|
|
||||||
// Linux. A click that appears to do nothing is the complaint either way.
|
|
||||||
openUrlExternal(safe).catch((e) =>
|
|
||||||
useAppState.getState().pushToast({
|
|
||||||
kind: "error",
|
|
||||||
message: "Could not open that link in your browser",
|
|
||||||
detail: String(e),
|
|
||||||
// A dead opener fails for every link in the buffer. One card.
|
|
||||||
dedupeKey: "host-open-failed",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, { urlRegex });
|
}, { urlRegex });
|
||||||
term.loadAddon(webLinksAddon);
|
term.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user