diff --git a/src/modules/terminal/lib/rendererPool.ts b/src/modules/terminal/lib/rendererPool.ts index 81c7d098a..3d83b6268 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 { + clearLiveTerminalSelection, + 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 terminalSelectionDrag: { slot: Slot; leafId: number } | null = null; let cursorBlinkEnabled = false; function bindWindowActivityListeners(): void { @@ -90,6 +97,60 @@ function bindWindowActivityListeners(): void { document.addEventListener("visibilitychange", sync); } +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 = () => { + const drag = terminalSelectionDrag; + if (!drag) return; + terminalSelectionDrag = null; + clearLiveTerminalSelection(drag.slot, drag.leafId); + }; + + window.addEventListener( + "mousedown", + (event) => { + terminalSelectionDrag = isTerminalSelectionStart(event, event.target) + ? getTrackedSelectionDrag(event.target) + : null; + }, + { capture: true }, + ); + window.addEventListener( + "mouseup", + (event) => { + if (event.button === 0) terminalSelectionDrag = null; + }, + { capture: true }, + ); + window.addEventListener( + "mousemove", + (event) => { + if (isReleasedMoveDuringSelection(terminalSelectionDrag !== null, 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 +166,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..d7e49087e --- /dev/null +++ b/src/modules/terminal/lib/stuckSelectionGuard.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { + clearLiveTerminalSelection, + 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 selection only on the tracked live terminal slot", () => { + const liveClear = vi.fn(); + const otherClear = vi.fn(); + const retainedClear = vi.fn(); + + 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 new file mode 100644 index 000000000..9af137d73 --- /dev/null +++ b/src/modules/terminal/lib/stuckSelectionGuard.ts @@ -0,0 +1,34 @@ +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 clearLiveTerminalSelection( + slot: TerminalSelectionSlot | null, + leafId: number, +): void { + if (slot?.currentLeafId !== leafId) return; + slot.term.clearSelection(); +}