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

@@ -13,6 +13,7 @@ import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, processListExecutionError, processListSessionCount } from "@/lib/database/mysqlProcessList"; import { clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, processListExecutionError, processListSessionCount } from "@/lib/database/mysqlProcessList";
import { resolveProcessListDriverForConnection, type ProcessRow } from "@/lib/database/processListDrivers"; import { resolveProcessListDriverForConnection, type ProcessRow } from "@/lib/database/processListDrivers";
import { processListSelectionAfterClick } from "@/lib/database/processListSelection";
import { useTabUiState } from "@/lib/tabs/tabUiState"; import { useTabUiState } from "@/lib/tabs/tabUiState";
const props = defineProps<{ const props = defineProps<{
@@ -55,6 +56,8 @@ const cancelTarget = ref<ProcessRow | null>(null);
const canceling = ref(false); const canceling = ref(false);
const batchSupported = computed(() => driver.value?.supportsBatchCancel === true); const batchSupported = computed(() => driver.value?.supportsBatchCancel === true);
const selectedIds = ref(new Set<number>()); const selectedIds = ref(new Set<number>());
// Anchor row for Shift+click range selection, in the current display order.
const selectionAnchorId = ref<number | null>(null);
const batchTargets = ref<ProcessRow[] | null>(null); const batchTargets = ref<ProcessRow[] | null>(null);
const batchResult = ref<{ succeeded: number; failures: { id: number; message: string }[] } | null>(null); const batchResult = ref<{ succeeded: number; failures: { id: number; message: string }[] } | null>(null);
const refreshing = ref(false); const refreshing = ref(false);
@@ -108,21 +111,31 @@ watch(
(visible) => { (visible) => {
const available = new Set(visible.map((row) => row.id)); const available = new Set(visible.map((row) => row.id));
selectedIds.value = new Set([...selectedIds.value].filter((id) => available.has(id))); selectedIds.value = new Set([...selectedIds.value].filter((id) => available.has(id)));
if (selectionAnchorId.value !== null && !available.has(selectionAnchorId.value)) selectionAnchorId.value = null;
}, },
{ flush: "sync" }, { flush: "sync" },
); );
function toggleSelection(id: number, checked: boolean) { function clearSelection() {
selectedIds.value = new Set();
selectionAnchorId.value = null;
}
/** Row checkbox click: a plain click toggles one row, Shift+click extends from the anchor. */
function toggleSelection(id: number, event: MouseEvent) {
if (actionsLocked.value || refreshing.value) return; if (actionsLocked.value || refreshing.value) return;
const next = new Set(selectedIds.value); // The browser toggles the clicked checkbox before this handler runs, so the
if (checked && selectableRows.value.some((row) => row.id === id)) next.add(id); // next state comes from the selection; both agree because the click is never
else next.delete(id); // cancelled (cancelling it would leave the DOM checkbox out of sync).
selectedIds.value = next; const next = processListSelectionAfterClick(selectableRows.value, { selected: selectedIds.value, anchorId: selectionAnchorId.value }, id, event);
selectedIds.value = next.selected;
selectionAnchorId.value = next.anchorId;
} }
function toggleAll(checked: boolean) { function toggleAll(checked: boolean) {
if (actionsLocked.value || refreshing.value) return; if (actionsLocked.value || refreshing.value) return;
selectedIds.value = new Set(checked ? selectableRows.value.map((row) => row.id) : []); selectedIds.value = new Set(checked ? selectableRows.value.map((row) => row.id) : []);
selectionAnchorId.value = null;
} }
function requestBatchCancel() { function requestBatchCancel() {
@@ -168,7 +181,7 @@ async function confirmBatchCancel() {
if (result === undefined || !isCurrent()) return; if (result === undefined || !isCurrent()) return;
batchResult.value = result; batchResult.value = result;
batchTargets.value = null; batchTargets.value = null;
selectedIds.value = new Set(); clearSelection();
} catch (error: unknown) { } catch (error: unknown) {
if (isCurrent()) toast(t("processList.killFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); if (isCurrent()) toast(t("processList.killFailed", { message: error instanceof Error ? error.message : String(error) }), 5000);
} finally { } finally {
@@ -327,7 +340,7 @@ watch(
() => props.connection.id, () => props.connection.id,
() => { () => {
connectionGeneration++; connectionGeneration++;
selectedIds.value = new Set(); clearSelection();
batchTargets.value = null; batchTargets.value = null;
batchResult.value = null; batchResult.value = null;
cancelTarget.value = null; cancelTarget.value = null;
@@ -432,7 +445,7 @@ onBeforeUnmount(() => {
:checked="selectedIds.has(row.id)" :checked="selectedIds.has(row.id)"
:disabled="actionsLocked || refreshing || ownSessionId === null || isOwnSession(row) || !Number.isInteger(row.id) || row.id <= 0" :disabled="actionsLocked || refreshing || ownSessionId === null || isOwnSession(row) || !Number.isInteger(row.id) || row.id <= 0"
:aria-label="t('processList.selectSession', { id: row.id })" :aria-label="t('processList.selectSession', { id: row.id })"
@change="toggleSelection(row.id, ($event.target as HTMLInputElement).checked)" @click="toggleSelection(row.id, $event as MouseEvent)"
/> />
</td> </td>
<td <td
@@ -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 { describe, expect, it } from "vitest";
import { orderedListRangeAnchorIndex, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection"; import { orderedListRangeAnchorIndex, orderedListRangeIndices, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
describe("orderedListSelectionIntent", () => { describe("orderedListSelectionIntent", () => {
it("uses Shift for range selection even when another modifier is also reported", () => { 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(); 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); const index = items.findIndex((item) => item.type === activeItem.type && item.id === activeItem.id);
return index >= 0 ? index : null; 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);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB