From f2217002ba3c05a4db2491f9b4034adc9a37f6f6 Mon Sep 17 00:00:00 2001 From: maco Date: Thu, 1 Oct 2026 06:33:59 +0000 Subject: [PATCH] fix(web): stop double paste in Chromium terminal The paste shortcut races navigator.clipboard.readText() against the browser's native paste event. The token only covered the native event landing first; when the read resolved first, the following native paste was sent again. Record the text the read delivered and drop the matching native paste from that gesture. The gesture ends on that paste, the shortcut key's own keyup, a new paste shortcut, or blur. Unrelated keys do not end it, so a late native paste is still dropped, and a later menu paste of the same text still lands. --- apps/web/src/terminal/ghostty/surface.test.ts | 116 +++++++++++++++++- apps/web/src/terminal/ghostty/surface.ts | 36 +++++- 2 files changed, 145 insertions(+), 7 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 59150ee320ae..ba3e8f62cb43 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -97,6 +97,7 @@ describe("GhosttyTerminalSurface visibility", () => { const canvas = new TerminalTestElement(); const mount = new TerminalTestElement(); + let input: TerminalTestElement | undefined; const context = { canvas, beginPath() {}, @@ -116,7 +117,12 @@ describe("GhosttyTerminalSurface visibility", () => { }), }; vi.stubGlobal("document", { - createElement: (tag: string) => (tag === "canvas" ? canvas : new TerminalTestElement()), + createElement: (tag: string) => { + if (tag === "canvas") return canvas; + const element = new TerminalTestElement(); + if (tag === "textarea") input = element; + return element; + }, fonts: Object.assign(new EventTarget(), { load: async () => [], add() {} }), }); vi.stubGlobal( @@ -179,6 +185,49 @@ describe("GhosttyTerminalSurface visibility", () => { }), ); }, + key( + key: string, + modifiers: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }, + type: "keydown" | "keyup" = "keydown", + ) { + input?.dispatchEvent( + Object.assign(new Event(type, { cancelable: true }), { + key, + code: `Key${key.toUpperCase()}`, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + repeat: false, + isComposing: false, + ...modifiers, + getModifierState: () => false, + }), + ); + }, + blur() { + input?.dispatchEvent(new Event("blur")); + }, + /** + * Serves `text` to the paste shortcut's clipboard reads. The returned + * function asserts how many reads started and waits for the last one to + * settle, so no read can land after the caller's next assertion. + */ + stubClipboard(text: string) { + const readText = vi.fn(async () => text); + vi.stubGlobal("navigator", { platform: "MacIntel", clipboard: { readText } }); + return async (reads: number) => { + expect(readText).toHaveBeenCalledTimes(reads); + await readText.mock.results[reads - 1]?.value; + }; + }, + paste(text: string) { + input?.dispatchEvent( + Object.assign(new Event("paste", { cancelable: true }), { + clipboardData: { getData: () => text }, + }), + ); + }, async create(options: Partial = {}) { const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, { theme: { @@ -305,6 +354,71 @@ describe("GhosttyTerminalSurface visibility", () => { expect(readText).not.toHaveBeenCalled(); }); + it("pastes once when the shortcut's clipboard read lands before the native paste", async () => { + const harness = createHarness(); + const settleReads = harness.stubClipboard("echo hi"); + await harness.create({ beforeKey: () => true }); + + harness.key("v", { metaKey: true }); + await settleReads(1); + expect(harness.onData).toHaveBeenCalledTimes(1); + expect(harness.onData).toHaveBeenNthCalledWith(1, "echo hi"); + harness.paste("echo hi"); + harness.key("v", { metaKey: true }, "keyup"); + expect(harness.onData).toHaveBeenCalledTimes(1); + + // The next paste gesture is a new paste, and its read must not land on + // top of the native paste that won. + harness.key("v", { metaKey: true }); + harness.paste("echo hi"); + await settleReads(2); + expect(harness.onData).toHaveBeenCalledTimes(2); + }); + + it("still drops the late native paste when other keys move mid-gesture", async () => { + const harness = createHarness(); + const settleReads = harness.stubClipboard("echo hi"); + await harness.create({ beforeKey: () => true }); + + harness.key("v", { metaKey: true }); + await settleReads(1); + // Rolling typing: another key goes down and a previous one comes up + // before the browser's paste event arrives. + harness.key("Shift", { shiftKey: true }); + harness.key("Shift", {}, "keyup"); + harness.paste("echo hi"); + + expect(harness.onData.mock.calls.filter(([data]) => data === "echo hi")).toHaveLength(1); + }); + + it("keeps a later menu paste once the shortcut that only the read served ends", async () => { + const harness = createHarness(); + const settleReads = harness.stubClipboard("echo hi"); + await harness.create({ beforeKey: () => true }); + + // No native paste for the shortcut: the read is the only delivery. An + // Edit → Paste of the same text afterwards has no keydown of its own. + harness.key("v", { metaKey: true }); + await settleReads(1); + harness.key("v", { metaKey: true }, "keyup"); + harness.paste("echo hi"); + expect(harness.onData).toHaveBeenCalledTimes(2); + + // Same when the read only settles after the shortcut was released. + harness.key("v", { metaKey: true }); + harness.key("v", { metaKey: true }, "keyup"); + await settleReads(2); + harness.paste("echo hi"); + expect(harness.onData).toHaveBeenCalledTimes(4); + + // Same when focus leaves before the release, so the terminal never sees it. + harness.key("v", { metaKey: true }); + await settleReads(3); + harness.blur(); + harness.paste("echo hi"); + expect(harness.onData).toHaveBeenCalledTimes(6); + }); + 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 be62ede4d065..598e3932621d 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -625,6 +625,12 @@ export class GhosttyTerminalSurface { private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); private pasteShortcutToken = 0; + // The paste shortcut gesture whose native paste may still arrive, and the + // text its clipboard read already sent (empty until then; onPaste ignores + // empty data). Chromium can deliver that paste after the read and after + // other keys move, but before the shortcut key's own keyup, so only that + // keyup, a new paste shortcut, or blur ends the gesture. + private pasteShortcutGesture: { code: string; readText: string } | null = null; private copyShortcutToken = 0; private clearSelectionAfterCopy = false; private primedCopySelection = ""; @@ -1118,19 +1124,26 @@ 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. + // Race the async clipboard read against the browser's own paste event. + // 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. A native paste that lands first claims the + // token; a read that lands first records its text so the matching + // native paste from this gesture is dropped. 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; + // Keyup or blur may already have ended the gesture. Recording + // after that would swallow a later menu paste of the same text. + if (this.pasteShortcutGesture === gesture) gesture.readText = text; + this.options.onData(this.core.encodePaste(text)); }, () => { // Clipboard read denied; the native paste event remains the path. @@ -1155,6 +1168,9 @@ export class GhosttyTerminalSurface { }; private readonly onKeyUp = (event: KeyboardEvent) => { + // The clipboard spec dispatches the keyboard paste before this keyup, so + // the duplicate from the same gesture is still dropped. + if (event.code === this.pasteShortcutGesture?.code) this.pasteShortcutGesture = null; if (this.suppressedKeyCodes.delete(event.code)) return; if (isTerminalCompositionKey(event, this.composing)) { return; @@ -1176,6 +1192,8 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; + // A menu paste after focus leaves has no keyup of its own. + 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 @@ -1235,6 +1253,12 @@ export class GhosttyTerminalSurface { event.preventDefault(); const data = event.clipboardData?.getData("text/plain") ?? ""; if (data.length === 0) return; + // The clipboard read already delivered this payload for the open shortcut. + // Drop the one native paste that belongs to that gesture. + if (data === this.pasteShortcutGesture?.readText) { + 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;