mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
Open OSC 8 terminal links the same way as plain URLs (#5388)
* Open OSC 8 terminal hyperlinks like plain URLs Clicking a link a CLI printed with OSC 8 (gh, npm, ls --hyperlink) showed xterm's "Do you want to navigate to" prompt and then opened a bare Paseo window, while the same URL printed as plain text opened in the system browser. The terminal runtime now gives xterm a link handler that sends OSC 8 links to the same external opener as plain URLs. Closes #4768 Co-authored-by: liujin0506 <liujin0506@users.noreply.github.com> * Name the hyperlink and line writes in the terminal link test --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: liujin0506 <liujin0506@users.noreply.github.com>
This commit is contained in:
co-authored by
liujin0506
parent
43a2a7969c
commit
dc9799f6fc
@@ -1,4 +1,4 @@
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { page, userEvent } from "@vitest/browser/context";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
@@ -40,6 +40,7 @@ interface MountedTerminal {
|
||||
sizes: TerminalSize[];
|
||||
terminalKeys: TerminalKeyRecord[];
|
||||
inputModeChanges: TerminalInputModeState[];
|
||||
openedUrls: string[];
|
||||
}
|
||||
|
||||
const mountedTerminals: MountedTerminal[] = [];
|
||||
@@ -95,6 +96,7 @@ function createTerminalHost(input: {
|
||||
const inputs: string[] = [];
|
||||
const terminalKeys: TerminalKeyRecord[] = [];
|
||||
const inputModeChanges: TerminalInputModeState[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
@@ -110,6 +112,9 @@ function createTerminalHost(input: {
|
||||
onInputModeChange: (state) => {
|
||||
inputModeChanges.push(state);
|
||||
},
|
||||
onOpenExternalUrl: (url) => {
|
||||
openedUrls.push(url);
|
||||
},
|
||||
},
|
||||
});
|
||||
runtime.mount({
|
||||
@@ -124,7 +129,16 @@ function createTerminalHost(input: {
|
||||
},
|
||||
});
|
||||
|
||||
const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges };
|
||||
const mounted = {
|
||||
host,
|
||||
root,
|
||||
runtime,
|
||||
inputs,
|
||||
sizes,
|
||||
terminalKeys,
|
||||
inputModeChanges,
|
||||
openedUrls,
|
||||
};
|
||||
mountedTerminals.push(mounted);
|
||||
return mounted;
|
||||
}
|
||||
@@ -188,7 +202,43 @@ function dispatchTerminalKey(input: {
|
||||
);
|
||||
}
|
||||
|
||||
/** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */
|
||||
function hyperlink(input: { url: string; text: string }): string {
|
||||
return `\x1b]8;;${input.url}\x1b\\${input.text}\x1b]8;;\x1b\\`;
|
||||
}
|
||||
|
||||
function writeLines(mounted: MountedTerminal, lines: string[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
mounted.runtime.write({
|
||||
data: terminalOutput(lines.map((line) => `${line}\r\n`).join("")),
|
||||
onCommitted: resolve,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function clickTerminalLink(input: {
|
||||
host: HTMLElement;
|
||||
row: number;
|
||||
col: number;
|
||||
}): Promise<void> {
|
||||
const terminal = getBrowserTerminal();
|
||||
const screen = input.host.querySelector<HTMLElement>(".xterm-screen");
|
||||
if (!screen) {
|
||||
throw new Error("Expected xterm screen to be mounted");
|
||||
}
|
||||
const position = {
|
||||
x: (input.col + 0.5) * (screen.clientWidth / terminal.cols),
|
||||
y: (input.row + 0.5) * (screen.clientHeight / terminal.rows),
|
||||
};
|
||||
await userEvent.hover(screen, { position });
|
||||
await waitFor({
|
||||
predicate: () => input.host.querySelector(".xterm-cursor-pointer") !== null,
|
||||
});
|
||||
await userEvent.click(screen, { position });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const mounted of mountedTerminals.splice(0)) {
|
||||
mounted.runtime.unmount();
|
||||
mounted.root.remove();
|
||||
@@ -526,4 +576,23 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
|
||||
expect(reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
await writeLines(mounted, [
|
||||
hyperlink({ url: "https://example.com/osc8", text: "example" }),
|
||||
"https://example.com/plain",
|
||||
]);
|
||||
|
||||
await clickTerminalLink({ host: mounted.host, row: 1, col: 4 });
|
||||
await clickTerminalLink({ host: mounted.host, row: 0, col: 2 });
|
||||
|
||||
expect(mounted.openedUrls).toEqual(["https://example.com/plain", "https://example.com/osc8"]);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,6 +434,10 @@ export class TerminalEmulatorRuntime {
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
|
||||
const openExternalLink = (event: MouseEvent, uri: string) => {
|
||||
event.preventDefault();
|
||||
void this.callbacks.onOpenExternalUrl?.(uri);
|
||||
};
|
||||
const terminal = new Terminal({
|
||||
allowProposedApi: true,
|
||||
convertEol: false,
|
||||
@@ -441,6 +445,8 @@ export class TerminalEmulatorRuntime {
|
||||
cursorStyle: "bar",
|
||||
fontFamily: resolveTerminalFontFamily(input.fontFamily),
|
||||
fontSize: resolveTerminalFontSize(input.fontSize),
|
||||
// OSC 8 hyperlinks; without a handler xterm prompts and calls window.open().
|
||||
linkHandler: { activate: openExternalLink },
|
||||
lineHeight: 1.0,
|
||||
macOptionIsMeta: true,
|
||||
minimumContrastRatio: 1,
|
||||
@@ -457,12 +463,7 @@ export class TerminalEmulatorRuntime {
|
||||
let imageAddon: ImageAddon | null = null;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
event.preventDefault();
|
||||
void this.callbacks.onOpenExternalUrl?.(uri);
|
||||
}),
|
||||
);
|
||||
terminal.loadAddon(new WebLinksAddon(openExternalLink));
|
||||
const localFileLinkProvider = terminal.registerLinkProvider(
|
||||
createTerminalLocalFileLinkProvider(terminal, {
|
||||
resolveLink: async (source) => {
|
||||
|
||||
Reference in New Issue
Block a user