Skip to content
Open
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
62 changes: 62 additions & 0 deletions src/modules/terminal/lib/rendererPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions src/modules/terminal/lib/stuckSelectionGuard.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
34 changes: 34 additions & 0 deletions src/modules/terminal/lib/stuckSelectionGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
type ClosestTarget = {
closest?: (selector: string) => unknown;
};

export type TerminalSelectionSlot = {
currentLeafId: number | null;
term: {
clearSelection(): void;
};
};

export function isTerminalSelectionStart(
event: Pick<MouseEvent, "button">,
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<MouseEvent, "buttons">,
): boolean {
return trackingSelection && event.buttons === 0;
}

export function clearLiveTerminalSelection(
slot: TerminalSelectionSlot | null,
leafId: number,
): void {
if (slot?.currentLeafId !== leafId) return;
slot.term.clearSelection();
}