Skip to content
Merged
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
48 changes: 47 additions & 1 deletion apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe("GhosttyTerminalSurface visibility", () => {

const canvas = new TerminalTestElement();
const mount = new TerminalTestElement();
let input: TerminalTestElement | undefined;
const context = {
canvas,
beginPath() {},
Expand All @@ -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(
Expand Down Expand Up @@ -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<GhosttyTerminalSurfaceOptions> = {}) {
const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, {
theme: {
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 16 additions & 6 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,8 @@ export class GhosttyTerminalSurface {
private theme: GhosttyTheme;
private readonly suppressedKeyCodes = new Set<string>();
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 = "";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Loading