diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 8560f3d2d2..45673c50ea 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -501,14 +501,44 @@ async function submitKeySearch(value: string) { await settle(); } -function groupCheckbox(label: string): HTMLInputElement { +function groupRow(label: string): HTMLElement { const labelElement = Array.from(document.querySelectorAll(".dbx-editor-font-family")).find((element) => element.textContent === label); expect(labelElement, label).toBeDefined(); - const checkbox = labelElement?.closest(".group")?.querySelector('input[type="checkbox"]'); + const row = labelElement?.closest(".group"); + expect(row, label).toBeDefined(); + return row!; +} + +function redisCheckboxes(): HTMLElement[] { + return Array.from(document.querySelectorAll('input[type="checkbox"]')); +} + +function groupCheckbox(label: string): HTMLElement { + const checkbox = groupRow(label).querySelector('input[type="checkbox"]'); expect(checkbox, label).toBeDefined(); return checkbox!; } +function leafCheckbox(label: string): HTMLElement { + const checkbox = redisCheckboxes().find((el) => el.closest(".group")?.querySelector(".dbx-editor-font-family")?.textContent === label); + expect(checkbox, label).toBeDefined(); + return checkbox!; +} + +function isCheckboxChecked(el: HTMLElement): boolean { + return (el as HTMLInputElement).checked; +} + +function isCheckboxMixed(el: HTMLElement): boolean { + return (el as HTMLInputElement).indeterminate; +} + +/** Select a folder via its left-side checkbox (same single-click path as leaf keys). */ +async function selectGroup(label: string) { + groupCheckbox(label).dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await settle(); +} + async function select(value: string) { const item = requiredElement(`[data-test-select-value="${value}"]`); const root = item.closest("[data-test-select-root]"); @@ -1127,10 +1157,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { clickButtonWithText("redis.fuzzyMatch"); await settle(); - const firstGroupCheckbox = groupCheckbox(`a\0b`); - firstGroupCheckbox.checked = true; - firstGroupCheckbox.dispatchEvent(new Event("change", { bubbles: true })); - await settle(); + await selectGroup(`a\0b`); const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "1"); expect(deleteButton).toBeDefined(); deleteButton!.click(); @@ -1163,12 +1190,9 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { clickButtonWithText("redis.fuzzyMatch"); await settle(); - // Fuzzy search restores the namespace hierarchy and exposes group selection. + // Hierarchy restores leaf + group checkboxes (groups stay opacity-0 until hover/selection). expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length + 2); - const userCheckbox = groupCheckbox("user"); - userCheckbox.checked = true; - userCheckbox.dispatchEvent(new Event("change", { bubbles: true })); - await settle(); + await selectGroup("user"); const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === String(keys.length)); expect(deleteButton).toBeDefined(); @@ -1185,6 +1209,48 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(0); }); + it("selects hierarchy folders in the normal tree the same way as fuzzy groups", async () => { + const keys = [ + { key_display: "course:incr_class_id-1004", key_raw: "Y291cnNlOjEwMDQ=", key_type: "string", ttl: -1 }, + { key_display: "course:incr_class_id-172", key_raw: "Y291cnNlOjE3Mg==", key_type: "string", ttl: -1 }, + { key_display: "other:item", key_raw: "b3RoZXI6aXRlbQ==", key_type: "string", ttl: -1 }, + ]; + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length }); + mocks.redisDeleteKeys.mockResolvedValue(2); + mountBrowser(); + await settle(); + + // Single-click the folder checkbox (left side) selects all loaded keys under it. + await selectGroup("course"); + expect(isCheckboxChecked(groupCheckbox("course"))).toBe(true); + expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("2"); + + // Expand and uncheck children — parent must clear (not stay checked). + groupRow("course").dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await settle(); + for (const input of redisCheckboxes()) { + const label = input.closest(".group")?.querySelector(".dbx-editor-font-family")?.textContent; + if (label?.startsWith("incr_class_id-") && isCheckboxChecked(input)) { + input.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + } + } + await settle(); + expect(isCheckboxChecked(groupCheckbox("course"))).toBe(false); + expect(isCheckboxMixed(groupCheckbox("course"))).toBe(false); + expect(document.querySelector("[data-redis-batch-delete]")).toBeNull(); + + // Re-select the folder and delete only its keys. + await selectGroup("course"); + expect(isCheckboxChecked(groupCheckbox("course"))).toBe(true); + requiredElement("[data-redis-batch-delete]").click(); + await settle(); + requiredElement("[data-test-danger-confirm]").click(); + await settle(); + + expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, expect.arrayContaining(["Y291cnNlOjEwMDQ=", "Y291cnNlOjE3Mg=="])); + expect(mocks.redisDeleteKeys.mock.calls[0]?.[2]).not.toContain("b3RoZXI6aXRlbQ=="); + }); + it("falls back to flat rows at the fuzzy tree limit while retaining loaded-result delete wording", async () => { const keys = [ { key_display: "user:profile:email", key_raw: "cmF3LWVtYWls", key_type: "string", ttl: -1 }, @@ -1204,7 +1270,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length); expect(document.body.textContent).toContain("redis.fuzzyTreeLimit"); - requiredElement('input[type="checkbox"]').click(); + requiredElement('input[type="checkbox"]').click(); await settle(); const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "1"); expect(deleteButton).toBeDefined(); @@ -1229,16 +1295,13 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { clickButtonWithText("redis.fuzzyMatch"); await settle(); - const userCheckbox = groupCheckbox("user"); - userCheckbox.checked = true; - userCheckbox.dispatchEvent(new Event("change", { bubbles: true })); - await settle(); + await selectGroup("user"); clickButtonWithText("redis.loadMoreKeys"); await settle(); const updatedUserCheckbox = groupCheckbox("user"); - expect(updatedUserCheckbox.checked).toBe(false); - expect(updatedUserCheckbox.indeterminate).toBe(true); + expect(isCheckboxChecked(updatedUserCheckbox)).toBe(false); + expect(isCheckboxMixed(updatedUserCheckbox)).toBe(true); const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "1"); expect(deleteButton).toBeDefined(); deleteButton!.click(); @@ -1265,10 +1328,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { clickButtonWithText("redis.fuzzyMatch"); await settle(); - const batchCheckbox = groupCheckbox("batch"); - batchCheckbox.checked = true; - batchCheckbox.dispatchEvent(new Event("change", { bubbles: true })); - await settle(); + await selectGroup("batch"); const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === String(keys.length)); expect(deleteButton).toBeDefined(); deleteButton!.click(); @@ -1302,10 +1362,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { clickButtonWithText("redis.fuzzyMatch"); await settle(); - const batchCheckbox = groupCheckbox("batch"); - batchCheckbox.checked = true; - batchCheckbox.dispatchEvent(new Event("change", { bubbles: true })); - await settle(); + await selectGroup("batch"); const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === String(keys.length)); expect(deleteButton).toBeDefined(); deleteButton!.click(); @@ -1339,10 +1396,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => { clickButtonWithText("redis.fuzzyMatch"); await settle(); - const batchCheckbox = groupCheckbox("batch"); - batchCheckbox.checked = true; - batchCheckbox.dispatchEvent(new Event("change", { bubbles: true })); - await settle(); + await selectGroup("batch"); const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === String(keys.length)); expect(deleteButton).toBeDefined(); deleteButton!.click(); @@ -1394,4 +1448,179 @@ describe("RedisKeyBrowser interrupted Fetch All", () => { expect(document.body.textContent).not.toContain("buffered"); expect(freshPageRequests).toBeGreaterThan(0); }); + + it("selects all loaded keys from the toolbar and clears the multi-selection", async () => { + const keys = [ + { key_display: "alpha", key_raw: "YWxwaGE=", key_type: "string", ttl: -1 }, + { key_display: "bravo", key_raw: "YnJhdm8=", key_type: "string", ttl: -1 }, + { key_display: "charlie", key_raw: "Y2hhcmxpZQ==", key_type: "string", ttl: -1 }, + ]; + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length }); + mountBrowser(); + await settle(); + + const selectAll = requiredElement("[data-redis-select-all]"); + selectAll.click(); + await settle(); + + const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"]')); + expect(checkboxes).toHaveLength(keys.length); + expect(checkboxes.every((checkbox) => isCheckboxChecked(checkbox as HTMLElement))).toBe(true); + expect(document.querySelector("[data-redis-select-all]")).toBeNull(); + expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain(String(keys.length)); + expect(document.activeElement).toBe(document.querySelector(".redis-key-pane")); + + requiredElement("[data-redis-deselect-all]").click(); + await settle(); + expect(Array.from(document.querySelectorAll('input[type="checkbox"]')).every((checkbox) => !isCheckboxChecked(checkbox))).toBe(true); + expect(document.querySelector("[data-redis-batch-delete]")).toBeNull(); + expect(document.querySelector("[data-redis-select-all]")).not.toBeNull(); + expect(document.activeElement).toBe(document.querySelector(".redis-key-pane")); + }); + + it("fetches remaining scan pages before selecting all keys", async () => { + const first = { key_display: "first", key_raw: "Zmlyc3Q=", key_type: "string", ttl: -1 }; + const second = { key_display: "second", key_raw: "c2Vjb25k", key_type: "string", ttl: -1 }; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => { + if (cursor === 0) return Promise.resolve({ cursor: 1, keys: [first], total_keys: 2 }); + return Promise.resolve({ cursor: 0, keys: [second], total_keys: 0 }); + }); + mountBrowser(); + await settle(); + + expect(document.body.textContent).toContain("redis.loadMoreKeys"); + const keyPane = requiredElement(".redis-key-pane"); + keyPane.focus(); + keyPane.dispatchEvent(new KeyboardEvent("keydown", { key: "a", ctrlKey: true, bubbles: true, cancelable: true })); + await vi.waitFor(() => expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("2")); + + expect(document.body.textContent).toContain("second"); + expect(Array.from(document.querySelectorAll('input[type="checkbox"]')).every(isCheckboxChecked)).toBe(true); + expect(document.querySelector("[data-redis-select-all]")).toBeNull(); + }); + + it("does not select stale keys when a search changes during select-all scanning", async () => { + const oldPage = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + const oldKey = { key_display: "old", key_raw: "b2xk", key_type: "string", ttl: -1 }; + const freshKey = { key_display: "fresh", key_raw: "ZnJlc2g=", key_type: "string", ttl: -1 }; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => { + if (pattern === "*" && cursor === 0) return Promise.resolve({ cursor: 1, keys: [oldKey], total_keys: 2 }); + if (pattern === "*") return oldPage.promise; + return Promise.resolve({ cursor: 0, keys: [freshKey], total_keys: 1 }); + }); + mountBrowser(); + await settle(); + + requiredElement("[data-redis-select-all]").click(); + await vi.waitFor(() => expect(mocks.redisScanKeysBatch.mock.calls.length).toBeGreaterThan(1)); + const input = requiredElement("[data-redis-search-input]"); + input.value = "fresh"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + oldPage.resolve({ cursor: 0, keys: [oldKey], total_keys: 0 }); + await settle(); + + await vi.waitFor(() => expect(document.body.textContent).toContain("fresh")); + expect(document.body.textContent).not.toContain("old"); + expect(document.querySelector("[data-redis-batch-delete]")).toBeNull(); + }); + + it("does not downgrade Ctrl+A to a partial selection when fetch-all is stopped", async () => { + const continuation = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>(); + const first = { key_display: "first", key_raw: "Zmlyc3Q=", key_type: "string", ttl: -1 }; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => { + if (cursor === 0) return Promise.resolve({ cursor: 1, keys: [first], total_keys: 2 }); + return continuation.promise; + }); + mountBrowser(); + await settle(); + + requiredElement("[data-redis-select-all]").click(); + await vi.waitFor(() => expect(document.body.textContent).toContain("redis.stopFetchAll")); + clickButtonWithText("redis.stopFetchAll"); + continuation.resolve({ cursor: 1, keys: [], total_keys: 0 }); + await settle(); + + expect(document.querySelector("[data-redis-batch-delete]")).toBeNull(); + expect(document.querySelector("[data-redis-select-all]")).not.toBeNull(); + }); + + it("shows a native checked mark after selecting a single leaf key", async () => { + const keys = [ + { key_display: "solo-a", key_raw: "c29sby1h", key_type: "string", ttl: -1 }, + { key_display: "solo-b", key_raw: "c29sby1i", key_type: "string", ttl: -1 }, + ]; + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length }); + mountBrowser(); + await settle(); + + const leafA = leafCheckbox("solo-a"); + expect(leafA.className).toContain("accent-primary"); + expect(leafA.className).toContain("h-3.5"); + leafA.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await settle(); + expect(isCheckboxChecked(leafCheckbox("solo-a"))).toBe(true); + expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("1"); + }); + + it("keeps parent and child checkbox DOM state in sync", async () => { + const keys = [ + { key_display: "pack:user_select", key_raw: "cGFjazp1c2VyX3NlbGVjdA==", key_type: "string", ttl: -1 }, + { key_display: "other:item", key_raw: "b3RoZXI6aXRlbQ==", key_type: "string", ttl: -1 }, + ]; + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length }); + mountBrowser(); + await settle(); + + // Select parent folder → child leaf must also appear checked. + await selectGroup("pack"); + groupRow("pack").dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await settle(); + + const child = leafCheckbox("user_select"); + expect(isCheckboxChecked(groupCheckbox("pack"))).toBe(true); + expect(isCheckboxChecked(child)).toBe(true); + + // Uncheck child → parent must clear. + child.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await settle(); + expect(isCheckboxChecked(leafCheckbox("user_select"))).toBe(false); + expect(isCheckboxChecked(groupCheckbox("pack"))).toBe(false); + expect(isCheckboxMixed(groupCheckbox("pack"))).toBe(false); + }); + + it("supports Shift multi-line selection and Ctrl/Cmd+A on the key list", async () => { + const keys = [ + { key_display: "k1", key_raw: "azE=", key_type: "string", ttl: -1 }, + { key_display: "k2", key_raw: "azI=", key_type: "string", ttl: -1 }, + { key_display: "k3", key_raw: "azM=", key_type: "string", ttl: -1 }, + { key_display: "k4", key_raw: "azQ=", key_type: "string", ttl: -1 }, + ]; + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length }); + mountBrowser(); + await settle(); + + const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"]')); + expect(checkboxes).toHaveLength(keys.length); + + checkboxes[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await settle(); + expect(isCheckboxChecked(checkboxes[0]!)).toBe(true); + + checkboxes[2]!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey: true })); + await settle(); + expect(checkboxes.map((checkbox) => isCheckboxChecked(checkbox as HTMLElement))).toEqual([true, true, true, false]); + expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("3"); + + const keyPane = requiredElement(".redis-key-pane"); + keyPane.focus(); + keyPane.dispatchEvent(new KeyboardEvent("keydown", { key: "a", ctrlKey: true, bubbles: true, cancelable: true })); + await settle(); + expect(checkboxes.every((checkbox) => isCheckboxChecked(checkbox as HTMLElement))).toBe(true); + expect(document.querySelector("[data-redis-batch-delete]")?.textContent).toContain("4"); + + keyPane.dispatchEvent(new KeyboardEvent("keydown", { key: "a", metaKey: true, bubbles: true, cancelable: true })); + await settle(); + expect(checkboxes.every((checkbox) => !isCheckboxChecked(checkbox as HTMLElement))).toBe(true); + }); }); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index e0cd07b572..551a4eb9a3 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -102,6 +102,7 @@ const isFetchingAll = ref(false); const fetchAllStopRequested = ref(false); const fetchAllLoadedCount = ref(0); const rootRef = ref(); +const keyPaneRef = ref(); const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null); const commandTerminalRef = ref(); const searchPattern = ref(""); @@ -112,6 +113,9 @@ const hasMore = ref(false); const scanCursor = ref(0); const expandedGroupIds = ref>(new Set()); const checkedKeys = ref>(new Set()); +/** Bumped when selection or tree counts change so virtualized rows re-evaluate state. */ +const selectionEpoch = ref(0); +const selectionAnchorRowId = ref(null); const selectedGroupLeafCounts = shallowRef>(new Map()); const deletingKeys = ref(false); const pendingDanger = ref<{ kind: "delete-keys"; title: string; keyRaws: string[]; loadedSearchResults: boolean } | { kind: "command"; command: string } | null>(null); @@ -179,6 +183,9 @@ const fuzzyTreeLimitReached = computed(() => isFuzzyKeySearch.value && !canBuild const useFlatKeySearchRows = computed(() => (searchMode.value === "key" && isSearchMode.value && !fuzzyKeySearch.value) || fuzzyTreeLimitReached.value); const isFuzzyHierarchyView = computed(() => isFuzzyKeySearch.value && !fuzzyTreeLimitReached.value); const selectionBusy = computed(() => deletingKeys.value || loading.value || loadingMore.value || isFetchingAll.value || searchPending.value); +// checkedKeys is always a subset of loaded keys, so size equality is enough. +const allLoadedKeysSelected = computed(() => flatKeys.value.length > 0 && checkedKeys.value.size === flatKeys.value.length); +const allKeysSelected = computed(() => allLoadedKeysSelected.value && !hasMore.value); const searchPlaceholder = computed(() => { if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern"); return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder"); @@ -311,64 +318,145 @@ let commandHistoryId = 0; function resetCheckedKeys() { checkedKeys.value = new Set(); selectedGroupLeafCounts.value = new Map(); + selectionAnchorRowId.value = null; + selectionEpoch.value++; +} + +/** Parent folder selected-count derived only from currently checked leaves. */ +function groupLeafCountsFromChecked(checked: ReadonlySet): Map { + const nextCounts = new Map(); + const ancestors = treeIndex?.ancestorGroupIdsByKeyRaw; + if (!ancestors) return nextCounts; + for (const keyRaw of checked) { + for (const groupId of ancestors.get(keyRaw) ?? []) { + nextCounts.set(groupId, (nextCounts.get(groupId) ?? 0) + 1); + } + } + return nextCounts; } function refreshSelectedGroupLeafCounts() { const nextChecked = new Set(); - const nextCounts = new Map(); for (const keyRaw of checkedKeys.value) { - if (!loadedKeyRaws.has(keyRaw)) continue; - nextChecked.add(keyRaw); - for (const groupId of treeIndex?.ancestorGroupIdsByKeyRaw.get(keyRaw) ?? []) { - nextCounts.set(groupId, (nextCounts.get(groupId) ?? 0) + 1); - } + if (loadedKeyRaws.has(keyRaw)) nextChecked.add(keyRaw); } checkedKeys.value = nextChecked; - selectedGroupLeafCounts.value = nextCounts; + selectedGroupLeafCounts.value = groupLeafCountsFromChecked(nextChecked); + selectionEpoch.value++; } function setKeysChecked(keyRaws: Iterable, checked: boolean) { const nextChecked = new Set(checkedKeys.value); - const nextCounts = new Map(selectedGroupLeafCounts.value); let changed = false; for (const keyRaw of keyRaws) { - if (!loadedKeyRaws.has(keyRaw)) continue; - const wasChecked = nextChecked.has(keyRaw); - if (wasChecked === checked) continue; - + if (!loadedKeyRaws.has(keyRaw) && !nextChecked.has(keyRaw)) continue; + if (nextChecked.has(keyRaw) === checked) continue; if (checked) nextChecked.add(keyRaw); else nextChecked.delete(keyRaw); - - const delta = checked ? 1 : -1; - for (const groupId of treeIndex?.ancestorGroupIdsByKeyRaw.get(keyRaw) ?? []) { - const nextCount = (nextCounts.get(groupId) ?? 0) + delta; - if (nextCount > 0) nextCounts.set(groupId, nextCount); - else nextCounts.delete(groupId); - } changed = true; } - if (!changed) return; + checkedKeys.value = nextChecked; - selectedGroupLeafCounts.value = nextCounts; + // Always recompute parent counts from the full leaf set so parent/child never drift. + selectedGroupLeafCounts.value = groupLeafCountsFromChecked(nextChecked); + selectionEpoch.value++; } -function setKeyChecked(keyRaw: string, checked: boolean) { - setKeysChecked([keyRaw], checked); +function nodeKeyRaws(node: RedisKeyTreeNode): string[] { + return node.kind === "leaf" ? [node.keyRaw] : collectRedisGroupKeyRaws(node); } -function isGroupFullyChecked(group: RedisKeyTreeGroupNode): boolean { - return group.loadedLeafCount > 0 && selectedGroupLeafCounts.value.get(group.id) === group.loadedLeafCount; +function focusKeyPane() { + keyPaneRef.value?.focus({ preventScroll: true }); +} + +function groupSelectedCount(group: RedisKeyTreeGroupNode): number { + void selectionEpoch.value; + return selectedGroupLeafCounts.value.get(group.id) ?? 0; +} + +function isNodeChecked(node: RedisKeyTreeNode): boolean { + void selectionEpoch.value; + if (node.kind === "leaf") return checkedKeys.value.has(node.keyRaw); + return node.loadedLeafCount > 0 && groupSelectedCount(node) === node.loadedLeafCount; } function isGroupPartiallyChecked(group: RedisKeyTreeGroupNode): boolean { - const selectedCount = selectedGroupLeafCounts.value.get(group.id) ?? 0; + void selectionEpoch.value; + const selectedCount = groupSelectedCount(group); return selectedCount > 0 && selectedCount < group.loadedLeafCount; } -function setGroupChecked(group: RedisKeyTreeGroupNode, checked: boolean) { - setKeysChecked(collectRedisGroupKeyRaws(group), checked); +function isLeafChecked(keyRaw: string): boolean { + void selectionEpoch.value; + return checkedKeys.value.has(keyRaw); +} + +/** Check/uncheck a leaf or folder; Shift expands an inclusive visible-row range. */ +function toggleNodeCheck(node: RedisKeyTreeNode, event: MouseEvent) { + // Let the native checkbox handle ordinary clicks; custom range selection owns Shift clicks. + if (event.shiftKey) event.preventDefault(); + event.stopPropagation(); + if (selectionBusy.value) return; + focusKeyPane(); + + if (event.shiftKey) { + const rows = visibleRows.value; + const to = rows.findIndex((row) => row.node.id === node.id); + if (to < 0) return; + let from = selectionAnchorRowId.value ? rows.findIndex((row) => row.id === selectionAnchorRowId.value) : to; + if (from < 0) from = to; + const keyRaws: string[] = []; + for (let i = Math.min(from, to); i <= Math.max(from, to); i++) keyRaws.push(...nodeKeyRaws(rows[i].node)); + setKeysChecked(keyRaws, true); + if (!selectionAnchorRowId.value) selectionAnchorRowId.value = node.id; + return; + } + + setKeysChecked(nodeKeyRaws(node), !isNodeChecked(node)); + selectionAnchorRowId.value = node.id; +} + +function selectAllLoadedKeys() { + if (selectionBusy.value || loadedKeyRaws.size === 0) return; + focusKeyPane(); + setKeysChecked(loadedKeyRaws, true); + selectionAnchorRowId.value = visibleRows.value[0]?.id ?? null; +} + +async function selectAllKeys() { + if (selectionBusy.value || (loadedKeyRaws.size === 0 && !hasMore.value)) return; + const requestId = searchRequestId; + if (hasMore.value) { + let fetchedAll = false; + try { + fetchedAll = await fetchAll(); + } catch (error) { + toast(errorMessage(error), 5000); + return; + } + // A search or scope change may have replaced the result while SCAN was running. + // If the user stopped the scan, do not silently downgrade Ctrl+A to a partial selection. + if (!fetchedAll || requestId !== searchRequestId || !redisBrowserIsActive) return; + } + selectAllLoadedKeys(); +} + +function clearAllCheckedKeys() { + if (selectionBusy.value || checkedKeys.value.size === 0) return; + focusKeyPane(); + resetCheckedKeys(); +} + +function onKeyPaneKeydown(event: KeyboardEvent) { + if (selectionBusy.value || event.isComposing) return; + if ((event.target as HTMLElement | null)?.closest("input, textarea, select, [contenteditable='true']")) return; + if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "a") return; + event.preventDefault(); + if (allKeysSelected.value) clearAllCheckedKeys(); + else void selectAllKeys(); } function rebuildTree(expandAll = false) { @@ -418,6 +506,8 @@ function mergeTree(newKeys: RedisKeyInfo[]) { for (const id of addedGroupIds) nextExpanded.add(id); } expandedGroupIds.value = nextExpanded; + // loadedLeafCount changed — re-evaluate parent checked/partial state in the UI. + if (checkedKeys.value.size > 0) selectionEpoch.value++; } function invalidateScanRequests(): number { @@ -607,14 +697,15 @@ function onRedisKeyScroll(event: Event) { const FETCH_ALL_SCAN_COUNT = 50000; const FETCH_ALL_BATCH_ITERATIONS = 8; -async function fetchAll() { - if (!hasMore.value || isFetchingAll.value) return; +async function fetchAll(): Promise { + if (!hasMore.value || isFetchingAll.value) return false; const requestId = searchRequestId; const bufferedKeys: RedisKeyInfo[] = []; isFetchingAll.value = true; fetchAllStopRequested.value = false; fetchAllLoadedCount.value = flatKeys.value.length; let changed = false; + let completed = false; try { while (requestId === searchRequestId && !fetchAllStopRequested.value && hasMore.value) { const result = await fetchScanBatchPage(FETCH_ALL_BATCH_ITERATIONS, { @@ -625,6 +716,7 @@ async function fetchAll() { changed = appendScanResult(result, { updateTree: false, buffer: bufferedKeys }) > 0 || changed; fetchAllLoadedCount.value = flatKeys.value.length + bufferedKeys.length; } + completed = requestId === searchRequestId && !fetchAllStopRequested.value && !hasMore.value; } finally { if (requestId === searchRequestId) { if (bufferedKeys.length > 0) flatKeys.value = [...flatKeys.value, ...bufferedKeys]; @@ -642,6 +734,7 @@ async function fetchAll() { fetchAllLoadedCount.value = 0; } } + return completed; } function stopFetchAll() { @@ -655,12 +748,21 @@ function toggleGroup(groupId: string) { expandedGroupIds.value = next; } -function onRowClick(node: RedisKeyTreeNode) { +function onRowClick(node: RedisKeyTreeNode, event?: MouseEvent) { + if (event && !selectionBusy.value && (event.shiftKey || event.ctrlKey || event.metaKey)) { + toggleNodeCheck(node, event); + if (node.kind === "leaf") { + focusKeyPane(); + selectedKeyRaw.value = node.keyRaw; + activeSidePanel.value = "detail"; + } + return; + } if (node.kind === "group") { toggleGroup(node.id); return; } - + focusKeyPane(); selectedKeyRaw.value = node.keyRaw; activeSidePanel.value = "detail"; } @@ -717,12 +819,6 @@ function onKeyLoaded(value: RedisValue) { } } -function toggleCheck(keyRaw: string, event: Event) { - event.stopPropagation(); - if (selectionBusy.value) return; - setKeyChecked(keyRaw, !checkedKeys.value.has(keyRaw)); -} - function requestBatchDelete() { if (checkedKeys.value.size === 0 || selectionBusy.value) return; pendingDanger.value = { @@ -1729,7 +1825,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); -
+
@@ -1746,7 +1842,9 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
{{ keyCountText }}
- + + +