diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts index 1d7b2cd8b..d34229b5c 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date"; -import { createApp, nextTick, type ComponentPublicInstance } from "vue"; +import { createApp, defineComponent, h, KeepAlive, nextTick, ref, type ComponentPublicInstance } from "vue"; import { createI18n } from "vue-i18n"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { calendarDateTimeToUnixSeconds } from "@/components/ui/date-time-picker/dateTimePicker"; @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ redisCheckJsonModule: vi.fn(), redisDeleteKey: vi.fn(), redisDeleteKeys: vi.fn(), + canBuildRedisFuzzyTree: vi.fn((loadedKeyCount: number) => loadedKeyCount <= 200_000), toast: vi.fn(), updateRedisDbKeyStats: vi.fn(), })); @@ -42,6 +43,11 @@ vi.mock("@/lib/backend/api", () => ({ redisDeleteKeys: mocks.redisDeleteKeys, })); +vi.mock("@/lib/redis/redisKeyTree", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, canBuildRedisFuzzyTree: mocks.canBuildRedisFuzzyTree }; +}); + vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ ensureConnected: vi.fn().mockResolvedValue(undefined), @@ -228,7 +234,30 @@ vi.mock("@/components/ui/CustomContextMenu.vue", async () => { vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => { const { defineComponent, h } = await import("vue"); - return { default: defineComponent({ setup: () => () => h("div") }) }; + return { + default: defineComponent({ + props: { open: Boolean, loading: Boolean, details: String }, + emits: ["confirm"], + setup(props, { emit }) { + return () => + props.open + ? h("div", { "data-test-danger-dialog": "" }, [ + h("div", { "data-test-danger-details": "" }, props.details), + h( + "button", + { + type: "button", + disabled: props.loading, + "data-test-danger-confirm": "", + onClick: () => emit("confirm"), + }, + "Confirm", + ), + ]) + : null; + }, + }), + }; }); vi.mock("./RedisValueViewer.vue", async () => { @@ -248,7 +277,23 @@ vi.mock("./RedisSlowlogPanel.vue", async () => { vi.mock("vue-virtual-scroller", async () => { const { defineComponent, h } = await import("vue"); - return { RecycleScroller: defineComponent({ setup: () => () => h("div") }) }; + return { + RecycleScroller: defineComponent({ + inheritAttrs: false, + props: { items: { type: Array, default: () => [] } }, + setup(props, { attrs, slots }) { + // Mirror the real scroller: interaction tests should render a viewport, + // not every row in a deliberately large result set. + const visibleItemCount = 50; + return () => + h( + "div", + attrs, + props.items.slice(0, visibleItemCount).map((item) => slots.default?.({ item })), + ); + }, + }), + }; }); vi.mock("splitpanes", async () => { @@ -284,6 +329,16 @@ function redisKeyInfo(keyType = "json") { return { key_display: KEY_NAME, key_raw: KEY_RAW, key_type: keyType, ttl: 90, size: 7, value_preview: "{}" }; } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((next, fail) => { + resolve = next; + reject = fail; + }); + return { promise, resolve, reject }; +} + function resetApiMocks() { vi.clearAllMocks(); mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 }); @@ -300,6 +355,7 @@ function resetApiMocks() { mocks.redisCheckJsonModule.mockResolvedValue(true); mocks.redisDeleteKey.mockResolvedValue(undefined); mocks.redisDeleteKeys.mockResolvedValue(0); + mocks.canBuildRedisFuzzyTree.mockImplementation((loadedKeyCount: number) => loadedKeyCount <= 200_000); } function mountBrowser() { @@ -311,6 +367,36 @@ function mountBrowser() { mountedApps.push({ unmount: () => app.unmount(), host }); } +function mountKeptAliveBrowser() { + const active = ref(true); + const host = document.createElement("div"); + document.body.append(host); + const app = createApp( + defineComponent({ + setup() { + return () => + h(KeepAlive, null, { + default: () => (active.value ? h(RedisKeyBrowser, { connectionId: "connection", db: 0, blockDangerousRedisCommands: false }) : null), + }); + }, + }), + ); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + mountedApps.push({ unmount: () => app.unmount(), host }); + + return { + async deactivate() { + active.value = false; + await settle(); + }, + async activate() { + active.value = true; + await settle(); + }, + }; +} + async function settle() { await nextTick(); await Promise.resolve(); @@ -338,6 +424,22 @@ async function setInput(selector: string, value: string) { await settle(); } +async function submitKeySearch(value: string) { + const input = requiredElement("[data-redis-search-input]"); + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(); +} + +function groupCheckbox(label: string): HTMLInputElement { + 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"]'); + expect(checkbox, label).toBeDefined(); + return checkbox!; +} + async function select(value: string) { const item = requiredElement(`[data-test-select-value="${value}"]`); const root = item.closest("[data-test-select-root]"); @@ -522,3 +624,289 @@ describe("RedisKeyBrowser expiry creation", () => { expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000); }); }); + +describe("RedisKeyBrowser fuzzy key hierarchy", () => { + it("keeps NUL-containing fuzzy groups isolated when selecting keys to delete", async () => { + const firstKeyRaw = "cmF3LWZpcnN0"; + const secondKeyRaw = "cmF3LXNlY29uZA=="; + const keys = [ + { key_display: `a\0b:c:x`, key_raw: firstKeyRaw, key_type: "string", ttl: -1 }, + { key_display: `a:b\0c:y`, key_raw: secondKeyRaw, key_type: "string", ttl: -1 }, + ]; + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length }); + mocks.redisDeleteKeys.mockResolvedValue(1); + mountBrowser(); + await settle(); + + await submitKeySearch("a"); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + const firstGroupCheckbox = groupCheckbox(`a\0b`); + firstGroupCheckbox.checked = true; + firstGroupCheckbox.dispatchEvent(new Event("change", { bubbles: true })); + await settle(); + const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "1"); + expect(deleteButton).toBeDefined(); + deleteButton!.click(); + await settle(); + requiredElement("[data-test-danger-confirm]").click(); + await settle(); + + expect(mocks.redisDeleteKeys).toHaveBeenCalledTimes(1); + expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, [firstKeyRaw]); + expect(mocks.redisDeleteKeys.mock.calls[0]?.[2]).not.toContain(secondKeyRaw); + }); + + it("keeps regular key searches flat, then selects and deletes a loaded fuzzy branch", async () => { + const keys = [ + { key_display: "user:profile:email", key_raw: "cmF3LWVtYWls", key_type: "string", ttl: -1 }, + { key_display: "user:profile:name", key_raw: "cmF3LW5hbWU=", key_type: "string", ttl: -1 }, + { key_display: "user:settings", key_raw: "cmF3LXNldHRpbmdz", key_type: "hash", ttl: -1 }, + ]; + // The first page is intentionally incomplete: branch selection must only + // submit the currently loaded raw keys, never widen into a new SCAN. + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 7, keys, total_keys: 20 }); + mocks.redisDeleteKeys.mockResolvedValue(keys.length); + mountBrowser(); + await settle(); + + await submitKeySearch("user"); + // Regular glob search keeps the pre-existing flat, virtualized result path. + expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length); + + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + // Fuzzy search restores the namespace hierarchy and exposes group 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(); + + const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === String(keys.length)); + expect(deleteButton).toBeDefined(); + deleteButton!.click(); + await settle(); + requiredElement("[data-test-danger-confirm]").click(); + await settle(); + + expect(mocks.redisDeleteKeys).toHaveBeenCalledTimes(1); + const [connectionId, db, deletedKeyRaws] = mocks.redisDeleteKeys.mock.calls[0] ?? []; + expect(connectionId).toBe("connection"); + expect(db).toBe(0); + expect(new Set(deletedKeyRaws)).toEqual(new Set(keys.map((key) => key.key_raw))); + expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(0); + }); + + 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 }, + { key_display: "user:profile:name", key_raw: "cmF3LW5hbWU=", key_type: "string", ttl: -1 }, + ]; + mocks.canBuildRedisFuzzyTree.mockReturnValue(false); + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length }); + mocks.redisDeleteKeys.mockResolvedValue(1); + mountBrowser(); + await settle(); + + await submitKeySearch("user"); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + // The group controls disappear when the view falls back to virtualized rows. + expect(document.querySelectorAll('input[type="checkbox"]')).toHaveLength(keys.length); + expect(document.body.textContent).toContain("redis.fuzzyTreeLimit"); + + requiredElement('input[type="checkbox"]').click(); + await settle(); + const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "1"); + expect(deleteButton).toBeDefined(); + deleteButton!.click(); + await settle(); + expect(requiredElement("[data-test-danger-details]").textContent).toContain("redis.deleteLoadedSearchKeysDetails"); + requiredElement("[data-test-danger-confirm]").click(); + await settle(); + + expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, [keys[0]!.key_raw]); + }); + + it("keeps a selected fuzzy group partial when a later SCAN page adds matching keys", async () => { + const firstPageKeys = [{ key_display: "user:one", key_raw: "dXNlci1vbmU=", key_type: "string", ttl: -1 }]; + const laterPageKeys = [{ key_display: "user:two", key_raw: "dXNlci10d28=", key_type: "string", ttl: -1 }]; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => Promise.resolve(cursor === 0 ? { cursor: 7, keys: firstPageKeys, total_keys: 2 } : { cursor: 0, keys: laterPageKeys, total_keys: 0 })); + mocks.redisDeleteKeys.mockResolvedValue(1); + mountBrowser(); + await settle(); + + await submitKeySearch("user"); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + const userCheckbox = groupCheckbox("user"); + userCheckbox.checked = true; + userCheckbox.dispatchEvent(new Event("change", { bubbles: true })); + await settle(); + clickButtonWithText("redis.loadMoreKeys"); + await settle(); + + const updatedUserCheckbox = groupCheckbox("user"); + expect(updatedUserCheckbox.checked).toBe(false); + expect(updatedUserCheckbox.indeterminate).toBe(true); + const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "1"); + expect(deleteButton).toBeDefined(); + deleteButton!.click(); + await settle(); + requiredElement("[data-test-danger-confirm]").click(); + await settle(); + + expect(mocks.redisDeleteKeys).toHaveBeenCalledWith("connection", 0, [firstPageKeys[0]!.key_raw]); + }); + + it("sends a large selected fuzzy group in bounded delete batches", async () => { + const keys = Array.from({ length: 1_001 }, (_, index) => ({ + key_display: `batch:${String(index).padStart(4, "0")}`, + key_raw: `cmF3LWJhdGNoLS${index}`, + key_type: "string", + ttl: -1, + })); + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys, total_keys: keys.length }); + mocks.redisDeleteKeys.mockImplementation(async (_connectionId: string, _db: number, keyRaws: string[]) => keyRaws.length); + mountBrowser(); + await settle(); + + await submitKeySearch("batch"); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + const batchCheckbox = groupCheckbox("batch"); + batchCheckbox.checked = true; + batchCheckbox.dispatchEvent(new Event("change", { bubbles: true })); + await settle(); + const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === String(keys.length)); + expect(deleteButton).toBeDefined(); + deleteButton!.click(); + await settle(); + requiredElement("[data-test-danger-confirm]").click(); + await settle(); + await settle(); + + expect(mocks.redisDeleteKeys.mock.calls.map((call) => call[2]?.length)).toEqual([1_000, 1]); + expect(new Set(mocks.redisDeleteKeys.mock.calls.flatMap((call) => call[2] ?? []))).toEqual(new Set(keys.map((key) => key.key_raw))); + }); + + it("reloads the result set when a later delete batch fails after an earlier batch succeeds", async () => { + const keys = Array.from({ length: 1_001 }, (_, index) => ({ + key_display: `batch:${String(index).padStart(4, "0")}`, + key_raw: `cmF3LWJhdGNoLS${index}`, + key_type: "string", + ttl: -1, + })); + const freshKeys = [{ key_display: "fresh:remaining", key_raw: "ZnJlc2gtcmVtYWluaW5n", key_type: "string", ttl: -1 }]; + let returnFreshResults = false; + mocks.redisScanKeysBatch.mockImplementation(() => Promise.resolve(returnFreshResults ? { cursor: 0, keys: freshKeys, total_keys: 1 } : { cursor: 0, keys, total_keys: keys.length })); + mocks.redisDeleteKeys.mockResolvedValueOnce(1_000).mockImplementationOnce(async () => { + returnFreshResults = true; + throw new Error("second batch failed"); + }); + mountBrowser(); + await settle(); + + await submitKeySearch("batch"); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + const batchCheckbox = groupCheckbox("batch"); + batchCheckbox.checked = true; + batchCheckbox.dispatchEvent(new Event("change", { bubbles: true })); + await settle(); + const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === String(keys.length)); + expect(deleteButton).toBeDefined(); + deleteButton!.click(); + await settle(); + requiredElement("[data-test-danger-confirm]").click(); + await settle(); + await settle(); + + expect(mocks.redisDeleteKeys.mock.calls.map((call) => call[2]?.length)).toEqual([1_000, 1]); + expect(mocks.toast).toHaveBeenCalledWith("second batch failed", 5000); + expect(document.body.textContent).toContain("fresh"); + expect(document.body.textContent).not.toContain("0000"); + }); + + it("reloads after a later delete batch fails while the browser is deactivated", async () => { + const keys = Array.from({ length: 1_001 }, (_, index) => ({ + key_display: `batch:${String(index).padStart(4, "0")}`, + key_raw: `cmF3LWJhdGNoLS${index}`, + key_type: "string", + ttl: -1, + })); + const freshKeys = [{ key_display: "fresh:remaining", key_raw: "ZnJlc2gtcmVtYWluaW5n", key_type: "string", ttl: -1 }]; + const laterDelete = deferred(); + let returnFreshResults = false; + mocks.redisScanKeysBatch.mockImplementation(() => Promise.resolve(returnFreshResults ? { cursor: 0, keys: freshKeys, total_keys: 1 } : { cursor: 0, keys, total_keys: keys.length })); + mocks.redisDeleteKeys.mockResolvedValueOnce(1_000).mockImplementationOnce(() => laterDelete.promise); + const browser = mountKeptAliveBrowser(); + await settle(); + + await submitKeySearch("batch"); + clickButtonWithText("redis.fuzzyMatch"); + await settle(); + + const batchCheckbox = groupCheckbox("batch"); + batchCheckbox.checked = true; + batchCheckbox.dispatchEvent(new Event("change", { bubbles: true })); + await settle(); + const deleteButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === String(keys.length)); + expect(deleteButton).toBeDefined(); + deleteButton!.click(); + await settle(); + requiredElement("[data-test-danger-confirm]").click(); + await settle(); + + expect(mocks.redisDeleteKeys.mock.calls.map((call) => call[2]?.length)).toEqual([1_000, 1]); + await browser.deactivate(); + returnFreshResults = true; + laterDelete.reject(new Error("second batch failed while inactive")); + await settle(); + const scanCountBeforeActivation = mocks.redisScanKeysBatch.mock.calls.length; + + await browser.activate(); + await settle(); + + expect(mocks.toast).toHaveBeenCalledWith("second batch failed while inactive", 5000); + expect(mocks.redisScanKeysBatch.mock.calls.length).toBeGreaterThan(scanCountBeforeActivation); + expect(document.body.textContent).toContain("fresh"); + expect(document.body.textContent).not.toContain("0000"); + }); +}); + +describe("RedisKeyBrowser interrupted Fetch All", () => { + it("reloads instead of advancing past an uncommitted buffered page after reactivation", async () => { + const bufferedPage = deferred<{ cursor: number; keys: Array<{ key_display: string; key_raw: string; key_type: string; ttl: number }>; total_keys: number }>(); + let returnFreshPage = false; + let freshPageRequests = 0; + mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number) => { + if (cursor === 1) return bufferedPage.promise; + if (returnFreshPage) freshPageRequests++; + return Promise.resolve(returnFreshPage ? { cursor: 0, keys: [{ key_display: "fresh:key", key_raw: "ZnJlc2gta2V5", key_type: "string", ttl: -1 }], total_keys: 2 } : { cursor: 1, keys: [{ key_display: "initial:key", key_raw: "aW5pdGlhbC1rZXk=", key_type: "string", ttl: -1 }], total_keys: 2 }); + }); + const browser = mountKeptAliveBrowser(); + await settle(); + + clickButtonWithText("redis.fetchAllKeys"); + await settle(); + + await browser.deactivate(); + returnFreshPage = true; + bufferedPage.resolve({ cursor: 0, keys: [{ key_display: "buffered:key", key_raw: "YnVmZmVyZWQta2V5", key_type: "string", ttl: -1 }], total_keys: 0 }); + await settle(); + await browser.activate(); + await settle(); + + expect(document.body.textContent).toContain("fresh"); + expect(document.body.textContent).not.toContain("buffered"); + expect(freshPageRequests).toBeGreaterThan(0); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 921f433df..ed81cc1bb 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -25,7 +25,19 @@ import * as api from "@/lib/backend/api"; import type { RedisKeyInfo, RedisScanResult, RedisValue, HistoryEntry } from "@/lib/backend/api"; import { uuid } from "@/lib/common/utils"; import { useConnectionStore } from "@/stores/connectionStore"; -import { buildRedisKeyTree, collectExpandedGroupIds, collectRedisGroupKeyRaws, flattenVisibleRedisKeyTree, mergeKeysIntoRedisKeyTree, redisKeyNameCopyText, redisKeyToFlatTreeRow, type RedisKeyTreeNode } from "@/lib/redis/redisKeyTree"; +import { + appendRedisKeysToTreeIndex, + canBuildRedisFuzzyTree, + collectExpandedGroupIds, + collectRedisGroupKeyRaws, + createRedisKeyTreeIndex, + flattenVisibleRedisKeyTree, + redisKeyNameCopyText, + redisKeyToFlatTreeRow, + type RedisKeyTreeGroupNode, + type RedisKeyTreeIndex, + type RedisKeyTreeNode, +} from "@/lib/redis/redisKeyTree"; import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety"; import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable"; import { isRedisClearScreenCommand, nextRedisCommandDb, redisKeyTextToRaw } from "@/lib/redis/redisCommandSession"; @@ -36,7 +48,7 @@ import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle import { useToast } from "@/composables/useToast"; import { redisKeySearchPattern } from "@/lib/redis/redisKeyPattern"; import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern"; -import { collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch"; +import { chunkRedisKeyRaws, collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch"; import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp"; import { optionHelpPanelOffsetTop } from "@/lib/common/optionHelpPanelOffset"; import { applyRedisExpiryPolicy, type RedisExpiryMode, validateRedisExpiry } from "@/lib/redis/redisExpiry"; @@ -76,7 +88,7 @@ const redisExpiryTransport = { }; const flatKeys = shallowRef([]); -const treeKeys = ref([]); +const treeKeys = shallowRef([]); const loading = ref(false); const loadingMore = ref(false); const searchPending = ref(false); @@ -94,7 +106,9 @@ const hasMore = ref(false); const scanCursor = ref(0); const expandedGroupIds = ref>(new Set()); const checkedKeys = ref>(new Set()); -const pendingDanger = ref<{ kind: "delete-keys"; title: string; keyRaws: string[] } | { kind: "command"; command: string } | null>(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); const showDangerConfirm = ref(false); const commandText = ref(""); const commandRunning = ref(false); @@ -128,14 +142,22 @@ const createKeyTypeHelpOffsetTop = ref(0); let nextEntryId = 0; let searchRequestId = 0; let redisBrowserIsActive = true; +let reloadKeysOnActivation = false; let redisDbFlushedListenerRegistered = false; const loadedKeyRaws = new Set(); +let treeIndex: RedisKeyTreeIndex | null = null; const valueQuery = computed(() => searchPattern.value.trim()); const isValueSearchMode = computed(() => searchMode.value === "value" || searchMode.value === "all"); const effectivePattern = computed(() => (searchMode.value === "key" ? redisKeySearchPattern(searchPattern.value, fuzzyKeySearch.value) : "*")); const isSearchMode = computed(() => (searchMode.value === "key" ? effectivePattern.value !== "*" : valueQuery.value !== "")); -const useFlatKeySearchRows = computed(() => searchMode.value === "key" && isSearchMode.value); +// Keep regular glob search on the low-cost flat path. The explicit fuzzy mode +// opts into the namespace hierarchy that users need for group selection. +const isFuzzyKeySearch = computed(() => searchMode.value === "key" && isSearchMode.value && fuzzyKeySearch.value); +const fuzzyTreeLimitReached = computed(() => isFuzzyKeySearch.value && !canBuildRedisFuzzyTree(flatKeys.value.length)); +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); 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"); @@ -144,7 +166,14 @@ const loadingEmptyText = computed(() => (isValueSearchMode.value && valueQuery.v const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator ?? ":"); const redisScanPageSize = computed(() => connectionStore.getConfig(props.connectionId)?.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT); watch(redisKeySeparator, () => { - if (flatKeys.value.length > 0) rebuildTree(false); + if (flatKeys.value.length === 0) return; + if (useFlatKeySearchRows.value) { + treeKeys.value = []; + treeIndex = null; + expandedGroupIds.value = new Set(); + return; + } + rebuildTree(false); }); const lastTotalKeys = ref(0); const displayedKeyCount = computed(() => (isFetchingAll.value ? fetchAllLoadedCount.value : flatKeys.value.length)); @@ -166,7 +195,7 @@ const selectedKey = computed(() => flatKeys.value.find((key) => key.key_raw === const dangerDetails = computed(() => { if (!pendingDanger.value) return ""; if (pendingDanger.value.kind === "delete-keys") { - return t("redis.deleteGroupDetails", { + return t(pendingDanger.value.loadedSearchResults ? "redis.deleteLoadedSearchKeysDetails" : "redis.deleteGroupDetails", { target: pendingDanger.value.title, count: pendingDanger.value.keyRaws.length, }); @@ -256,13 +285,79 @@ watch(activeCreateKeyTypeHelp, () => { const visibleRows = computed(() => (useFlatKeySearchRows.value ? flatKeys.value.map((key) => redisKeyToFlatTreeRow(key, props.db)) : flattenVisibleRedisKeyTree(treeKeys.value, expandedGroupIds.value))); let commandHistoryId = 0; -function countLeaves(node: RedisKeyTreeNode): number { - if (node.kind === "leaf") return 1; - return node.children.reduce((sum, child) => sum + countLeaves(child), 0); +function resetCheckedKeys() { + checkedKeys.value = new Set(); + selectedGroupLeafCounts.value = new Map(); +} + +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); + } + } + checkedKeys.value = nextChecked; + selectedGroupLeafCounts.value = nextCounts; +} + +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 (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; +} + +function setKeyChecked(keyRaw: string, checked: boolean) { + setKeysChecked([keyRaw], checked); +} + +function isGroupFullyChecked(group: RedisKeyTreeGroupNode): boolean { + return group.loadedLeafCount > 0 && selectedGroupLeafCounts.value.get(group.id) === group.loadedLeafCount; +} + +function isGroupPartiallyChecked(group: RedisKeyTreeGroupNode): boolean { + const selectedCount = selectedGroupLeafCounts.value.get(group.id) ?? 0; + return selectedCount > 0 && selectedCount < group.loadedLeafCount; +} + +function setGroupChecked(group: RedisKeyTreeGroupNode, checked: boolean) { + setKeysChecked(collectRedisGroupKeyRaws(group), checked); } function rebuildTree(expandAll = false) { - const nextTree = buildRedisKeyTree(flatKeys.value, props.db, redisKeySeparator.value); + if (useFlatKeySearchRows.value) { + treeKeys.value = []; + treeIndex = null; + expandedGroupIds.value = new Set(); + refreshSelectedGroupLeafCounts(); + return; + } + treeIndex = createRedisKeyTreeIndex(flatKeys.value, props.db, redisKeySeparator.value); + const nextTree = treeIndex.root; treeKeys.value = nextTree; const nextExpanded = new Set(); @@ -275,6 +370,7 @@ function rebuildTree(expandAll = false) { } } expandedGroupIds.value = nextExpanded; + refreshSelectedGroupLeafCounts(); if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) { selectedKeyRaw.value = null; @@ -283,18 +379,22 @@ function rebuildTree(expandAll = false) { function mergeTree(newKeys: RedisKeyInfo[]) { if (newKeys.length === 0) return; - treeKeys.value = mergeKeysIntoRedisKeyTree(treeKeys.value, newKeys, props.db, redisKeySeparator.value); + if (!treeIndex) { + rebuildTree(isSearchMode.value); + return; + } + const { addedGroupIds } = appendRedisKeysToTreeIndex(treeIndex, newKeys, props.db, redisKeySeparator.value); + // Trigger the shallow ref without proxying the whole namespace hierarchy. + treeKeys.value = [...treeIndex.root]; - const availableExpanded = collectExpandedGroupIds(treeKeys.value); const nextExpanded = new Set(); for (const id of expandedGroupIds.value) { - if (availableExpanded.has(id)) nextExpanded.add(id); + if (treeIndex.groupById.has(id)) nextExpanded.add(id); + } + if (isFuzzyHierarchyView.value) { + for (const id of addedGroupIds) nextExpanded.add(id); } expandedGroupIds.value = nextExpanded; - - if (selectedKeyRaw.value && !flatKeys.value.some((key) => key.key_raw === selectedKeyRaw.value)) { - selectedKeyRaw.value = null; - } } async function fetchScanPage(requestId = searchRequestId): Promise { @@ -362,6 +462,7 @@ function appendScanResult(result: RedisScanResult, options: { updateTree?: boole if (options.updateTree ?? true) { if (useFlatKeySearchRows.value) { treeKeys.value = []; + treeIndex = null; expandedGroupIds.value = new Set(); } else if (treeKeys.value.length === 0) { rebuildTree(isSearchMode.value); @@ -405,8 +506,9 @@ async function loadKeys() { loadedKeyRaws.clear(); flatKeys.value = []; treeKeys.value = []; + treeIndex = null; selectedKeyRaw.value = null; - checkedKeys.value = new Set(); + resetCheckedKeys(); expandedGroupIds.value = new Set(); scanCursor.value = 0; lastTotalKeys.value = 0; @@ -463,7 +565,15 @@ async function fetchAll() { } finally { if (requestId === searchRequestId) { if (bufferedKeys.length > 0) flatKeys.value = [...flatKeys.value, ...bufferedKeys]; - if (changed && !useFlatKeySearchRows.value) rebuildTree(isSearchMode.value); + if (changed) { + if (useFlatKeySearchRows.value) { + treeKeys.value = []; + treeIndex = null; + expandedGroupIds.value = new Set(); + } else { + rebuildTree(isSearchMode.value); + } + } isFetchingAll.value = false; fetchAllStopRequested.value = false; fetchAllLoadedCount.value = 0; @@ -497,10 +607,13 @@ function removeKnownKey(keyRaw: string) { loadedKeyRaws.delete(keyRaw); flatKeys.value = flatKeys.value.filter((key) => key.key_raw !== keyRaw); if (selectedKeyRaw.value === keyRaw) selectedKeyRaw.value = null; - const nextChecked = new Set(checkedKeys.value); - nextChecked.delete(keyRaw); - checkedKeys.value = nextChecked; - rebuildTree(false); + if (useFlatKeySearchRows.value) { + treeKeys.value = []; + treeIndex = null; + refreshSelectedGroupLeafCounts(); + } else { + rebuildTree(false); + } connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, { loaded: isSearchMode.value ? undefined : flatKeys.value.length, totalDelta: -1, @@ -532,29 +645,43 @@ function onKeyLoaded(value: RedisValue) { if (existingIndex < 0) return; flatKeys.value = flatKeys.value.map((key, index) => (index === existingIndex ? keyInfo : key)); loadedKeyRaws.add(keyInfo.key_raw); - rebuildTree(false); + if (useFlatKeySearchRows.value) { + treeKeys.value = []; + treeIndex = null; + refreshSelectedGroupLeafCounts(); + } else { + rebuildTree(false); + } } function toggleCheck(keyRaw: string, event: Event) { event.stopPropagation(); - const next = new Set(checkedKeys.value); - if (next.has(keyRaw)) next.delete(keyRaw); - else next.add(keyRaw); - checkedKeys.value = next; + if (selectionBusy.value) return; + setKeyChecked(keyRaw, !checkedKeys.value.has(keyRaw)); } function requestBatchDelete() { - if (checkedKeys.value.size === 0) return; - pendingDanger.value = { kind: "delete-keys", title: t("redis.selectedKeys"), keyRaws: [...checkedKeys.value] }; + if (checkedKeys.value.size === 0 || selectionBusy.value) return; + pendingDanger.value = { + kind: "delete-keys", + title: t("redis.selectedKeys"), + keyRaws: [...checkedKeys.value], + loadedSearchResults: isFuzzyKeySearch.value, + }; showDangerConfirm.value = true; } function requestGroupDelete(node: RedisKeyTreeNode, event: Event) { event.stopPropagation(); - if (node.kind !== "group") return; + if (node.kind !== "group" || selectionBusy.value) return; const keyRaws = collectRedisGroupKeyRaws(node); if (keyRaws.length === 0) return; - pendingDanger.value = { kind: "delete-keys", title: node.pathSegments.join(":"), keyRaws }; + pendingDanger.value = { + kind: "delete-keys", + title: node.pathSegments.join(redisKeySeparator.value), + keyRaws, + loadedSearchResults: false, + }; showDangerConfirm.value = true; } @@ -593,27 +720,54 @@ function resetLoadedKeys() { loadedKeyRaws.clear(); flatKeys.value = []; treeKeys.value = []; + treeIndex = null; selectedKeyRaw.value = null; - checkedKeys.value = new Set(); + resetCheckedKeys(); expandedGroupIds.value = new Set(); hasMore.value = false; lastTotalKeys.value = 0; } async function deleteKeyRaws(keys: string[]) { - const deletedCount = await api.redisDeleteKeys(props.connectionId, props.db, keys); - const deleted = new Set(keys); - for (const key of deleted) loadedKeyRaws.delete(key); - flatKeys.value = flatKeys.value.filter((k) => !deleted.has(k.key_raw)); - if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) { - selectedKeyRaw.value = null; + const uniqueKeys = [...new Set(keys)]; + if (uniqueKeys.length === 0 || deletingKeys.value) return; + + // Ignore a late SCAN page while an explicit mutation changes this result set. + searchRequestId++; + fetchAllStopRequested.value = true; + deletingKeys.value = true; + try { + let deletedCount = 0; + for (const batch of chunkRedisKeyRaws(uniqueKeys)) { + deletedCount += await api.redisDeleteKeys(props.connectionId, props.db, batch); + } + const deleted = new Set(uniqueKeys); + for (const key of deleted) loadedKeyRaws.delete(key); + flatKeys.value = flatKeys.value.filter((key) => !deleted.has(key.key_raw)); + if (selectedKeyRaw.value && deleted.has(selectedKeyRaw.value)) { + selectedKeyRaw.value = null; + } + resetCheckedKeys(); + if (useFlatKeySearchRows.value) { + treeKeys.value = []; + treeIndex = null; + } else { + rebuildTree(false); + } + connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, { + loaded: isSearchMode.value ? undefined : flatKeys.value.length, + totalDelta: -deletedCount, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast(message, 5000); + // A Cluster request may have deleted an earlier shard before a later error. + // Reload instead of leaving a potentially stale partial result in the tree. + if (redisBrowserIsActive) await loadKeys(); + else reloadKeysOnActivation = true; + } finally { + deletingKeys.value = false; } - checkedKeys.value = new Set(); - rebuildTree(false); - connectionStore.updateRedisDbKeyStats(props.connectionId, props.db, { - loaded: isSearchMode.value ? undefined : flatKeys.value.length, - totalDelta: -deletedCount, - }); } function scrollCommandTerminalToEnd() { @@ -1065,13 +1219,15 @@ async function executeCommand() { async function applyDangerAction() { const pending = pendingDanger.value; - pendingDanger.value = null; - showDangerConfirm.value = false; if (!pending) return; if (pending.kind === "delete-keys") { await deleteKeyRaws(pending.keyRaws); + pendingDanger.value = null; + showDangerConfirm.value = false; } else { + pendingDanger.value = null; + showDangerConfirm.value = false; await runRedisCommand(pending.command); } } @@ -1175,6 +1331,10 @@ function unregisterRedisDbFlushedListener() { } function pauseRedisBrowserBackgroundWork() { + // Fetch All advances the cursor and duplicate filter before its local buffer + // is committed. Discard that incomplete session so reactivation cannot skip + // keys that were never rendered. + const discardIncompleteFetchAll = isFetchingAll.value; redisBrowserIsActive = false; searchRequestId++; isFetchingAll.value = false; @@ -1185,6 +1345,7 @@ function pauseRedisBrowserBackgroundWork() { searchPending.value = false; if (searchTimer) clearTimeout(searchTimer); searchTimer = null; + if (discardIncompleteFetchAll) resetLoadedKeys(); unregisterRedisDbFlushedListener(); } @@ -1252,6 +1413,8 @@ onMounted(async () => { onActivated(async () => { resumeRedisBrowserBackgroundWork(); void autofocusSearchOnce(); + const shouldReload = reloadKeysOnActivation; + reloadKeysOnActivation = false; // Ensure the connection is still alive after reactivation (e.g. tab switch). // If keys failed to load previously (empty list), retry loading. try { @@ -1259,7 +1422,7 @@ onActivated(async () => { } catch (e) { console.warn("[DBX] ensureConnected failed for", props.connectionId, e); } - if (flatKeys.value.length === 0 && !loading.value) { + if ((shouldReload || flatKeys.value.length === 0) && !loading.value) { void loadKeys(); } }); @@ -1318,8 +1481,8 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
{{ keyCountText }} - - + @@ -1359,7 +1522,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
+
+ {{ t("redis.fuzzyTreeLimit", { count: flatKeys.length }) }} +
- -
@@ -1421,7 +1605,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
{{ fetchAllProgressText }}
-
@@ -1509,7 +1693,7 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand }); - + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 2a792ccf5..615ecf351 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2909,8 +2909,12 @@ export default { columnTTL: "TTL", binaryStringReadonlyHint: "Binary string values are shown as escaped text in read-only mode; editing raw bytes is not supported.", selectedKeys: "Selected keys", + selectLoadedGroupKeys: "Select {count} loaded matching keys", + loadedMatchingKeys: "{count} loaded matching keys", + fuzzyTreeLimit: "{count} fuzzy matches are shown as a flat list to keep the browser responsive. Refine the search to view the hierarchy.", deleteGroup: "Delete group", deleteGroupDetails: "{target}\n{count} keys", + deleteLoadedSearchKeysDetails: "{target}\n{count} loaded matching keys", flushDb: "Clear current DB", flushDbMessage: "This will delete every key in the current Redis database and cannot be undone. Continue?", flushDbDetails: "Target: Redis db{db}\nScope: all keys in the current DB", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 334e82519..fb04e65f9 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2770,8 +2770,12 @@ export default withEnglishFallback({ columnTTL: "TTL", binaryStringReadonlyHint: "Los valores de cadena binaria se muestran como texto escapado en modo de solo lectura; la edición de bytes sin procesar no está disponible.", selectedKeys: "Claves seleccionadas", + selectLoadedGroupKeys: "Seleccionar {count} claves coincidentes cargadas", + loadedMatchingKeys: "{count} claves coincidentes cargadas", + fuzzyTreeLimit: "Se muestran {count} coincidencias difusas como lista plana para mantener el navegador responsivo. Refina la búsqueda para ver la jerarquía.", deleteGroup: "Eliminar grupo", deleteGroupDetails: "{target}\n{count} claves", + deleteLoadedSearchKeysDetails: "{target}\n{count} claves coincidentes cargadas", flushDb: "Limpiar DB actual", flushDbMessage: "Esto eliminará todas las claves de la base de datos Redis actual y no se puede deshacer. ¿Continuar?", flushDbDetails: "Destino: Redis db{db}\nAlcance: todas las claves de la DB actual", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 6db6e6656..b04205101 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2768,8 +2768,12 @@ export default withEnglishFallback({ columnTTL: "TTL", binaryStringReadonlyHint: "I valori delle stringhe binarie sono mostrati come testo con escape in modalità sola lettura; la modifica dei byte grezzi non è supportata.", selectedKeys: "Chiavi selezionate", + selectLoadedGroupKeys: "Seleziona {count} chiavi corrispondenti caricate", + loadedMatchingKeys: "{count} chiavi corrispondenti caricate", + fuzzyTreeLimit: "{count} corrispondenze fuzzy sono mostrate come elenco piatto per mantenere il browser reattivo. Restringi la ricerca per vedere la gerarchia.", deleteGroup: "Elimina gruppo", deleteGroupDetails: "{target}\n{count} chiavi", + deleteLoadedSearchKeysDetails: "{target}\n{count} chiavi corrispondenti caricate", flushDb: "Pulisci DB corrente", flushDbMessage: "Questo eliminerà ogni chiave nel database Redis corrente e non può essere annullato. Continuare?", flushDbDetails: "Destinazione: Redis db{db}\nAmbito: tutte le chiavi nel DB corrente", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 17b111a76..df365a9cb 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2769,8 +2769,12 @@ export default withEnglishFallback({ columnTTL: "TTL", binaryStringReadonlyHint: "バイナリ文字列値は読み取り専用モードでエスケープテキストとして表示されます。生バイトの編集はサポートされていません。", selectedKeys: "選択中のキー", + selectLoadedGroupKeys: "読み込み済みの一致キー {count} 件を選択", + loadedMatchingKeys: "読み込み済みの一致キー {count} 件", + fuzzyTreeLimit: "ブラウザーの応答性を保つため、あいまい一致 {count} 件をフラットな一覧で表示しています。階層を表示するには検索条件を絞り込んでください。", deleteGroup: "グループを削除", deleteGroupDetails: "{target}\n{count}個のキー", + deleteLoadedSearchKeysDetails: "{target}\n読み込み済みの一致キー {count} 件", flushDb: "現在のDBをクリア", flushDbMessage: "現在のRedisデータベース内のすべてのキーが削除されます。この操作は取り消せません。続行しますか?", flushDbDetails: "対象: Redis db{db}\n範囲: 現在のDB内のすべてのキー", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 413c6aa21..124c37905 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2770,8 +2770,12 @@ export default withEnglishFallback({ columnTTL: "TTL", binaryStringReadonlyHint: "Valores de string binária são exibidos como texto escapado no modo somente leitura; a edição de bytes brutos não é suportada.", selectedKeys: "Chaves selecionadas", + selectLoadedGroupKeys: "Selecionar {count} chaves correspondentes carregadas", + loadedMatchingKeys: "{count} chaves correspondentes carregadas", + fuzzyTreeLimit: "{count} correspondências difusas são mostradas como uma lista plana para manter o navegador responsivo. Refine a busca para ver a hierarquia.", deleteGroup: "Excluir grupo", deleteGroupDetails: "{target}\n{count} chaves", + deleteLoadedSearchKeysDetails: "{target}\n{count} chaves correspondentes carregadas", flushDb: "Limpar DB atual", flushDbMessage: "Isto excluirá todas as chaves do banco de dados Redis atual e não pode ser desfeito. Continuar?", flushDbDetails: "Alvo: Redis db{db}\nEscopo: todas as chaves do DB atual", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 23ac6e552..65025e37c 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2909,8 +2909,12 @@ export default withEnglishFallback({ columnTTL: "TTL", binaryStringReadonlyHint: "二进制字符串按转义文本只读展示;当前不支持直接编辑原始字节值。", selectedKeys: "已选择的 key", + selectLoadedGroupKeys: "选择已加载的 {count} 个匹配 key", + loadedMatchingKeys: "已加载的 {count} 个匹配 key", + fuzzyTreeLimit: "已加载 {count} 个模糊匹配 key。为保持浏览器响应,当前以扁平列表展示;请缩小搜索范围以查看层级。", deleteGroup: "删除分组", deleteGroupDetails: "{target}\n{count} 个 key", + deleteLoadedSearchKeysDetails: "{target}\n已加载的 {count} 个匹配 key", flushDb: "清空当前 DB", flushDbMessage: "这会删除当前 Redis 数据库中的所有 key,且不可撤销。确认继续?", flushDbDetails: "目标: Redis db{db}\n范围: 当前 DB 的所有 key", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 222d2da52..241ef1929 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2441,8 +2441,12 @@ export default withEnglishFallback({ columnTTL: "TTL", binaryStringReadonlyHint: "二進位字串值會以逸出文字唯讀顯示;不支援編輯原始位元組。", selectedKeys: "已選擇的 key", + selectLoadedGroupKeys: "選取已載入的 {count} 個相符 key", + loadedMatchingKeys: "已載入的 {count} 個相符 key", + fuzzyTreeLimit: "已載入 {count} 個模糊相符 key。為保持瀏覽器回應,目前以扁平清單顯示;請縮小搜尋範圍以查看層級。", deleteGroup: "刪除群組", deleteGroupDetails: "{target}\n{count} 個 key", + deleteLoadedSearchKeysDetails: "{target}\n已載入的 {count} 個相符 key", flushDb: "清空目前 DB", flushDbMessage: "這會刪除目前 Redis 資料庫中的所有 key,且無法復原。確認繼續?", flushDbDetails: "目標: Redis db{db}\n範圍: 目前 DB 的所有 key", diff --git a/apps/desktop/src/lib/redis/redisKeyBatch.ts b/apps/desktop/src/lib/redis/redisKeyBatch.ts index 9f5522f5d..170d208b1 100644 --- a/apps/desktop/src/lib/redis/redisKeyBatch.ts +++ b/apps/desktop/src/lib/redis/redisKeyBatch.ts @@ -1,5 +1,8 @@ import type { RedisKeyInfo } from "@/lib/backend/api"; +// Keep desktop IPC and Redis command payloads bounded for large group deletes. +export const REDIS_DELETE_KEY_BATCH_SIZE = 1_000; + export function collectUniqueRedisKeys(keys: RedisKeyInfo[], loadedKeyRaws: Set): RedisKeyInfo[] { const uniqueKeys: RedisKeyInfo[] = []; @@ -11,3 +14,9 @@ export function collectUniqueRedisKeys(keys: RedisKeyInfo[], loadedKeyRaws: Set< return uniqueKeys; } + +export function* chunkRedisKeyRaws(keyRaws: readonly string[], batchSize = REDIS_DELETE_KEY_BATCH_SIZE): Generator { + for (let start = 0; start < keyRaws.length; start += batchSize) { + yield keyRaws.slice(start, start + batchSize); + } +} diff --git a/apps/desktop/src/lib/redis/redisKeyTree.ts b/apps/desktop/src/lib/redis/redisKeyTree.ts index f07e97e7f..c48d93591 100644 --- a/apps/desktop/src/lib/redis/redisKeyTree.ts +++ b/apps/desktop/src/lib/redis/redisKeyTree.ts @@ -1,5 +1,13 @@ import type { RedisKeyInfo } from "@/lib/backend/api"; +// A hierarchy duplicates key metadata and indexes; above this limit keep +// fuzzy results virtualized as flat rows instead of exhausting desktop memory. +export const REDIS_FUZZY_TREE_MAX_KEYS = 200_000; + +export function canBuildRedisFuzzyTree(loadedKeyCount: number): boolean { + return loadedKeyCount <= REDIS_FUZZY_TREE_MAX_KEYS; +} + export interface RedisKeyTreeLeafNode { kind: "leaf"; id: string; @@ -20,6 +28,8 @@ export interface RedisKeyTreeGroupNode { label: string; pathSegments: string[]; children: RedisKeyTreeNode[]; + // The rendered count must not recursively walk an entire visible subtree. + loadedLeafCount: number; } export type RedisKeyTreeNode = RedisKeyTreeLeafNode | RedisKeyTreeGroupNode; @@ -30,6 +40,17 @@ export interface RedisKeyTreeRow { depth: number; } +export interface RedisKeyTreeIndex { + root: RedisKeyTreeNode[]; + groupById: Map; + keyRaws: Set; + ancestorGroupIdsByKeyRaw: Map; +} + +export interface AppendRedisKeysResult { + addedGroupIds: Set; +} + export function redisKeyNameCopyText(node: RedisKeyTreeNode): string | null { // keyRaw is base64-encoded for backend roundtrips; copy the user-visible // Redis key name instead of the internal transport value. @@ -37,7 +58,7 @@ export function redisKeyNameCopyText(node: RedisKeyTreeNode): string | null { } function buildGroupId(db: number, pathSegments: string[]): string { - return `group:${db}:${pathSegments.join("\u0000")}`; + return `group:${db}:${JSON.stringify(pathSegments)}`; } function buildLeafId(db: number, keyRaw: string): string { @@ -70,35 +91,73 @@ function compareRedisTreeNodes(a: RedisKeyTreeNode, b: RedisKeyTreeNode): number return a.label.localeCompare(b.label); } -function sortRedisTreeNodes(nodes: RedisKeyTreeNode[]): RedisKeyTreeNode[] { - return [...nodes].sort(compareRedisTreeNodes).map((node) => - node.kind === "group" - ? { - ...node, - children: sortRedisTreeNodes(node.children), - } - : node, - ); +function redisKeyInfoFromLeaf(node: RedisKeyTreeLeafNode): RedisKeyInfo { + return { + key_display: node.fullKeyDisplay, + key_raw: node.keyRaw, + key_type: node.keyType, + ttl: node.ttl, + size: node.size, + value_preview: node.valuePreview, + }; } -export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] { - const root: RedisKeyTreeNode[] = []; - const groupMap = new Map(); +export function createRedisKeyTreeIndex(keys: readonly RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeIndex { + const index: RedisKeyTreeIndex = { + root: [], + groupById: new Map(), + keyRaws: new Set(), + ancestorGroupIdsByKeyRaw: new Map(), + }; + appendRedisKeysToTreeIndex(index, keys, db, separator); + return index; +} + +/** + * Inserts one SCAN batch while sorting only sibling arrays changed by that + * batch. This keeps repeated "load more" merges from re-sorting the full tree. + */ +export function appendRedisKeysToTreeIndex(index: RedisKeyTreeIndex, keys: readonly RedisKeyInfo[], db: number, separator = ":"): AppendRedisKeysResult { + const touchedLevels = new Set(); + const addedGroupIds = new Set(); for (const key of keys) { - insertKeyIntoTree(root, groupMap, key, db, separator); - } + if (index.keyRaws.has(key.key_raw)) continue; - return sortRedisTreeNodes(root); -} + const pathSegments = separator ? key.key_display.split(separator) : [key.key_display]; + const ancestorGroupIds: string[] = []; + let currentLevel = index.root; -function insertKeyIntoTree(root: RedisKeyTreeNode[], groupMap: Map, key: RedisKeyInfo, db: number, separator: string): void { - const pathSegments = separator ? key.key_display.split(separator) : [key.key_display]; - if (pathSegments.length === 1) { - root.push({ + if (pathSegments.length > 1) { + const groupSegments: string[] = []; + for (const segment of pathSegments.slice(0, -1)) { + groupSegments.push(segment); + const groupId = buildGroupId(db, groupSegments); + let group = index.groupById.get(groupId); + if (!group) { + group = { + kind: "group", + id: groupId, + label: segment, + pathSegments: [...groupSegments], + children: [], + loadedLeafCount: 0, + }; + index.groupById.set(groupId, group); + currentLevel.push(group); + touchedLevels.add(currentLevel); + addedGroupIds.add(groupId); + } + group.loadedLeafCount++; + ancestorGroupIds.push(groupId); + currentLevel = group.children; + } + } + + currentLevel.push({ kind: "leaf", id: buildLeafId(db, key.key_raw), - label: pathSegments[0], + label: pathSegments[pathSegments.length - 1], fullKeyDisplay: key.key_display, keyRaw: key.key_raw, db, @@ -108,113 +167,62 @@ function insertKeyIntoTree(root: RedisKeyTreeNode[], groupMap: Map { - const groupMap = new Map(); - - const walk = (nodes: RedisKeyTreeNode[]) => { - for (const node of nodes) { - if (node.kind === "group") { - groupMap.set(node.id, node); - walk(node.children); - } - } - }; - - walk(tree); - return groupMap; +export function buildRedisKeyTree(keys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] { + return createRedisKeyTreeIndex(keys, db, separator).root; } export function mergeKeysIntoRedisKeyTree(existingTree: RedisKeyTreeNode[], newKeys: RedisKeyInfo[], db: number, separator = ":"): RedisKeyTreeNode[] { - if (existingTree.length === 0) return buildRedisKeyTree(newKeys, db, separator); - - const groupMap = rebuildGroupMap(existingTree); - const existingKeyIds = new Set(); - const collectKeys = (nodes: RedisKeyTreeNode[]) => { - for (const node of nodes) { - if (node.kind === "leaf") { - existingKeyIds.add(node.keyRaw); - } else { - collectKeys(node.children); - } + const existingKeys: RedisKeyInfo[] = []; + const stack = [...existingTree]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.kind === "leaf") { + existingKeys.push(redisKeyInfoFromLeaf(node)); + } else { + for (const child of node.children) stack.push(child); } - }; - collectKeys(existingTree); - - for (const key of newKeys) { - if (existingKeyIds.has(key.key_raw)) continue; - insertKeyIntoTree(existingTree, groupMap, key, db, separator); } - return sortRedisTreeNodes(existingTree); + const index = createRedisKeyTreeIndex(existingKeys, db, separator); + appendRedisKeysToTreeIndex(index, newKeys, db, separator); + return index.root; } export function collectExpandedGroupIds(nodes: RedisKeyTreeNode[]): Set { const ids = new Set(); - - const visit = (entries: RedisKeyTreeNode[]) => { - for (const node of entries) { - if (node.kind !== "group") continue; - ids.add(node.id); - visit(node.children); - } - }; - - visit(nodes); + const stack = [...nodes]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.kind !== "group") continue; + ids.add(node.id); + for (const child of node.children) stack.push(child); + } return ids; } export function collectRedisGroupKeyRaws(group: RedisKeyTreeGroupNode): string[] { const keyRaws: string[] = []; - - const visit = (nodes: RedisKeyTreeNode[]) => { - for (const node of nodes) { - if (node.kind === "leaf") { - keyRaws.push(node.keyRaw); - } else { - visit(node.children); + const stack = [...group.children].reverse(); + while (stack.length > 0) { + const node = stack.pop()!; + if (node.kind === "leaf") { + keyRaws.push(node.keyRaw); + } else { + // Push in reverse so the iterative walk keeps the rendered tree order. + for (let index = node.children.length - 1; index >= 0; index--) { + stack.push(node.children[index]); } } - }; - - visit(group.children); + } return keyRaws; } diff --git a/packages/app-tests/redisKeyBatch.test.ts b/packages/app-tests/redisKeyBatch.test.ts index 0bdead0de..eed356c64 100644 --- a/packages/app-tests/redisKeyBatch.test.ts +++ b/packages/app-tests/redisKeyBatch.test.ts @@ -1,6 +1,6 @@ import { test } from "vitest"; import assert from "node:assert/strict"; -import { collectUniqueRedisKeys } from "../../apps/desktop/src/lib/redis/redisKeyBatch.ts"; +import { chunkRedisKeyRaws, collectUniqueRedisKeys, REDIS_DELETE_KEY_BATCH_SIZE } from "../../apps/desktop/src/lib/redis/redisKeyBatch.ts"; import type { RedisKeyInfo } from "../../apps/desktop/src/lib/backend/api.ts"; function makeKey(key: string): RedisKeyInfo { @@ -40,3 +40,14 @@ test("collectUniqueRedisKeys handles large batches without changing key objects" assert.equal(keys[0], input[0]); assert.equal(keys.at(-1), input.at(-1)); }); + +test("chunkRedisKeyRaws bounds large delete payloads without changing key order", () => { + const keyRaws = Array.from({ length: REDIS_DELETE_KEY_BATCH_SIZE * 2 + 1 }, (_, index) => `key:${index}`); + const batches = [...chunkRedisKeyRaws(keyRaws)]; + + assert.deepEqual( + batches.map((batch) => batch.length), + [REDIS_DELETE_KEY_BATCH_SIZE, REDIS_DELETE_KEY_BATCH_SIZE, 1], + ); + assert.deepEqual(batches.flat(), keyRaws); +}); diff --git a/packages/app-tests/redisKeyTree.test.ts b/packages/app-tests/redisKeyTree.test.ts index 0f1fb91a3..a61d93358 100644 --- a/packages/app-tests/redisKeyTree.test.ts +++ b/packages/app-tests/redisKeyTree.test.ts @@ -1,6 +1,20 @@ import { test } from "vitest"; import assert from "node:assert/strict"; -import { buildRedisKeyTree, collectRedisGroupKeyRaws, collectExpandedGroupIds, flattenVisibleRedisKeyTree, mergeKeysIntoRedisKeyTree, redisKeyNameCopyText, redisKeyToFlatTreeRow, type RedisKeyTreeNode } from "../../apps/desktop/src/lib/redis/redisKeyTree.ts"; +import { + appendRedisKeysToTreeIndex, + buildRedisKeyTree, + canBuildRedisFuzzyTree, + collectRedisGroupKeyRaws, + collectExpandedGroupIds, + createRedisKeyTreeIndex, + flattenVisibleRedisKeyTree, + mergeKeysIntoRedisKeyTree, + redisKeyNameCopyText, + redisKeyToFlatTreeRow, + REDIS_FUZZY_TREE_MAX_KEYS, + type RedisKeyTreeGroupNode, + type RedisKeyTreeNode, +} from "../../apps/desktop/src/lib/redis/redisKeyTree.ts"; import type { RedisKeyInfo } from "../../apps/desktop/src/lib/backend/api.ts"; function makeKey(key_display: string, key_raw: string, key_type = "string", ttl = -1): RedisKeyInfo { @@ -11,6 +25,18 @@ function leafLabels(nodes: RedisKeyTreeNode[]): string[] { return nodes.filter((node) => node.kind === "leaf").map((node) => node.label); } +function findGroup(nodes: RedisKeyTreeNode[], label: string): RedisKeyTreeGroupNode { + const group = nodes.find((node) => node.kind === "group" && node.label === label); + assert.ok(group); + assert.equal(group.kind, "group"); + return group; +} + +test("fuzzy hierarchy stops before the configured key limit is exceeded", () => { + assert.equal(canBuildRedisFuzzyTree(REDIS_FUZZY_TREE_MAX_KEYS), true); + assert.equal(canBuildRedisFuzzyTree(REDIS_FUZZY_TREE_MAX_KEYS + 1), false); +}); + test("buildRedisKeyTree groups colon-delimited keys by segment", () => { const tree = buildRedisKeyTree([makeKey("a:b:c", "k1"), makeKey("a:b:d", "k2"), makeKey("a:e", "k3"), makeKey("x", "k4")], 0); @@ -127,6 +153,81 @@ test("collectRedisGroupKeyRaws returns every leaf key under a group", () => { assert.deepEqual(collectRedisGroupKeyRaws(userGroup), ["k2", "k1", "k3"]); }); +test("group identity keeps NUL-containing path segments isolated", () => { + const firstKeyRaw = "raw-first"; + const secondKeyRaw = "raw-second"; + const index = createRedisKeyTreeIndex([makeKey(`a\0b:c:x`, firstKeyRaw), makeKey(`a:b\0c:y`, secondKeyRaw)], 0); + + const firstRoot = findGroup(index.root, `a\0b`); + const firstBranch = findGroup(firstRoot.children, "c"); + const secondRoot = findGroup(index.root, "a"); + const secondBranch = findGroup(secondRoot.children, `b\0c`); + + assert.notEqual(firstBranch.id, secondBranch.id); + assert.equal(index.groupById.size, 4); + assert.deepEqual(collectRedisGroupKeyRaws(firstRoot), [firstKeyRaw]); + assert.deepEqual(collectRedisGroupKeyRaws(secondRoot), [secondKeyRaw]); + assert.deepEqual(index.ancestorGroupIdsByKeyRaw.get(firstKeyRaw), [firstRoot.id, firstBranch.id]); + assert.deepEqual(index.ancestorGroupIdsByKeyRaw.get(secondKeyRaw), [secondRoot.id, secondBranch.id]); +}); + +test("tree index incrementally merges SCAN pages with loaded counts and selection ancestry", () => { + const index = createRedisKeyTreeIndex([makeKey("team:api:v1", "k1"), makeKey("team:web:home", "k2")], 0); + const team = findGroup(index.root, "team"); + const api = findGroup(team.children, "api"); + + const { addedGroupIds } = appendRedisKeysToTreeIndex( + index, + [ + makeKey("team:api:v2", "k3"), + makeKey("orders:1", "k4"), + // SCAN may return the same key more than once across pages. + makeKey("team:api:v1", "k1"), + ], + 0, + ); + + const orders = findGroup(index.root, "orders"); + assert.deepEqual( + index.root.map((node) => node.label), + ["orders", "team"], + ); + assert.equal(team.loadedLeafCount, 3); + assert.equal(api.loadedLeafCount, 2); + assert.equal(orders.loadedLeafCount, 1); + assert.equal(index.keyRaws.size, 4); + assert.deepEqual(index.ancestorGroupIdsByKeyRaw.get("k3"), [team.id, api.id]); + assert.ok(addedGroupIds.has(orders.id)); + assert.ok(!addedGroupIds.has(team.id)); + assert.deepEqual(collectRedisGroupKeyRaws(team), ["k1", "k3", "k2"]); +}); + +test("buildRedisKeyTree honors custom and empty Redis key separators", () => { + const customSeparatorTree = buildRedisKeyTree([makeKey("service/api/v1", "k1"), makeKey("service/web", "k2"), makeKey("literal:colon", "k3")], 0, "/"); + const service = findGroup(customSeparatorTree, "service"); + const api = findGroup(service.children, "api"); + + assert.equal(service.loadedLeafCount, 2); + assert.equal(api.loadedLeafCount, 1); + assert.deepEqual(collectRedisGroupKeyRaws(service), ["k1", "k2"]); + assert.deepEqual( + buildRedisKeyTree([makeKey("service/api/v1", "k1"), makeKey("literal:colon", "k2")], 0, "").map((node) => `${node.kind}:${node.label}`), + ["leaf:literal:colon", "leaf:service/api/v1"], + ); +}); + +test("tree keeps a namespace leaf alongside its child group", () => { + const tree = buildRedisKeyTree([makeKey("session", "k1"), makeKey("session:active", "k2")], 0); + const sessionGroup = findGroup(tree, "session"); + + assert.equal(sessionGroup.loadedLeafCount, 1); + assert.deepEqual( + tree.map((node) => `${node.kind}:${node.label}`), + ["group:session", "leaf:session"], + ); + assert.deepEqual(collectRedisGroupKeyRaws(sessionGroup), ["k2"]); +}); + test("mergeKeysIntoRedisKeyTree adds new leaf to existing group", () => { const tree = buildRedisKeyTree([makeKey("user:profile:name", "k1")], 0); const merged = mergeKeysIntoRedisKeyTree(tree, [makeKey("user:profile:email", "k2")], 0);