Skip to content

Commit b4e75c1

Browse files
fix(web): end terminal paste gestures on modifier release
1 parent 22c5d9c commit b4e75c1

2 files changed

Lines changed: 55 additions & 48 deletions

File tree

‎apps/web/src/terminal/ghostty/surface.test.ts‎

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -777,50 +777,55 @@ describe("GhosttyTerminalSurface visibility", () => {
777777
expect(readText).not.toHaveBeenCalled();
778778
});
779779

780-
it("pastes once per shortcut whichever of the clipboard read and native paste lands first", async () => {
780+
function paste(surface: GhosttyTerminalSurface, text: string) {
781+
surface.input.dispatchEvent(
782+
Object.assign(new Event("paste", { cancelable: true }), {
783+
clipboardData: { getData: () => text },
784+
}),
785+
);
786+
}
787+
788+
it.each(["native", "clipboard"])("pastes once when %s arrives first", async (first) => {
781789
const harness = createHarness();
782-
const readText = vi.fn(async () => "echo hi");
783-
vi.stubGlobal("navigator", { platform: "MacIntel", clipboard: { readText } });
790+
const read = Promise.resolve("echo hi");
791+
vi.stubGlobal("navigator", {
792+
platform: "MacIntel",
793+
clipboard: { readText: () => read },
794+
});
784795
const surface = await harness.create({ beforeKey: () => true });
785-
const paste = (text: string) =>
786-
surface.input.dispatchEvent(
787-
Object.assign(new Event("paste", { cancelable: true }), {
788-
clipboardData: { getData: () => text },
789-
}),
790-
);
791-
const settleRead = async (reads: number) => {
792-
expect(readText).toHaveBeenCalledTimes(reads);
793-
await readText.mock.results[reads - 1]?.value;
794-
};
795796

796-
// Read first (Chromium menu paste): the native paste that follows is dropped.
797797
key(surface, "v", "KeyV", { metaKey: true });
798-
await settleRead(1);
799-
paste("echo hi");
800-
key(surface, "v", "KeyV", { metaKey: true }, "keyup");
801-
expect(harness.onData).toHaveBeenCalledTimes(1);
798+
if (first === "native") paste(surface, "echo hi");
799+
await read;
800+
if (first === "clipboard") paste(surface, "echo hi");
802801

803-
// Native first: the read that settles afterwards is dropped.
804-
key(surface, "v", "KeyV", { metaKey: true });
805-
paste("echo hi");
806-
await settleRead(2);
807-
key(surface, "v", "KeyV", { metaKey: true }, "keyup");
808-
expect(harness.onData).toHaveBeenCalledTimes(2);
809-
810-
// A shortcut served only by the read must not swallow a later Edit → Paste
811-
// of the same text, after the keyup or after a blur.
812-
key(surface, "v", "KeyV", { metaKey: true });
813-
await settleRead(3);
814-
key(surface, "v", "KeyV", { metaKey: true }, "keyup");
815-
paste("echo hi");
816-
key(surface, "v", "KeyV", { metaKey: true });
817-
await settleRead(4);
818-
surface.input.dispatchEvent(new Event("blur"));
819-
paste("echo hi");
820-
expect(harness.onData).toHaveBeenCalledTimes(6);
821-
expect(new Set(harness.onData.mock.calls.map(([data]) => data))).toEqual(new Set(["echo hi"]));
802+
expect(harness.onData.mock.calls).toEqual([["echo hi"]]);
822803
});
823804

805+
it.each(["V keyup", "blur", "Meta keyup without V keyup"])(
806+
"accepts a later identical menu paste after %s",
807+
async (end) => {
808+
const harness = createHarness();
809+
const read = Promise.resolve("echo hi");
810+
vi.stubGlobal("navigator", {
811+
platform: "MacIntel",
812+
clipboard: { readText: () => read },
813+
});
814+
const surface = await harness.create({ beforeKey: () => true });
815+
816+
key(surface, "v", "KeyV", { metaKey: true });
817+
await read;
818+
expect(harness.onData.mock.calls).toEqual([["echo hi"]]);
819+
if (end === "blur") surface.input.dispatchEvent(new Event("blur"));
820+
else if (end === "V keyup") key(surface, "v", "KeyV", { metaKey: true }, "keyup");
821+
// macOS can omit V keyup while Command is held.
822+
else key(surface, "Meta", "MetaLeft", {}, "keyup");
823+
paste(surface, "echo hi");
824+
825+
expect(harness.onData.mock.calls).toEqual([["echo hi"], ["echo hi"]]);
826+
},
827+
);
828+
824829
it("starts a selection when dragging from a link", async () => {
825830
const harness = createHarness();
826831
const onLinkActivate = vi.fn();

‎apps/web/src/terminal/ghostty/surface.ts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -580,10 +580,8 @@ export class GhosttyTerminalSurface {
580580
private theme: GhosttyTheme;
581581
private readonly suppressedKeyCodes = new Set<string>();
582582
private pasteShortcutToken = 0;
583-
// The paste shortcut whose native paste event may still arrive, and the text
584-
// its clipboard read already sent ("" until then). Chromium can deliver that
585-
// event after the read resolves, but before the shortcut key's keyup, so the
586-
// gesture ends on that keyup, the next shortcut, blur, or the matching paste.
583+
// Chromium can deliver native paste after the clipboard read resolves.
584+
// Suppress its duplicate only for the current shortcut gesture.
587585
private pasteShortcutGesture: { code: string; readText: string } | null = null;
588586
private copyShortcutToken = 0;
589587
private clearSelectionAfterCopy = false;
@@ -1146,12 +1144,8 @@ export class GhosttyTerminalSurface {
11461144
this.pasteShortcutGesture = gesture;
11471145
const clipboard = navigator.clipboard;
11481146
if (typeof clipboard?.readText === "function") {
1149-
// Race the async clipboard read against the browser's own paste event.
1150-
// The read covers browsers whose paste shortcut produces no paste
1151-
// event; not preventing the default keeps the native path alive when
1152-
// the read is denied. Whichever lands first wins: a native paste
1153-
// claims the token, and a read records its text on the gesture so
1154-
// onPaste drops the native paste that follows (Chromium menu paste).
1147+
// Keep native paste available if the read is denied. The token drops
1148+
// a late read; the gesture drops a native duplicate when the read wins.
11551149
const token = ++this.pasteShortcutToken;
11561150
void clipboard.readText().then(
11571151
(text) => {
@@ -1176,7 +1170,15 @@ export class GhosttyTerminalSurface {
11761170
};
11771171

11781172
private readonly onKeyUp = (event: KeyboardEvent) => {
1179-
if (event.code === this.pasteShortcutGesture?.code) this.pasteShortcutGesture = null;
1173+
// macOS can omit V keyup while Command is held.
1174+
if (
1175+
event.code === this.pasteShortcutGesture?.code ||
1176+
event.key === "Meta" ||
1177+
event.key === "Control" ||
1178+
event.key === "Shift"
1179+
) {
1180+
this.pasteShortcutGesture = null;
1181+
}
11801182
if (this.suppressedKeyCodes.delete(event.code)) return;
11811183
if (isTerminalCompositionKey(event, this.composing)) {
11821184
return;

0 commit comments

Comments
 (0)