diff --git a/frontend/app/view/term/term-links.test.ts b/frontend/app/view/term/term-links.test.ts new file mode 100644 index 0000000000..42a895200a --- /dev/null +++ b/frontend/app/view/term/term-links.test.ts @@ -0,0 +1,44 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; +import { makeTermLinkHandlers } from "./term-links"; + +function linkEvent(metaKey = false, ctrlKey = false): MouseEvent { + return { metaKey, ctrlKey, preventDefault: vi.fn(), clientX: 12, clientY: 34 } as unknown as MouseEvent; +} + +describe("terminal link handlers", () => { + it.each([ + { isMacOS: true, modifier: "metaKey" }, + { isMacOS: false, modifier: "ctrlKey" }, + ])("only opens $modifier-clicked links on the corresponding platform", ({ isMacOS }) => { + const openUri = vi.fn(); + const handlers = makeTermLinkHandlers(isMacOS, openUri, vi.fn()); + const plainClick = linkEvent(); + handlers.activate(plainClick, "https://example.com/first"); + expect(plainClick.preventDefault).toHaveBeenCalledOnce(); + expect(openUri).not.toHaveBeenCalled(); + + const wrongModifier = linkEvent(!isMacOS, isMacOS); + handlers.activate(wrongModifier, "https://example.com/second"); + expect(openUri).not.toHaveBeenCalled(); + + const rightModifier = linkEvent(isMacOS, !isMacOS); + handlers.activate(rightModifier, "https://example.com/third"); + expect(openUri).toHaveBeenCalledExactlyOnceWith("https://example.com/third"); + }); + + it("shows the destination for OSC 8 links but keeps plain URL hover unchanged", () => { + const onHover = vi.fn(); + const handlers = makeTermLinkHandlers(false, vi.fn(), onHover); + handlers.hover(linkEvent(), "https://example.com/visible"); + handlers.osc8Hover(linkEvent(), "https://example.com/hidden"); + handlers.leave(); + expect(onHover.mock.calls).toEqual([ + ["https://example.com/visible", 12, 34, false], + ["https://example.com/hidden", 12, 34, true], + [null, 0, 0, false], + ]); + }); +}); diff --git a/frontend/app/view/term/term-links.ts b/frontend/app/view/term/term-links.ts new file mode 100644 index 0000000000..ceaf15c7ea --- /dev/null +++ b/frontend/app/view/term/term-links.ts @@ -0,0 +1,21 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export function makeTermLinkHandlers( + isMacOS: boolean, + openUri: (uri: string) => void, + onHover: (uri: string | null, x: number, y: number, showUrl: boolean) => void +) { + return { + activate: (event: MouseEvent, uri: string) => { + event.preventDefault(); + if (!(isMacOS ? event.metaKey : event.ctrlKey)) { + return; + } + openUri(uri); + }, + hover: (event: MouseEvent, uri: string) => onHover(uri, event.clientX, event.clientY, false), + osc8Hover: (event: MouseEvent, uri: string) => onHover(uri, event.clientX, event.clientY, true), + leave: () => onHover(null, 0, 0, false), + }; +} diff --git a/frontend/app/view/term/term-tooltip.test.tsx b/frontend/app/view/term/term-tooltip.test.tsx new file mode 100644 index 0000000000..15213c37e1 --- /dev/null +++ b/frontend/app/view/term/term-tooltip.test.tsx @@ -0,0 +1,22 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { TermLinkTooltipContent } from "./term-tooltip"; + +describe("TermLinkTooltipContent", () => { + it("keeps the existing modifier-click hint for plain URLs", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("-click to open link"); + expect(markup).not.toContain("break-all"); + }); + + it("shows and safely escapes the destination for OSC 8 links", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("-click to open link"); + expect(markup).toContain("https://example.com/?next=<script>&x=1"); + expect(markup).toContain("break-all"); + expect(markup).not.toContain("