feat(admin): shift-select multiple sessions in process list
This commit is contained in:
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 |
Reference in new issue
Block a user