diff --git a/apps/desktop/src/components/admin/ProcessListPanel.vue b/apps/desktop/src/components/admin/ProcessListPanel.vue index 570d0798a2..e8b066ac84 100644 --- a/apps/desktop/src/components/admin/ProcessListPanel.vue +++ b/apps/desktop/src/components/admin/ProcessListPanel.vue @@ -13,6 +13,7 @@ import * as api from "@/lib/backend/api"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, processListExecutionError, processListSessionCount } from "@/lib/database/mysqlProcessList"; import { resolveProcessListDriverForConnection, type ProcessRow } from "@/lib/database/processListDrivers"; +import { processListSelectionAfterClick } from "@/lib/database/processListSelection"; import { useTabUiState } from "@/lib/tabs/tabUiState"; const props = defineProps<{ @@ -55,6 +56,8 @@ const cancelTarget = ref(null); const canceling = ref(false); const batchSupported = computed(() => driver.value?.supportsBatchCancel === true); const selectedIds = ref(new Set()); +// Anchor row for Shift+click range selection, in the current display order. +const selectionAnchorId = ref(null); const batchTargets = ref(null); const batchResult = ref<{ succeeded: number; failures: { id: number; message: string }[] } | null>(null); const refreshing = ref(false); @@ -108,21 +111,31 @@ watch( (visible) => { const available = new Set(visible.map((row) => row.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" }, ); -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; - const next = new Set(selectedIds.value); - if (checked && selectableRows.value.some((row) => row.id === id)) next.add(id); - else next.delete(id); - selectedIds.value = next; + // The browser toggles the clicked checkbox before this handler runs, so the + // next state comes from the selection; both agree because the click is never + // cancelled (cancelling it would leave the DOM checkbox out of sync). + 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) { if (actionsLocked.value || refreshing.value) return; selectedIds.value = new Set(checked ? selectableRows.value.map((row) => row.id) : []); + selectionAnchorId.value = null; } function requestBatchCancel() { @@ -168,7 +181,7 @@ async function confirmBatchCancel() { if (result === undefined || !isCurrent()) return; batchResult.value = result; batchTargets.value = null; - selectedIds.value = new Set(); + clearSelection(); } catch (error: unknown) { if (isCurrent()) toast(t("processList.killFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); } finally { @@ -327,7 +340,7 @@ watch( () => props.connection.id, () => { connectionGeneration++; - selectedIds.value = new Set(); + clearSelection(); batchTargets.value = null; batchResult.value = null; cancelTarget.value = null; @@ -432,7 +445,7 @@ onBeforeUnmount(() => { :checked="selectedIds.has(row.id)" :disabled="actionsLocked || refreshing || ownSessionId === null || isOwnSession(row) || !Number.isInteger(row.id) || row.id <= 0" :aria-label="t('processList.selectSession', { id: row.id })" - @change="toggleSelection(row.id, ($event.target as HTMLInputElement).checked)" + @click="toggleSelection(row.id, $event as MouseEvent)" /> { + 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); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/selection/orderedListSelection.spec.ts b/apps/desktop/src/lib/__tests__/selection/orderedListSelection.spec.ts index 33c479baf3..2134c7197f 100644 --- a/apps/desktop/src/lib/__tests__/selection/orderedListSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/selection/orderedListSelection.spec.ts @@ -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([]); + }); +}); diff --git a/apps/desktop/src/lib/database/processListSelection.ts b/apps/desktop/src/lib/database/processListSelection.ts new file mode 100644 index 0000000000..8639fae3c4 --- /dev/null +++ b/apps/desktop/src/lib/database/processListSelection.ts @@ -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; + 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): 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 }; +} diff --git a/apps/desktop/src/lib/selection/orderedListSelection.ts b/apps/desktop/src/lib/selection/orderedListSelection.ts index bae4d8136e..a0b986a202 100644 --- a/apps/desktop/src/lib/selection/orderedListSelection.ts +++ b/apps/desktop/src/lib/selection/orderedListSelection.ts @@ -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); +} diff --git a/docs/screenshots/issue-9676-after-shift-deselect.png b/docs/screenshots/issue-9676-after-shift-deselect.png new file mode 100644 index 0000000000..b8b3c77a1a Binary files /dev/null and b/docs/screenshots/issue-9676-after-shift-deselect.png differ diff --git a/docs/screenshots/issue-9676-after-shift-range.png b/docs/screenshots/issue-9676-after-shift-range.png new file mode 100644 index 0000000000..b7b4704d39 Binary files /dev/null and b/docs/screenshots/issue-9676-after-shift-range.png differ diff --git a/docs/screenshots/issue-9676-before-shift-selects-one.png b/docs/screenshots/issue-9676-before-shift-selects-one.png new file mode 100644 index 0000000000..9b56238dff Binary files /dev/null and b/docs/screenshots/issue-9676-before-shift-selects-one.png differ