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
49 changes: 49 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,55 @@ describe("GhosttyTerminalSurface visibility", () => {
expect(readText).not.toHaveBeenCalled();
});

function paste(surface: GhosttyTerminalSurface, text: string) {
surface.input.dispatchEvent(
Object.assign(new Event("paste", { cancelable: true }), {
clipboardData: { getData: () => text },
}),
);
}

it.each(["native", "clipboard"])("pastes once when %s arrives first", async (first) => {
const harness = createHarness();
const read = Promise.resolve("echo hi");
vi.stubGlobal("navigator", {
platform: "MacIntel",
clipboard: { readText: () => read },
});
const surface = await harness.create({ beforeKey: () => true });

key(surface, "v", "KeyV", { metaKey: true });
if (first === "native") paste(surface, "echo hi");
await read;
if (first === "clipboard") paste(surface, "echo hi");

expect(harness.onData.mock.calls).toEqual([["echo hi"]]);
});

it.each(["V keyup", "blur", "Meta keyup without V keyup"])(
"accepts a later identical menu paste after %s",
async (end) => {
const harness = createHarness();
const read = Promise.resolve("echo hi");
vi.stubGlobal("navigator", {
platform: "MacIntel",
clipboard: { readText: () => read },
});
const surface = await harness.create({ beforeKey: () => true });

key(surface, "v", "KeyV", { metaKey: true });
await read;
expect(harness.onData.mock.calls).toEqual([["echo hi"]]);
if (end === "blur") surface.input.dispatchEvent(new Event("blur"));
else if (end === "V keyup") key(surface, "v", "KeyV", { metaKey: true }, "keyup");
// macOS can omit V keyup while Command is held.
else key(surface, "Meta", "MetaLeft", {}, "keyup");
paste(surface, "echo hi");

expect(harness.onData.mock.calls).toEqual([["echo hi"], ["echo hi"]]);
},
);

it("starts a selection when dragging from a link", async () => {
const harness = createHarness();
const onLinkActivate = vi.fn();
Expand Down
31 changes: 25 additions & 6 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,9 @@ export class GhosttyTerminalSurface {
private theme: GhosttyTheme;
private readonly suppressedKeyCodes = new Set<string>();
private pasteShortcutToken = 0;
// Chromium can deliver native paste after the clipboard read resolves.
// Suppress its duplicate only for the current shortcut gesture.
private pasteShortcutGesture: { code: string; readText: string } | null = null;
private copyShortcutToken = 0;
private clearSelectionAfterCopy = false;
private primedCopySelection = "";
Expand Down Expand Up @@ -1137,19 +1140,20 @@ 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.
// Keep native paste available if the read is denied. The token drops
// a late read; the gesture drops a native duplicate when the read wins.
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;
if (this.pasteShortcutGesture === gesture) gesture.readText = text;
this.options.onData(this.core.encodePaste(text));
},
() => {},
);
Expand All @@ -1166,6 +1170,15 @@ export class GhosttyTerminalSurface {
};

private readonly onKeyUp = (event: KeyboardEvent) => {
// macOS can omit V keyup while Command is held.
if (
event.code === this.pasteShortcutGesture?.code ||
event.key === "Meta" ||
event.key === "Control" ||
event.key === "Shift"
) {
this.pasteShortcutGesture = null;
}
if (this.suppressedKeyCodes.delete(event.code)) return;
if (isTerminalCompositionKey(event, this.composing)) {
return;
Expand All @@ -1187,6 +1200,7 @@ export class GhosttyTerminalSurface {

private readonly onBlur = () => {
this.focused = false;
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 @@ -1244,6 +1258,11 @@ export class GhosttyTerminalSurface {
event.preventDefault();
const data = event.clipboardData?.getData("text/plain") ?? "";
if (data.length === 0) return;
if (data === this.pasteShortcutGesture?.readText) {
// The shortcut's clipboard read already sent this paste.
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