diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 9ae725eba179..f967fcff23fd 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -777,6 +777,55 @@ describe("GhosttyTerminalSurface visibility", () => { expect(readText).not.toHaveBeenCalled(); }); + function paste(surface: GhosttyTerminalSurface, text: string) { + surface.input.dispatchEvent( + Object.assign(new Event("paste", { cancelable: true }), { + clipboardData: { getData: () => text }, + }), + ); + } + + it.each(["native", "clipboard"])("pastes once when %s arrives first", async (first) => { + const harness = createHarness(); + const read = Promise.resolve("echo hi"); + vi.stubGlobal("navigator", { + platform: "MacIntel", + clipboard: { readText: () => read }, + }); + const surface = await harness.create({ beforeKey: () => true }); + + key(surface, "v", "KeyV", { metaKey: true }); + if (first === "native") paste(surface, "echo hi"); + await read; + if (first === "clipboard") paste(surface, "echo hi"); + + expect(harness.onData.mock.calls).toEqual([["echo hi"]]); + }); + + it.each(["V keyup", "blur", "Meta keyup without V keyup"])( + "accepts a later identical menu paste after %s", + async (end) => { + const harness = createHarness(); + const read = Promise.resolve("echo hi"); + vi.stubGlobal("navigator", { + platform: "MacIntel", + clipboard: { readText: () => read }, + }); + const surface = await harness.create({ beforeKey: () => true }); + + key(surface, "v", "KeyV", { metaKey: true }); + await read; + expect(harness.onData.mock.calls).toEqual([["echo hi"]]); + if (end === "blur") surface.input.dispatchEvent(new Event("blur")); + else if (end === "V keyup") key(surface, "v", "KeyV", { metaKey: true }, "keyup"); + // macOS can omit V keyup while Command is held. + else key(surface, "Meta", "MetaLeft", {}, "keyup"); + paste(surface, "echo hi"); + + expect(harness.onData.mock.calls).toEqual([["echo hi"], ["echo hi"]]); + }, + ); + it("starts a selection when dragging from a link", async () => { const harness = createHarness(); const onLinkActivate = vi.fn(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index c221d35f26ad..7ebf0932ad7b 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -580,6 +580,9 @@ export class GhosttyTerminalSurface { private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); private pasteShortcutToken = 0; + // Chromium can deliver native paste after the clipboard read resolves. + // Suppress its duplicate only for the current shortcut gesture. + private pasteShortcutGesture: { code: string; readText: string } | null = null; private copyShortcutToken = 0; private clearSelectionAfterCopy = false; private primedCopySelection = ""; @@ -1137,19 +1140,20 @@ export class GhosttyTerminalSurface { } if (isTerminalPasteShortcut(event)) { this.suppressedKeyCodes.add(event.code); + const gesture = { code: event.code, readText: "" }; + this.pasteShortcutGesture = gesture; const clipboard = navigator.clipboard; if (typeof clipboard?.readText === "function") { - // Race the async clipboard read against the browser's own paste event: - // the native event (dispatched synchronously with the default action) - // always claims the token first when it fires, and the read covers - // browsers whose paste shortcut produces no paste event. Not preventing - // the default keeps the native path alive when the read is denied. + // Keep native paste available if the read is denied. The token drops + // a late read; the gesture drops a native duplicate when the read wins. const token = ++this.pasteShortcutToken; void clipboard.readText().then( (text) => { if (this.disposed || this.pasteShortcutToken !== token) return; this.pasteShortcutToken += 1; - if (text.length > 0) this.options.onData(this.core.encodePaste(text)); + if (text.length === 0) return; + if (this.pasteShortcutGesture === gesture) gesture.readText = text; + this.options.onData(this.core.encodePaste(text)); }, () => {}, ); @@ -1166,6 +1170,15 @@ export class GhosttyTerminalSurface { }; private readonly onKeyUp = (event: KeyboardEvent) => { + // macOS can omit V keyup while Command is held. + if ( + event.code === this.pasteShortcutGesture?.code || + event.key === "Meta" || + event.key === "Control" || + event.key === "Shift" + ) { + this.pasteShortcutGesture = null; + } if (this.suppressedKeyCodes.delete(event.code)) return; if (isTerminalCompositionKey(event, this.composing)) { return; @@ -1187,6 +1200,7 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; + this.pasteShortcutGesture = null; this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for // example terminal-toggle) must still swallow its own keyup if focus comes @@ -1244,6 +1258,11 @@ export class GhosttyTerminalSurface { event.preventDefault(); const data = event.clipboardData?.getData("text/plain") ?? ""; if (data.length === 0) return; + if (data === this.pasteShortcutGesture?.readText) { + // The shortcut's clipboard read already sent this paste. + this.pasteShortcutGesture = null; + return; + } // The native paste won the race with actual text; a pending clipboard read // must not double. An empty native paste leaves the read as the only path. this.pasteShortcutToken += 1;