From 9b0b5b20f7577968706e1c6f6fd5ee21b44eb77a Mon Sep 17 00:00:00 2001 From: PeppaPigw Date: Wed, 1 Jul 2026 22:39:58 +0800 Subject: [PATCH 1/2] fix(terminal): clear stuck xterm selection drag --- src/modules/terminal/lib/rendererPool.ts | 50 +++++++++++++++++++ .../terminal/lib/stuckSelectionGuard.test.ts | 42 ++++++++++++++++ .../terminal/lib/stuckSelectionGuard.ts | 35 +++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/modules/terminal/lib/stuckSelectionGuard.test.ts create mode 100644 src/modules/terminal/lib/stuckSelectionGuard.ts diff --git a/src/modules/terminal/lib/rendererPool.ts b/src/modules/terminal/lib/rendererPool.ts index 81c7d098a..b0a9d2be3 100644 --- a/src/modules/terminal/lib/rendererPool.ts +++ b/src/modules/terminal/lib/rendererPool.ts @@ -18,6 +18,11 @@ import { terminalLineNavigationSequence, terminalWordNavigationSequence, } from "./keymap"; +import { + clearLiveTerminalSelections, + isReleasedMoveDuringSelection, + isTerminalSelectionStart, +} from "./stuckSelectionGuard"; export const POOL_MAX_SIZE = 5; const FIT_DEBOUNCE_MS = 8; @@ -79,6 +84,8 @@ let adapter: SlotAdapter | null = null; let windowActive = typeof document === "undefined" || (!document.hidden && document.hasFocus()); let windowActivityBound = false; +let stuckSelectionGuardBound = false; +let terminalSelectionMouseDown = false; let cursorBlinkEnabled = false; function bindWindowActivityListeners(): void { @@ -90,6 +97,48 @@ function bindWindowActivityListeners(): void { document.addEventListener("visibilitychange", sync); } +function bindStuckSelectionGuard(): void { + if (stuckSelectionGuardBound || typeof window === "undefined") return; + stuckSelectionGuardBound = true; + + const clearTrackedSelection = () => { + if (!terminalSelectionMouseDown) return; + terminalSelectionMouseDown = false; + clearLiveTerminalSelections(slots); + }; + + window.addEventListener( + "mousedown", + (event) => { + terminalSelectionMouseDown = isTerminalSelectionStart( + event, + event.target, + ); + }, + { capture: true }, + ); + window.addEventListener( + "mouseup", + (event) => { + if (event.button === 0) terminalSelectionMouseDown = false; + }, + { capture: true }, + ); + window.addEventListener( + "mousemove", + (event) => { + if (isReleasedMoveDuringSelection(terminalSelectionMouseDown, event)) { + clearTrackedSelection(); + } + }, + { capture: true }, + ); + window.addEventListener("blur", clearTrackedSelection); + document.addEventListener("visibilitychange", () => { + if (document.hidden) clearTrackedSelection(); + }); +} + function setWindowActive(active: boolean): void { if (windowActive === active) return; windowActive = active; @@ -105,6 +154,7 @@ function setWindowActive(active: boolean): void { export function configureRendererPool(a: SlotAdapter): void { adapter = a; bindWindowActivityListeners(); + bindStuckSelectionGuard(); } export function forEachSlot(fn: (slot: Slot) => void): void { diff --git a/src/modules/terminal/lib/stuckSelectionGuard.test.ts b/src/modules/terminal/lib/stuckSelectionGuard.test.ts new file mode 100644 index 000000000..100644351 --- /dev/null +++ b/src/modules/terminal/lib/stuckSelectionGuard.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { + clearLiveTerminalSelections, + isReleasedMoveDuringSelection, + isTerminalSelectionStart, +} from "./stuckSelectionGuard"; + +function target(matches: boolean) { + return { + closest: vi.fn((selector: string) => + selector === ".xterm" && matches ? {} : null, + ), + } as unknown as EventTarget; +} + +describe("stuck terminal selection guard", () => { + it("tracks only primary-button mousedown inside xterm", () => { + expect(isTerminalSelectionStart({ button: 0 }, target(true))).toBe(true); + expect(isTerminalSelectionStart({ button: 1 }, target(true))).toBe(false); + expect(isTerminalSelectionStart({ button: 0 }, target(false))).toBe(false); + expect(isTerminalSelectionStart({ button: 0 }, null)).toBe(false); + }); + + it("clears only when a tracked selection receives a released mousemove", () => { + expect(isReleasedMoveDuringSelection(true, { buttons: 0 })).toBe(true); + expect(isReleasedMoveDuringSelection(true, { buttons: 1 })).toBe(false); + expect(isReleasedMoveDuringSelection(false, { buttons: 0 })).toBe(false); + }); + + it("clears selections only on live terminal slots", () => { + const liveClear = vi.fn(); + const retainedClear = vi.fn(); + + clearLiveTerminalSelections([ + { currentLeafId: 1, term: { clearSelection: liveClear } }, + { currentLeafId: null, term: { clearSelection: retainedClear } }, + ]); + + expect(liveClear).toHaveBeenCalledOnce(); + expect(retainedClear).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/terminal/lib/stuckSelectionGuard.ts b/src/modules/terminal/lib/stuckSelectionGuard.ts new file mode 100644 index 000000000..c193544fd --- /dev/null +++ b/src/modules/terminal/lib/stuckSelectionGuard.ts @@ -0,0 +1,35 @@ +type ClosestTarget = { + closest?: (selector: string) => unknown; +}; + +export type TerminalSelectionSlot = { + currentLeafId: number | null; + term: { + clearSelection(): void; + }; +}; + +export function isTerminalSelectionStart( + event: Pick, + target: EventTarget | null, +): boolean { + if (event.button !== 0) return false; + const closest = (target as ClosestTarget | null)?.closest; + return typeof closest === "function" && !!closest.call(target, ".xterm"); +} + +export function isReleasedMoveDuringSelection( + trackingSelection: boolean, + event: Pick, +): boolean { + return trackingSelection && event.buttons === 0; +} + +export function clearLiveTerminalSelections( + slots: Iterable, +): void { + for (const slot of slots) { + if (slot.currentLeafId === null) continue; + slot.term.clearSelection(); + } +} From 77fc1ef6bfc3f3d800ad78ccb269393c6262f309 Mon Sep 17 00:00:00 2001 From: PeppaPigw Date: Thu, 2 Jul 2026 10:34:43 +0800 Subject: [PATCH 2/2] fix(terminal): limit stuck selection cleanup to tracked slot --- src/modules/terminal/lib/rendererPool.ts | 34 +++++++++++++------ .../terminal/lib/stuckSelectionGuard.test.ts | 18 +++++++--- .../terminal/lib/stuckSelectionGuard.ts | 11 +++--- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/modules/terminal/lib/rendererPool.ts b/src/modules/terminal/lib/rendererPool.ts index b0a9d2be3..3d83b6268 100644 --- a/src/modules/terminal/lib/rendererPool.ts +++ b/src/modules/terminal/lib/rendererPool.ts @@ -19,7 +19,7 @@ import { terminalWordNavigationSequence, } from "./keymap"; import { - clearLiveTerminalSelections, + clearLiveTerminalSelection, isReleasedMoveDuringSelection, isTerminalSelectionStart, } from "./stuckSelectionGuard"; @@ -85,7 +85,7 @@ let windowActive = typeof document === "undefined" || (!document.hidden && document.hasFocus()); let windowActivityBound = false; let stuckSelectionGuardBound = false; -let terminalSelectionMouseDown = false; +let terminalSelectionDrag: { slot: Slot; leafId: number } | null = null; let cursorBlinkEnabled = false; function bindWindowActivityListeners(): void { @@ -101,33 +101,45 @@ function bindStuckSelectionGuard(): void { if (stuckSelectionGuardBound || typeof window === "undefined") return; stuckSelectionGuardBound = true; + const getTrackedSelectionDrag = ( + target: EventTarget | null, + ): { slot: Slot; leafId: number } | null => { + if (!(target instanceof Node)) return null; + const slot = + slots.find( + (slot) => slot.currentLeafId !== null && slot.host.contains(target), + ) ?? null; + if (slot === null || slot.currentLeafId === null) return null; + return { slot, leafId: slot.currentLeafId }; + }; + const clearTrackedSelection = () => { - if (!terminalSelectionMouseDown) return; - terminalSelectionMouseDown = false; - clearLiveTerminalSelections(slots); + const drag = terminalSelectionDrag; + if (!drag) return; + terminalSelectionDrag = null; + clearLiveTerminalSelection(drag.slot, drag.leafId); }; window.addEventListener( "mousedown", (event) => { - terminalSelectionMouseDown = isTerminalSelectionStart( - event, - event.target, - ); + terminalSelectionDrag = isTerminalSelectionStart(event, event.target) + ? getTrackedSelectionDrag(event.target) + : null; }, { capture: true }, ); window.addEventListener( "mouseup", (event) => { - if (event.button === 0) terminalSelectionMouseDown = false; + if (event.button === 0) terminalSelectionDrag = null; }, { capture: true }, ); window.addEventListener( "mousemove", (event) => { - if (isReleasedMoveDuringSelection(terminalSelectionMouseDown, event)) { + if (isReleasedMoveDuringSelection(terminalSelectionDrag !== null, event)) { clearTrackedSelection(); } }, diff --git a/src/modules/terminal/lib/stuckSelectionGuard.test.ts b/src/modules/terminal/lib/stuckSelectionGuard.test.ts index 100644351..d7e49087e 100644 --- a/src/modules/terminal/lib/stuckSelectionGuard.test.ts +++ b/src/modules/terminal/lib/stuckSelectionGuard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { - clearLiveTerminalSelections, + clearLiveTerminalSelection, isReleasedMoveDuringSelection, isTerminalSelectionStart, } from "./stuckSelectionGuard"; @@ -27,16 +27,26 @@ describe("stuck terminal selection guard", () => { expect(isReleasedMoveDuringSelection(false, { buttons: 0 })).toBe(false); }); - it("clears selections only on live terminal slots", () => { + it("clears selection only on the tracked live terminal slot", () => { const liveClear = vi.fn(); + const otherClear = vi.fn(); const retainedClear = vi.fn(); - clearLiveTerminalSelections([ + clearLiveTerminalSelection( { currentLeafId: 1, term: { clearSelection: liveClear } }, + 1, + ); + clearLiveTerminalSelection( + { currentLeafId: 2, term: { clearSelection: otherClear } }, + 1, + ); + clearLiveTerminalSelection( { currentLeafId: null, term: { clearSelection: retainedClear } }, - ]); + 1, + ); expect(liveClear).toHaveBeenCalledOnce(); + expect(otherClear).not.toHaveBeenCalled(); expect(retainedClear).not.toHaveBeenCalled(); }); }); diff --git a/src/modules/terminal/lib/stuckSelectionGuard.ts b/src/modules/terminal/lib/stuckSelectionGuard.ts index c193544fd..9af137d73 100644 --- a/src/modules/terminal/lib/stuckSelectionGuard.ts +++ b/src/modules/terminal/lib/stuckSelectionGuard.ts @@ -25,11 +25,10 @@ export function isReleasedMoveDuringSelection( return trackingSelection && event.buttons === 0; } -export function clearLiveTerminalSelections( - slots: Iterable, +export function clearLiveTerminalSelection( + slot: TerminalSelectionSlot | null, + leafId: number, ): void { - for (const slot of slots) { - if (slot.currentLeafId === null) continue; - slot.term.clearSelection(); - } + if (slot?.currentLeafId !== leafId) return; + slot.term.clearSelection(); }