mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
fix(app): fall back to native terminal copy/paste without the Clipboard API (#8230)
Browsers omit navigator.clipboard outside secure contexts, such as the daemon web UI served over plain http on a Tailscale or LAN address. The non-Mac Ctrl+C/Ctrl+V terminal shortcuts then threw synchronously, and Ctrl+V had already prevented the native paste, so terminal copy/paste broke. When the Clipboard API is unavailable, the shortcuts now leave the default action alone so xterm's native copy/paste event handlers do the work, and the browser clipboard rejects instead of throwing. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,7 @@ import {
|
||||
createTerminalResizeEvent,
|
||||
encodeTerminalOutput,
|
||||
TerminalEmulatorRuntime,
|
||||
type TerminalClipboard,
|
||||
} from "./terminal-emulator-runtime";
|
||||
|
||||
interface StubTerminal {
|
||||
@@ -638,3 +639,97 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(fitAndEmitResize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal-emulator-runtime clipboard shortcuts", () => {
|
||||
type KeyHandler = (event: KeyboardEvent) => boolean;
|
||||
|
||||
function createClipboard(available: boolean) {
|
||||
return {
|
||||
isAvailable: () => available,
|
||||
readText: vi.fn(async () => "clipboard text"),
|
||||
read: vi.fn(async () => []),
|
||||
writeText: vi.fn(async (_text: string) => undefined),
|
||||
} satisfies TerminalClipboard;
|
||||
}
|
||||
|
||||
function attachKeyHandler(input: { clipboard: TerminalClipboard; selection: string }) {
|
||||
const runtime = new TerminalEmulatorRuntime({ isMac: false }, input.clipboard);
|
||||
let handler: KeyHandler | null = null;
|
||||
const paste = vi.fn();
|
||||
runtime.attachKeyEventHandler({
|
||||
attachCustomKeyEventHandler: (next: KeyHandler) => {
|
||||
handler = next;
|
||||
},
|
||||
hasSelection: () => input.selection.length > 0,
|
||||
getSelection: () => input.selection,
|
||||
paste,
|
||||
input: vi.fn(),
|
||||
scrollToBottom: vi.fn(),
|
||||
});
|
||||
if (!handler) throw new Error("key handler was not attached");
|
||||
return { handler: handler as KeyHandler, paste };
|
||||
}
|
||||
|
||||
function ctrlKey(key: string) {
|
||||
const preventDefault = vi.fn();
|
||||
const event = {
|
||||
type: "keydown",
|
||||
key,
|
||||
isComposing: false,
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
preventDefault,
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
return { event, preventDefault };
|
||||
}
|
||||
|
||||
it("leaves Ctrl+C with a selection to the native copy event without the Clipboard API", () => {
|
||||
const clipboard = createClipboard(false);
|
||||
const { handler } = attachKeyHandler({ clipboard, selection: "selected text" });
|
||||
const { event, preventDefault } = ctrlKey("c");
|
||||
|
||||
expect(handler(event)).toBe(false);
|
||||
expect(clipboard.writeText).not.toHaveBeenCalled();
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves Ctrl+V to the native paste event without the Clipboard API", () => {
|
||||
const clipboard = createClipboard(false);
|
||||
const { handler, paste } = attachKeyHandler({ clipboard, selection: "" });
|
||||
const { event, preventDefault } = ctrlKey("v");
|
||||
|
||||
expect(handler(event)).toBe(false);
|
||||
expect(clipboard.readText).not.toHaveBeenCalled();
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
expect(paste).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets xterm send ^C for Ctrl+C without a selection when the Clipboard API is missing", () => {
|
||||
const clipboard = createClipboard(false);
|
||||
const { handler } = attachKeyHandler({ clipboard, selection: "" });
|
||||
const { event, preventDefault } = ctrlKey("c");
|
||||
|
||||
expect(handler(event)).toBe(true);
|
||||
expect(clipboard.writeText).not.toHaveBeenCalled();
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies and pastes through the Clipboard API when it is available", async () => {
|
||||
const clipboard = createClipboard(true);
|
||||
const { handler, paste } = attachKeyHandler({ clipboard, selection: "selected text" });
|
||||
|
||||
const copy = ctrlKey("c");
|
||||
expect(handler(copy.event)).toBe(false);
|
||||
expect(clipboard.writeText).toHaveBeenCalledWith("selected text");
|
||||
expect(copy.preventDefault).not.toHaveBeenCalled();
|
||||
|
||||
const pasteKey = ctrlKey("v");
|
||||
expect(handler(pasteKey.event)).toBe(false);
|
||||
expect(pasteKey.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(clipboard.readText).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("clipboard text"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,15 +182,26 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
|
||||
}
|
||||
|
||||
export interface TerminalClipboard {
|
||||
/** Whether the async Clipboard API is usable. Browsers omit it outside secure contexts. */
|
||||
isAvailable?(): boolean;
|
||||
readText(): Promise<string>;
|
||||
read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]>;
|
||||
writeText(text: string): Promise<void>;
|
||||
}
|
||||
|
||||
function getBrowserClipboard(): Clipboard | undefined {
|
||||
return typeof navigator !== "undefined" ? navigator.clipboard : undefined;
|
||||
}
|
||||
|
||||
function rejectUnavailableClipboard(): Promise<never> {
|
||||
return Promise.reject(new Error("Clipboard API is unavailable"));
|
||||
}
|
||||
|
||||
const browserClipboard: TerminalClipboard = {
|
||||
readText: () => navigator.clipboard.readText(),
|
||||
read: () => navigator.clipboard.read(),
|
||||
writeText: (text) => navigator.clipboard.writeText(text),
|
||||
isAvailable: () => !!getBrowserClipboard(),
|
||||
readText: () => getBrowserClipboard()?.readText() ?? rejectUnavailableClipboard(),
|
||||
read: () => getBrowserClipboard()?.read() ?? rejectUnavailableClipboard(),
|
||||
writeText: (text) => getBrowserClipboard()?.writeText(text) ?? rejectUnavailableClipboard(),
|
||||
};
|
||||
|
||||
async function readClipboardImage(clipboard: TerminalClipboard): Promise<TerminalFile | null> {
|
||||
@@ -368,22 +379,7 @@ export class TerminalEmulatorRuntime {
|
||||
!event.altKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
|
||||
if (key === "c" && terminal.hasSelection()) {
|
||||
void this.clipboard.writeText(terminal.getSelection());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
|
||||
if (key === "v") {
|
||||
event.preventDefault();
|
||||
this.pasteClipboard(terminal);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return !this.handleCtrlClipboardShortcut(terminal, event);
|
||||
}
|
||||
|
||||
if (this.maybeSendMacEditingShortcut(terminal, event)) {
|
||||
@@ -432,6 +428,36 @@ export class TerminalEmulatorRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns true when a non-Mac Ctrl+C / Ctrl+V clipboard shortcut was handled. */
|
||||
private handleCtrlClipboardShortcut(
|
||||
terminal: Pick<Terminal, "hasSelection" | "getSelection" | "paste">,
|
||||
event: KeyboardEvent,
|
||||
): boolean {
|
||||
const key = event.key.toLowerCase();
|
||||
// Without the Clipboard API (e.g. plain http on a LAN address), the browser's native
|
||||
// copy/paste events still fire and xterm handles them, so leave the default action.
|
||||
const clipboardAvailable = this.clipboard.isAvailable?.() ?? true;
|
||||
|
||||
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
|
||||
if (key === "c" && terminal.hasSelection()) {
|
||||
if (clipboardAvailable) {
|
||||
void this.clipboard.writeText(terminal.getSelection());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
|
||||
if (key === "v") {
|
||||
if (clipboardAvailable) {
|
||||
event.preventDefault();
|
||||
this.pasteClipboard(terminal);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private handleFindShortcut(event: KeyboardEvent): boolean {
|
||||
if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false;
|
||||
event.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user