authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-09 23:25:56-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-09 23:25:56-03:00
logd1eeb16247a0bd2eca8bbbfa5ab777e096af949c
tree930a57e4dcad0c36431fcefa0b0f8cdd8bea6ed0
parent385cc0a4c669aeae786ad47bfe3ecbbc352eff24
signature Signed by PGP key B5690EEEBB952194

fix(web): middle-click pastes in the terminal on Linux (#11018)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

2 files changed, 76 insertions(+), 3 deletions(-)

apps/web/src/terminal/ghostty/surface.test.ts+27-2
...@@ -167,13 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -167,13 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => {
167 resize() {167 resize() {
168 for (const callback of resizeCallbacks) callback();168 for (const callback of resizeCallbacks) callback();
169 },169 },
170 pointer(type: string, clientX: number, buttons: number, shiftKey = false) {170 pointer(type: string, clientX: number, buttons: number, shiftKey = false, button = 0) {
171 canvas.dispatchEvent(171 canvas.dispatchEvent(
172 Object.assign(new Event(type, { cancelable: true }), {172 Object.assign(new Event(type, { cancelable: true }), {
173 clientX,173 clientX,
174 clientY: 5,174 clientY: 5,
175 pointerId: 1,175 pointerId: 1,
176 button: 0,176 button,
177 buttons,177 buttons,
178 shiftKey,178 shiftKey,
179 }),179 }),
...@@ -280,6 +280,31 @@ describe("GhosttyTerminalSurface visibility", () => {...@@ -280,6 +280,31 @@ describe("GhosttyTerminalSurface visibility", () => {
280 expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);280 expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false);
281 });281 });
282282
283 it("pastes the terminal selection, and only that, on a Linux middle click", async () => {
284 const harness = createHarness();
285 const readText = vi.fn(async () => "clipboard text");
286 vi.stubGlobal("navigator", { platform: "Linux x86_64", clipboard: { readText } });
287 const surface = await harness.create();
288 surface.write("hello world");
289 harness.flushFrame();
290 harness.pointer("pointerdown", 5, 1);
291 harness.pointer("pointermove", 37, 1);
292 harness.pointer("pointerup", 37, 0);
293 expect(surface.getSelection()).toBe("hello");
294
295 harness.onData.mockClear();
296 harness.pointer("pointerdown", 5, 4, false, 1);
297 await vi.waitFor(() => expect(harness.onData).toHaveBeenCalled());
298 expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("hello");
299 expect(surface.getSelection()).toBe("hello");
300
301 // Without a selection there is no primary buffer to paste; the clipboard
302 // holds what the user copied and must not be substituted.
303 surface.clearSelection();
304 harness.pointer("pointerdown", 5, 4, false, 1);
305 expect(readText).not.toHaveBeenCalled();
306 });
307
283 it("starts a selection when dragging from a link", async () => {308 it("starts a selection when dragging from a link", async () => {
284 const harness = createHarness();309 const harness = createHarness();
285 const onLinkActivate = vi.fn();310 const onLinkActivate = vi.fn();
apps/web/src/terminal/ghostty/surface.ts+49-1
...@@ -395,6 +395,15 @@ export function isTerminalPasteShortcut(...@@ -395,6 +395,15 @@ export function isTerminalPasteShortcut(
395 return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;395 return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;
396}396}
397397
398/**
399 * Middle-click paste is an X11/Wayland convention. macOS and Windows have no
400 * primary selection and use the button for autoscroll, so only desktops that
401 * expect the gesture get it.
402 */
403function isMiddleClickPastePlatform(): boolean {
404 return /linux|bsd/i.test(navigator.platform);
405}
406
398export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean {407export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean {
399 return (408 return (
400 event.inputType === "" ||409 event.inputType === "" ||
...@@ -938,6 +947,20 @@ export class GhosttyTerminalSurface {...@@ -938,6 +947,20 @@ export class GhosttyTerminalSurface {
938 if (encoded.length > 0) this.options.onData(encoded);947 if (encoded.length > 0) this.options.onData(encoded);
939 }948 }
940949
950 /**
951 * Middle-click pastes the terminal's own selection, which is the only
952 * primary-selection-like buffer a browser can read. It goes through
953 * pasteFromClipboard so it joins the same paste race as every other path.
954 * With nothing selected here there is no buffer to paste, and CLIPBOARD is
955 * deliberately not substituted: middle-click must never emit text the user
956 * only ever copied.
957 */
958 private pasteTerminalSelection(): void {
959 const selection = this.getSelection();
960 if (selection.length === 0) return;
961 void this.pasteFromClipboard(() => Promise.resolve(selection));
962 }
963
941 hasSelection(): boolean {964 hasSelection(): boolean {
942 return this.core.selectionText().length > 0;965 return this.core.selectionText().length > 0;
943 }966 }
...@@ -1272,6 +1295,12 @@ export class GhosttyTerminalSurface {...@@ -1272,6 +1295,12 @@ export class GhosttyTerminalSurface {
1272 this.canvas.setPointerCapture(event.pointerId);1295 this.canvas.setPointerCapture(event.pointerId);
1273 return;1296 return;
1274 }1297 }
1298 if (event.button === 1 && isMiddleClickPastePlatform()) {
1299 // Left uncancelled on purpose: cancelling pointerdown drops the
1300 // compatibility mousedown, which is what activates a split pane.
1301 this.pasteTerminalSelection();
1302 return;
1303 }
1275 if (event.button !== 0) return;1304 if (event.button !== 0) return;
1276 const clickCount = this.recordSelectionClick(event);1305 const clickCount = this.recordSelectionClick(event);
1277 const link = this.linkAt(event.clientX, event.clientY);1306 const link = this.linkAt(event.clientX, event.clientY);
...@@ -1515,6 +1544,10 @@ export class GhosttyTerminalSurface {...@@ -1515,6 +1544,10 @@ export class GhosttyTerminalSurface {
1515 if (this.canvas.hasPointerCapture(event.pointerId)) {1544 if (this.canvas.hasPointerCapture(event.pointerId)) {
1516 this.canvas.releasePointerCapture(event.pointerId);1545 this.canvas.releasePointerCapture(event.pointerId);
1517 }1546 }
1547 if (event.button === 1 && isMiddleClickPastePlatform()) {
1548 event.preventDefault();
1549 return;
1550 }
1518 if (event.button !== 0) return;1551 if (event.button !== 0) return;
1519 if (!this.selectionMoved && this.selectionMode === "cell") {1552 if (!this.selectionMoved && this.selectionMode === "cell") {
1520 this.clearSelection();1553 this.clearSelection();
...@@ -1551,10 +1584,23 @@ export class GhosttyTerminalSurface {...@@ -1551,10 +1584,23 @@ export class GhosttyTerminalSurface {
1551 };1584 };
15521585
1553 private readonly onMouseDown = (event: MouseEvent) => {1586 private readonly onMouseDown = (event: MouseEvent) => {
1554 if (event.button === 0) event.preventDefault();1587 // Cancelling the middle button here stops autoscroll while still letting
1588 // the event bubble to the drawer handler that activates a split pane.
1589 if (event.button === 0 || (event.button === 1 && isMiddleClickPastePlatform())) {
1590 event.preventDefault();
1591 }
1555 this.focus();1592 this.focus();
1556 };1593 };
15571594
1595 /**
1596 * Chromium pastes PRIMARY into the focused editable on a middle mouseup, and
1597 * the hidden textarea is focused, so leaving the default alive would deliver
1598 * a second paste through onPaste on top of the one onPointerDown sent.
1599 */
1600 private readonly onMouseUp = (event: MouseEvent) => {
1601 if (event.button === 1 && isMiddleClickPastePlatform()) event.preventDefault();
1602 };
1603
1558 private readonly onContextMenu = (event: MouseEvent) => {1604 private readonly onContextMenu = (event: MouseEvent) => {
1559 if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {1605 if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
1560 event.preventDefault();1606 event.preventDefault();
...@@ -1644,6 +1690,7 @@ export class GhosttyTerminalSurface {...@@ -1644,6 +1690,7 @@ export class GhosttyTerminalSurface {
1644 this.canvas.addEventListener("pointercancel", this.onPointerUp);1690 this.canvas.addEventListener("pointercancel", this.onPointerUp);
1645 this.canvas.addEventListener("wheel", this.onWheel, { passive: false });1691 this.canvas.addEventListener("wheel", this.onWheel, { passive: false });
1646 this.canvas.addEventListener("mousedown", this.onMouseDown);1692 this.canvas.addEventListener("mousedown", this.onMouseDown);
1693 this.canvas.addEventListener("mouseup", this.onMouseUp);
1647 this.canvas.addEventListener("contextmenu", this.onContextMenu);1694 this.canvas.addEventListener("contextmenu", this.onContextMenu);
1648 this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown);1695 this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown);
1649 this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove);1696 this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove);
...@@ -1669,6 +1716,7 @@ export class GhosttyTerminalSurface {...@@ -1669,6 +1716,7 @@ export class GhosttyTerminalSurface {
1669 this.canvas.removeEventListener("pointercancel", this.onPointerUp);1716 this.canvas.removeEventListener("pointercancel", this.onPointerUp);
1670 this.canvas.removeEventListener("wheel", this.onWheel);1717 this.canvas.removeEventListener("wheel", this.onWheel);
1671 this.canvas.removeEventListener("mousedown", this.onMouseDown);1718 this.canvas.removeEventListener("mousedown", this.onMouseDown);
1719 this.canvas.removeEventListener("mouseup", this.onMouseUp);
1672 this.canvas.removeEventListener("contextmenu", this.onContextMenu);1720 this.canvas.removeEventListener("contextmenu", this.onContextMenu);
1673 this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown);1721 this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown);
1674 this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove);1722 this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove);