diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 11b3cc890fdb..ccbccb0c6467 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -4,6 +4,7 @@ import type { GhosttyCell, GhosttyRow } from "./core"; import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, + GhosttyTerminalSurface, advanceTerminalSelectionClickSequence, applyTerminalCopyEvent, clearPrimedTerminalCopyInput, @@ -33,6 +34,25 @@ import { terminalWheelDeltaRows, } from "./surface"; +const terminalSurfaceForPaste = (onData: (data: string) => void) => { + const surface = Object.create(GhosttyTerminalSurface.prototype) as GhosttyTerminalSurface; + Object.assign(surface, { + disposed: false, + pasteShortcutToken: 0, + options: { onData }, + core: { encodePaste: (data: string) => `[paste]${data}` }, + }); + return surface; +}; + +const deferredClipboardText = () => { + let resolve: (text: string) => void = () => {}; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +}; + const cell = (text: string): GhosttyCell => ({ text, wide: 0, @@ -349,6 +369,50 @@ describe("isTerminalPasteShortcut", () => { }); }); +describe("GhosttyTerminalSurface paste races", () => { + it("lets a keyboard paste supersede a pending context-menu read", async () => { + const onData = vi.fn(); + const surface = terminalSurfaceForPaste(onData); + const clipboard = deferredClipboardText(); + const contextMenuPaste = surface.pasteFromClipboard(() => clipboard.promise); + + surface.paste("keyboard"); + clipboard.resolve("context menu"); + await contextMenuPaste; + + expect(onData).toHaveBeenCalledOnce(); + expect(onData).toHaveBeenCalledWith("[paste]keyboard"); + }); + + it("lets a native paste supersede a pending shortcut read", async () => { + const onData = vi.fn(); + const surface = terminalSurfaceForPaste(onData); + const clipboard = deferredClipboardText(); + const shortcutPaste = surface.pasteFromClipboard(() => clipboard.promise); + + surface.paste("native"); + clipboard.resolve("shortcut"); + await shortcutPaste; + + expect(onData).toHaveBeenCalledOnce(); + expect(onData).toHaveBeenCalledWith("[paste]native"); + }); + + it("keeps a pending clipboard read current after an empty paste", async () => { + const onData = vi.fn(); + const surface = terminalSurfaceForPaste(onData); + const clipboard = deferredClipboardText(); + const shortcutPaste = surface.pasteFromClipboard(() => clipboard.promise); + + surface.paste(""); + clipboard.resolve("shortcut"); + await shortcutPaste; + + expect(onData).toHaveBeenCalledOnce(); + expect(onData).toHaveBeenCalledWith("[paste]shortcut"); + }); +}); + describe("isTerminalCompositionCommitInput", () => { it("identifies browser composition follow-up input", () => { expect(isTerminalCompositionCommitInput({ inputType: "" })).toBe(true); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index d4e503d19f00..82ab86f8dcbe 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -885,6 +885,12 @@ export class GhosttyTerminalSurface { this.input.focus({ preventScroll: true }); } + paste(data: string): void { + if (this.disposed || data.length === 0) return; + this.pasteShortcutToken += 1; + this.options.onData(this.core.encodePaste(data)); + } + /** * Pastes clipboard text read by the host (context menu) with the same * bracketed-paste encoding as a native paste event. The read joins the same @@ -899,12 +905,7 @@ export class GhosttyTerminalSurface { const token = ++this.pasteShortcutToken; const text = await readText(); if (this.disposed || this.pasteShortcutToken !== token || !isCurrent()) return; - // As in every paste path, delivering bumps the token so a clipboard read - // still in flight cannot land after this text reaches the shell. - this.pasteShortcutToken += 1; - if (text.length === 0) return; - const encoded = this.core.encodePaste(text); - if (encoded.length > 0) this.options.onData(encoded); + this.paste(text); } hasSelection(): boolean { @@ -1077,8 +1078,7 @@ export class GhosttyTerminalSurface { 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)); + this.paste(text); }, () => { // Clipboard read denied; the native paste event remains the path. @@ -1187,8 +1187,7 @@ export class GhosttyTerminalSurface { if (data.length === 0) 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; - this.options.onData(this.core.encodePaste(data)); + this.paste(data); }; private readonly onCompositionStart = () => {