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
29 changes: 27 additions & 2 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => {
resize() {
for (const callback of resizeCallbacks) callback();
},
pointer(type: string, clientX: number, buttons: number, shiftKey = false) {
pointer(type: string, clientX: number, buttons: number, shiftKey = false, button = 0) {
canvas.dispatchEvent(
Object.assign(new Event(type, { cancelable: true }), {
clientX,
clientY: 5,
pointerId: 1,
button: 0,
button,
buttons,
shiftKey,
}),
Expand Down Expand Up @@ -280,6 +280,31 @@ describe("GhosttyTerminalSurface visibility", () => {
expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);
});

it("pastes the terminal selection, and only that, on a Linux middle click", async () => {
const harness = createHarness();
const readText = vi.fn(async () => "clipboard text");
vi.stubGlobal("navigator", { platform: "Linux x86_64", clipboard: { readText } });
const surface = await harness.create();
surface.write("hello world");
harness.flushFrame();
harness.pointer("pointerdown", 5, 1);
harness.pointer("pointermove", 37, 1);
harness.pointer("pointerup", 37, 0);
expect(surface.getSelection()).toBe("hello");

harness.onData.mockClear();
harness.pointer("pointerdown", 5, 4, false, 1);
await vi.waitFor(() => expect(harness.onData).toHaveBeenCalled());
expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("hello");
expect(surface.getSelection()).toBe("hello");

// Without a selection there is no primary buffer to paste; the clipboard
// holds what the user copied and must not be substituted.
surface.clearSelection();
harness.pointer("pointerdown", 5, 4, false, 1);
expect(readText).not.toHaveBeenCalled();
});

it("starts a selection when dragging from a link", async () => {
const harness = createHarness();
const onLinkActivate = vi.fn();
Expand Down
50 changes: 49 additions & 1 deletion apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,15 @@ export function isTerminalPasteShortcut(
return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;
}

/**
* Middle-click paste is an X11/Wayland convention. macOS and Windows have no
* primary selection and use the button for autoscroll, so only desktops that
* expect the gesture get it.
*/
function isMiddleClickPastePlatform(): boolean {
return /linux|bsd/i.test(navigator.platform);
}

export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean {
return (
event.inputType === "" ||
Expand Down Expand Up @@ -938,6 +947,20 @@ export class GhosttyTerminalSurface {
if (encoded.length > 0) this.options.onData(encoded);
}

/**
* Middle-click pastes the terminal's own selection, which is the only
* primary-selection-like buffer a browser can read. It goes through
* pasteFromClipboard so it joins the same paste race as every other path.
* With nothing selected here there is no buffer to paste, and CLIPBOARD is
* deliberately not substituted: middle-click must never emit text the user
* only ever copied.
*/
private pasteTerminalSelection(): void {
const selection = this.getSelection();
if (selection.length === 0) return;
void this.pasteFromClipboard(() => Promise.resolve(selection));
}

hasSelection(): boolean {
return this.core.selectionText().length > 0;
}
Expand Down Expand Up @@ -1272,6 +1295,12 @@ export class GhosttyTerminalSurface {
this.canvas.setPointerCapture(event.pointerId);
return;
}
if (event.button === 1 && isMiddleClickPastePlatform()) {
// Left uncancelled on purpose: cancelling pointerdown drops the
// compatibility mousedown, which is what activates a split pane.
this.pasteTerminalSelection();
return;
}
if (event.button !== 0) return;
const clickCount = this.recordSelectionClick(event);
const link = this.linkAt(event.clientX, event.clientY);
Expand Down Expand Up @@ -1515,6 +1544,10 @@ export class GhosttyTerminalSurface {
if (this.canvas.hasPointerCapture(event.pointerId)) {
this.canvas.releasePointerCapture(event.pointerId);
}
if (event.button === 1 && isMiddleClickPastePlatform()) {
event.preventDefault();
return;
}
if (event.button !== 0) return;
if (!this.selectionMoved && this.selectionMode === "cell") {
this.clearSelection();
Expand Down Expand Up @@ -1551,10 +1584,23 @@ export class GhosttyTerminalSurface {
};

private readonly onMouseDown = (event: MouseEvent) => {
if (event.button === 0) event.preventDefault();
// Cancelling the middle button here stops autoscroll while still letting
// the event bubble to the drawer handler that activates a split pane.
if (event.button === 0 || (event.button === 1 && isMiddleClickPastePlatform())) {
event.preventDefault();
}
this.focus();
};

/**
* Chromium pastes PRIMARY into the focused editable on a middle mouseup, and
* the hidden textarea is focused, so leaving the default alive would deliver
* a second paste through onPaste on top of the one onPointerDown sent.
*/
private readonly onMouseUp = (event: MouseEvent) => {
if (event.button === 1 && isMiddleClickPastePlatform()) event.preventDefault();
};

private readonly onContextMenu = (event: MouseEvent) => {
if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
event.preventDefault();
Expand Down Expand Up @@ -1644,6 +1690,7 @@ export class GhosttyTerminalSurface {
this.canvas.addEventListener("pointercancel", this.onPointerUp);
this.canvas.addEventListener("wheel", this.onWheel, { passive: false });
this.canvas.addEventListener("mousedown", this.onMouseDown);
this.canvas.addEventListener("mouseup", this.onMouseUp);
this.canvas.addEventListener("contextmenu", this.onContextMenu);
this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown);
this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove);
Expand All @@ -1669,6 +1716,7 @@ export class GhosttyTerminalSurface {
this.canvas.removeEventListener("pointercancel", this.onPointerUp);
this.canvas.removeEventListener("wheel", this.onWheel);
this.canvas.removeEventListener("mousedown", this.onMouseDown);
this.canvas.removeEventListener("mouseup", this.onMouseUp);
this.canvas.removeEventListener("contextmenu", this.onContextMenu);
this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown);
this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove);
Expand Down
Loading