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;