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
116 changes: 115 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,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<GhosttyTerminalSurfaceOptions> = {}) {
const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, {
theme: {
Expand Down Expand Up @@ -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();
Expand Down
36 changes: 30 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,12 @@ export class GhosttyTerminalSurface {
private theme: GhosttyTheme;
private readonly suppressedKeyCodes = new Set<string>();
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 = "";
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading