Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { GhosttyCell, GhosttyRow } from "./core";
import {
DEFAULT_TERMINAL_FONT_FAMILY,
DEFAULT_TERMINAL_FONT_SIZE,
GhosttyTerminalSurface,
advanceTerminalSelectionClickSequence,
applyTerminalCopyEvent,
clearPrimedTerminalCopyInput,
Expand Down Expand Up @@ -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<string>((next) => {
resolve = next;
});
return { promise, resolve };
};

const cell = (text: string): GhosttyCell => ({
text,
wide: 0,
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 9 additions & 10 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = () => {
Expand Down
Loading