diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 59150ee320ae..da22657826df 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,27 @@ describe("GhosttyTerminalSurface visibility", () => { }), ); }, + key(key: string, modifiers: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) { + input?.dispatchEvent( + Object.assign(new Event("keydown", { cancelable: true }), { + key, + code: `Key${key.toUpperCase()}`, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...modifiers, + getModifierState: () => false, + }), + ); + }, + 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 +332,25 @@ 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(); + vi.stubGlobal("navigator", { + platform: "MacIntel", + clipboard: { readText: async () => "echo hi" }, + }); + await harness.create({ beforeKey: () => true }); + + harness.key("v", { metaKey: true }); + await vi.waitFor(() => expect(harness.onData).toHaveBeenCalledTimes(1)); + harness.paste("echo hi"); + expect(harness.onData).toHaveBeenCalledTimes(1); + + // The next paste gesture is a new paste, not the tail of the last one. + harness.key("v", { metaKey: true }); + harness.paste("echo hi"); + expect(harness.onData).toHaveBeenCalledTimes(2); + }); + 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..b86ab4ff1e3f 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -625,6 +625,8 @@ export class GhosttyTerminalSurface { private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); private pasteShortcutToken = 0; + /** Text the paste shortcut's clipboard read already sent, until the next keydown. */ + private shortcutPasteReadText: string | null = null; private copyShortcutToken = 0; private clearSelectionAfterCopy = false; private primedCopySelection = ""; @@ -1053,6 +1055,7 @@ export class GhosttyTerminalSurface { } private readonly onKeyDown = (event: KeyboardEvent) => { + this.shortcutPasteReadText = null; // Presses handled outside the terminal must also swallow their release: // beforeKey runs side effects (keybindings, navigation sends), so it cannot // be consulted again on keyup, and Kitty report-event-types sessions would @@ -1120,17 +1123,20 @@ export class GhosttyTerminalSurface { this.suppressedKeyCodes.add(event.code); 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. Either side can land first: a native paste + // claims the token, and a delivered read records its text so the + // native paste that follows it is dropped in onPaste. 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; + this.shortcutPasteReadText = text; + this.options.onData(this.core.encodePaste(text)); }, () => { // Clipboard read denied; the native paste event remains the path. @@ -1235,6 +1241,10 @@ export class GhosttyTerminalSurface { event.preventDefault(); const data = event.clipboardData?.getData("text/plain") ?? ""; if (data.length === 0) return; + if (data === this.shortcutPasteReadText) { + this.shortcutPasteReadText = 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;