feat(admin): shift-select multiple sessions in process list

This commit is contained in:
zipg authored and GitHub committed 2026-09-20 16:49:57 +08:00
1 parent 08cb9287de
commit ec7bc617c4
8 files changed
+144 -9

No files matched your search

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { processListSelectionAfterClick, type ProcessListSelectionState } from "@/lib/database/processListSelection";
const rows = [{ id: 11 }, { id: 12 }, { id: 13 }, { id: 14 }, { id: 15 }];
const plain = { shiftKey: false, metaKey: false, ctrlKey: false };
const shift = { shiftKey: true, metaKey: false, ctrlKey: false };
const ctrl = { shiftKey: false, metaKey: false, ctrlKey: true };
function state(selected: number[], anchorId: number | null): ProcessListSelectionState {
return { selected: new Set(selected), anchorId };
}
describe("processListSelectionAfterClick", () => {
it("toggles one row and moves the anchor on a plain click", () => {
expect(processListSelectionAfterClick(rows, state([], null), 12, plain)).toEqual({ selected: new Set([12]), anchorId: 12 });
expect(processListSelectionAfterClick(rows, state([12, 14], 12), 14, plain)).toEqual({ selected: new Set([12]), anchorId: 14 });
});
it("selects the whole range on Shift+click in either direction", () => {
expect(processListSelectionAfterClick(rows, state([12], 12), 15, shift)).toEqual({ selected: new Set([12, 13, 14, 15]), anchorId: 12 });
expect(processListSelectionAfterClick(rows, state([15], 15), 12, shift)).toEqual({ selected: new Set([12, 13, 14, 15]), anchorId: 15 });
});
it("deselects the range when the Shift+clicked row was already selected", () => {
expect(processListSelectionAfterClick(rows, state([12, 13, 14, 15], 12), 14, shift)).toEqual({ selected: new Set([15]), anchorId: 12 });
});
it("keeps the anchor so consecutive Shift+clicks re-extend from the same row", () => {
const first = processListSelectionAfterClick(rows, state([12], 12), 14, shift);
expect(processListSelectionAfterClick(rows, first, 15, shift)).toEqual({ selected: new Set([12, 13, 14, 15]), anchorId: 12 });
});
it("keeps rows outside the range untouched", () => {
expect(processListSelectionAfterClick(rows, state([11, 12, 15], 12), 13, shift)).toEqual({ selected: new Set([11, 12, 13, 15]), anchorId: 12 });
});
it("treats Cmd/Ctrl as a single toggle", () => {
expect(processListSelectionAfterClick(rows, state([12], 12), 14, ctrl)).toEqual({ selected: new Set([12, 14]), anchorId: 14 });
});
it("falls back to a plain toggle when the Shift anchor is gone", () => {
expect(processListSelectionAfterClick(rows, state([12], 99), 13, shift)).toEqual({ selected: new Set([12, 13]), anchorId: 13 });
expect(processListSelectionAfterClick(rows, state([12], null), 13, shift)).toEqual({ selected: new Set([12, 13]), anchorId: 13 });
});
it("ignores ids that are not selectable in the current list", () => {
const current = state([12], 12);
expect(processListSelectionAfterClick(rows, current, 99, plain)).toBe(current);
expect(processListSelectionAfterClick(rows, current, 99, shift)).toBe(current);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { orderedListRangeAnchorIndex, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
import { orderedListRangeAnchorIndex, orderedListRangeIndices, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
describe("orderedListSelectionIntent", () => {
it("uses Shift for range selection even when another modifier is also reported", () => {
@@ -38,3 +38,21 @@ describe("orderedListRangeAnchorIndex", () => {
expect(orderedListRangeAnchorIndex(items, null, { type: "file", id: "missing" })).toBeNull();
});
});
describe("orderedListRangeIndices", () => {
it("covers both directions inclusively", () => {
expect(orderedListRangeIndices(5, 1, 3)).toEqual([1, 2, 3]);
expect(orderedListRangeIndices(5, 3, 1)).toEqual([1, 2, 3]);
expect(orderedListRangeIndices(5, 2, 2)).toEqual([2]);
});
it("clamps to the list bounds", () => {
expect(orderedListRangeIndices(3, 0, 2)).toEqual([0, 1, 2]);
});
it("returns nothing for an empty list or an out-of-range position", () => {
expect(orderedListRangeIndices(0, 0, 0)).toEqual([]);
expect(orderedListRangeIndices(3, -1, 1)).toEqual([]);
expect(orderedListRangeIndices(3, 1, 3)).toEqual([]);
});
});
@@ -0,0 +1,44 @@
import { orderedListRangeIndices, orderedListSelectionIntent } from "../selection/orderedListSelection";
/** Rows are identified by the server session id, matching the process list grid. */
export interface ProcessListSelectionRow {
id: number;
}
export interface ProcessListSelectionState {
selected: Set<number>;
anchorId: number | null;
}
/**
* Applies a row checkbox click to the current selection.
*
* A plain click toggles the clicked row and moves the anchor; Shift+click applies
* the clicked row's next state to every selectable row between the anchor and the
* clicked row, so long session lists can be selected in one gesture. Rows outside
* the range keep their current state, and an unknown/stale anchor degrades to a
* plain toggle instead of selecting an unexpected range.
*
* The next state is derived from the current selection because the grid binds
* `:checked` without cancelling the native checkbox activation.
*/
export function processListSelectionAfterClick(rows: ProcessListSelectionRow[], state: ProcessListSelectionState, id: number, event: Pick<MouseEvent, "shiftKey" | "metaKey" | "ctrlKey">): ProcessListSelectionState {
const targetIndex = rows.findIndex((row) => row.id === id);
if (targetIndex < 0) return state;
const checked = !state.selected.has(id);
const anchorIndex = state.anchorId === null ? -1 : rows.findIndex((row) => row.id === state.anchorId);
if (orderedListSelectionIntent(event) === "range" && anchorIndex >= 0) {
const selected = new Set(state.selected);
for (const index of orderedListRangeIndices(rows.length, anchorIndex, targetIndex)) {
const row = rows[index];
if (!row) continue;
if (checked) selected.add(row.id);
else selected.delete(row.id);
}
return { selected, anchorId: state.anchorId };
}
const selected = new Set(state.selected);
if (checked) selected.add(id);
else selected.delete(id);
return { selected, anchorId: id };
}
@@ -17,3 +17,12 @@ export function orderedListRangeAnchorIndex(items: OrderedListSelectionItem[], a
const index = items.findIndex((item) => item.type === activeItem.type && item.id === activeItem.id);
return index >= 0 ? index : null;
}
/** Inclusive, clamped index range between two positions in an ordered list. */
export function orderedListRangeIndices(length: number, anchorIndex: number, focusIndex: number): number[] {
if (length <= 0) return [];
if (anchorIndex < 0 || anchorIndex >= length || focusIndex < 0 || focusIndex >= length) return [];
const start = Math.min(anchorIndex, focusIndex);
const end = Math.max(anchorIndex, focusIndex);
return Array.from({ length: end - start + 1 }, (_, offset) => start + offset);
}