From dc1b651949dc667cc8e2b7ff76e5f48af39a08fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E9=A2=97=E7=BA=A2=E5=BF=83?= Date: Thu, 10 Sep 2026 11:23:57 +0800 Subject: [PATCH] fix(redis): keep key browser filters across tab switches --- .../src/components/layout/ContentArea.vue | 2 +- .../redis/RedisKeyBrowser.tabState.spec.ts | 361 ++++++++++++++++++ .../src/components/redis/RedisKeyBrowser.vue | 27 +- .../tabs/redisKeyBrowserStateCache.spec.ts | 58 +++ .../src/lib/tabs/redisKeyBrowserStateCache.ts | 43 +++ 5 files changed, 486 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/tabs/redisKeyBrowserStateCache.spec.ts create mode 100644 apps/desktop/src/lib/tabs/redisKeyBrowserStateCache.ts diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 87acac1b74..3c0085b129 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -2346,7 +2346,7 @@ defineExpose({ diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts new file mode 100644 index 0000000000..dc789a91b3 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.tabState.spec.ts @@ -0,0 +1,361 @@ +// @vitest-environment happy-dom + +import { createApp, nextTick } from "vue"; +import { createI18n } from "vue-i18n"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearRedisKeyBrowserState, saveRedisKeyBrowserState } from "@/lib/tabs/redisKeyBrowserStateCache"; + +const mocks = vi.hoisted(() => ({ + redisScanKeysBatch: vi.fn(), + redisGetValue: vi.fn(), + redisSetString: vi.fn(), + redisJsonSet: vi.fn(), + redisHashSet: vi.fn(), + redisListPush: vi.fn(), + redisSetAdd: vi.fn(), + redisZadd: vi.fn(), + redisStreamAdd: vi.fn(), + redisSetTtl: vi.fn(), + redisSetExpireAt: vi.fn(), + redisCheckJsonModule: vi.fn(), + redisDeleteKey: vi.fn(), + redisDeleteKeys: vi.fn(), + redisExecuteCommand: vi.fn(), + saveHistory: vi.fn(), + toast: vi.fn(), + updateRedisDbKeyStats: vi.fn(), + listRedisCompletionCommandDocs: vi.fn(), + listRedisCompletionKeys: vi.fn(), + redisScanPageSize: 100, + infiniteScroll: false, + queryResultMaxRowsEnabled: true, + queryResultMaxRows: 5000, +})); + +vi.mock("@/lib/backend/api", () => ({ + redisScanKeysBatch: mocks.redisScanKeysBatch, + redisGetValue: mocks.redisGetValue, + redisSetString: mocks.redisSetString, + redisJsonSet: mocks.redisJsonSet, + redisHashSet: mocks.redisHashSet, + redisListPush: mocks.redisListPush, + redisSetAdd: mocks.redisSetAdd, + redisZadd: mocks.redisZadd, + redisStreamAdd: mocks.redisStreamAdd, + redisSetTtl: mocks.redisSetTtl, + redisSetExpireAt: mocks.redisSetExpireAt, + redisCheckJsonModule: mocks.redisCheckJsonModule, + redisDeleteKey: mocks.redisDeleteKey, + redisDeleteKeys: mocks.redisDeleteKeys, + redisExecuteCommand: mocks.redisExecuteCommand, + saveHistory: mocks.saveHistory, +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: vi.fn().mockResolvedValue(undefined), + getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: mocks.redisScanPageSize }), + updateRedisDbKeyStats: mocks.updateRedisDbKeyStats, + listRedisCompletionCommandDocs: mocks.listRedisCompletionCommandDocs, + listRedisCompletionKeys: mocks.listRedisCompletionKeys, + invalidateCompletionCache: vi.fn(), + refreshRedisDbKeyCounts: vi.fn(), + }), +})); + +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + infiniteScroll: mocks.infiniteScroll, + queryResultMaxRowsEnabled: mocks.queryResultMaxRowsEnabled, + queryResultMaxRows: mocks.queryResultMaxRows, + }, + }), +})); + +vi.mock("@/composables/useEditorFontFamilyStyle", () => ({ + useEditorFontFamilyStyle: () => ({}), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + inheritAttrs: false, + props: { disabled: Boolean }, + setup(props, { attrs, slots }) { + return () => h("button", { ...attrs, disabled: props.disabled }, slots.default?.()); + }, + }), + }; +}); + +vi.mock("@/components/ui/input", async () => { + const { defineComponent, h, mergeProps } = await import("vue"); + return { + Input: defineComponent({ + inheritAttrs: false, + props: { modelValue: String, disabled: Boolean }, + emits: ["update:modelValue"], + setup(props, { attrs, emit }) { + return () => + h( + "input", + mergeProps(attrs, { + value: props.modelValue ?? "", + disabled: props.disabled, + onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), + }), + ); + }, + }), + }; +}); + +vi.mock("@/components/ui/badge", async () => { + const { defineComponent, h } = await import("vue"); + return { + Badge: defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("span", attrs, slots.default?.()), + }), + }; +}); + +vi.mock("@/components/ui/dialog", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { + Dialog: defineComponent({ + props: { open: Boolean }, + setup(props, { slots }) { + return () => (props.open ? h("div", { "data-test-dialog": "" }, slots.default?.()) : null); + }, + }), + DialogContent: slotContainer, + DialogFooter: slotContainer, + DialogHeader: slotContainer, + DialogTitle: slotContainer, + }; +}); + +vi.mock("@/components/ui/select", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { + Select: slotContainer, + SelectContent: slotContainer, + SelectItem: defineComponent({ + props: { value: String }, + setup(props, { slots }) { + return () => h("button", { type: "button", "data-test-select-value": props.value }, slots.default?.()); + }, + }), + SelectTrigger: slotContainer, + SelectValue: slotContainer, + }; +}); + +vi.mock("@/components/ui/option-help-panel", async () => { + const { defineComponent, h } = await import("vue"); + return { OptionHelpPanel: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("@/components/ui/tabs", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Tabs: slotContainer, TabsContent: slotContainer, TabsList: slotContainer, TabsTrigger: slotContainer }; +}); + +vi.mock("@/components/ui/switch", async () => { + const { defineComponent, h } = await import("vue"); + return { Switch: defineComponent({ setup: () => () => h("button", { type: "button" }) }) }; +}); + +vi.mock("@/components/ui/date-time-picker/DateTimePicker.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("@/components/ui/CustomContextMenu.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + setup(_, { slots }) { + return () => h("div", slots.default?.({ onContextMenu: () => undefined })); + }, + }), + }; +}); + +vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ props: { open: Boolean }, setup: () => () => h("div") }) }; +}); + +vi.mock("./RedisValueViewer.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("./RedisPubSubPanel.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("./RedisSlowlogPanel.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("vue-virtual-scroller", async () => { + const { defineComponent, h } = await import("vue"); + return { + RecycleScroller: defineComponent({ + inheritAttrs: false, + props: { items: { type: Array, default: () => [] } }, + setup(props, { attrs, slots }) { + return () => + h( + "div", + attrs, + props.items.slice(0, 20).map((item) => slots.default?.({ item })), + ); + }, + }), + }; +}); + +vi.mock("splitpanes", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Splitpanes: slotContainer, Pane: slotContainer }; +}); + +import RedisKeyBrowser from "./RedisKeyBrowser.vue"; + +const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = []; + +async function settle() { + for (let index = 0; index < 4; index++) { + await nextTick(); + await Promise.resolve(); + } +} + +function mountBrowser(stateKey?: string) { + const host = document.createElement("div"); + document.body.append(host); + const app = createApp(RedisKeyBrowser, { + connectionId: "connection", + db: 0, + blockDangerousRedisCommands: false, + stateKey, + }); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + mountedApps.push({ unmount: () => app.unmount(), host }); + return host; +} + +function unmountBrowser(host: HTMLElement) { + const index = mountedApps.findIndex((mounted) => mounted.host === host); + expect(index).toBeGreaterThanOrEqual(0); + const [mounted] = mountedApps.splice(index, 1); + mounted!.unmount(); + host.remove(); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 }); + clearRedisKeyBrowserState("tab-redis-filter"); + clearRedisKeyBrowserState("tab-a"); + clearRedisKeyBrowserState("tab-b"); +}); + +afterEach(() => { + while (mountedApps.length > 0) { + const mounted = mountedApps.pop()!; + mounted.unmount(); + mounted.host.remove(); + } +}); + +describe("RedisKeyBrowser tab state (tab switch persistence)", () => { + it("restores search conditions after unmount/remount", async () => { + const host = mountBrowser("tab-redis-filter"); + await settle(); + expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1); + expect(mocks.redisScanKeysBatch.mock.calls[0]![3]).toBe("*"); + + const input = host.querySelector("[data-redis-search-input]") as HTMLInputElement; + input.value = "monitor:job:*"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(); + + const afterSearchCalls = mocks.redisScanKeysBatch.mock.calls; + expect(afterSearchCalls[afterSearchCalls.length - 1]![3]).toBe("monitor:job:*"); + + // Switch away (unmount) and back (remount) — what ContentArea does per tab. + unmountBrowser(host); + await settle(); + const restoredHost = mountBrowser("tab-redis-filter"); + await settle(); + + const restoredInput = restoredHost.querySelector("[data-redis-search-input]") as HTMLInputElement; + expect(restoredInput.value).toBe("monitor:job:*"); + const restoredCalls = mocks.redisScanKeysBatch.mock.calls; + expect(restoredCalls[restoredCalls.length - 1]![3]).toBe("monitor:job:*"); + }); + + it("keeps tab states isolated from each other", async () => { + saveRedisKeyBrowserState("tab-a", { + searchPattern: "alpha:*", + searchMode: "key", + fuzzyKeySearch: false, + noExpiryOnly: false, + }); + + const hostB = mountBrowser("tab-b"); + await settle(); + expect((hostB.querySelector("[data-redis-search-input]") as HTMLInputElement).value).toBe(""); + const tabBCalls = mocks.redisScanKeysBatch.mock.calls; + expect(tabBCalls[tabBCalls.length - 1]![3]).toBe("*"); + + unmountBrowser(hostB); + const hostA = mountBrowser("tab-a"); + await settle(); + expect((hostA.querySelector("[data-redis-search-input]") as HTMLInputElement).value).toBe("alpha:*"); + const tabACalls = mocks.redisScanKeysBatch.mock.calls; + expect(tabACalls[tabACalls.length - 1]![3]).toBe("alpha:*"); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 1cbc1c5b20..a5316cd906 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -69,6 +69,7 @@ import { shouldLoadMoreRedisKeys } from "@/lib/redis/redisKeyInfiniteScroll"; import { formatTtl } from "@/lib/common/ttlFormat"; import { computeTtlCountdownValue } from "@/lib/redis/redisAutoRefresh"; import { createRedisKeyViewYield } from "@/lib/redis/redisKeyViewScheduler"; +import { restoreRedisKeyBrowserState, saveRedisKeyBrowserState } from "@/lib/tabs/redisKeyBrowserStateCache"; const { t, locale } = useI18n(); const { toast } = useToast(); @@ -121,8 +122,14 @@ const props = defineProps<{ connectionId: string; db: number; blockDangerousRedisCommands: boolean; + /** Tab id; search conditions are cached per tab and restored on remount. */ + stateKey?: string; }>(); +// This component is keyed by tab in ContentArea and unmounted on every tab +// switch; without restoring from the per-tab cache, coming back to the tab +// would silently drop the user's search pattern / mode / local filters. +const restoredRedisKeyBrowserState = props.stateKey ? restoreRedisKeyBrowserState(props.stateKey) : undefined; const redisExpiryTransport = { setTtl: api.redisSetTtl, setExpireAt: api.redisSetExpireAt, @@ -145,9 +152,9 @@ let redisKeyScrollRevision = 0; let latestRedisKeyScrollAnchor: RedisKeyViewportAnchor | null = null; const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null); const commandTerminalRef = ref(); -const searchPattern = ref(""); -const searchMode = ref("key"); -const fuzzyKeySearch = ref(false); +const searchPattern = ref(restoredRedisKeyBrowserState?.searchPattern ?? ""); +const searchMode = ref(restoredRedisKeyBrowserState?.searchMode ?? "key"); +const fuzzyKeySearch = ref(restoredRedisKeyBrowserState?.fuzzyKeySearch ?? false); const keyTemplateMenuOpen = ref(false); const keyTemplateSelectedIndex = ref(0); const keyTemplateListboxId = `redis-key-template-suggestions-${uuid()}`; @@ -312,7 +319,19 @@ watch(redisKeySeparator, () => { const lastTotalKeys = ref(0); // “仅看无过期”过滤开关:开启后只保留 TTL 为 -1(永不过期)的已加载 key。 // TTL 为 -2 的行(fetch-all 链路未查询 TTL)不会出现在过滤结果里。 -const noExpiryOnly = ref(false); +const noExpiryOnly = ref(restoredRedisKeyBrowserState?.noExpiryOnly ?? false); + +function persistRedisKeyBrowserState() { + if (!props.stateKey) return; + saveRedisKeyBrowserState(props.stateKey, { + searchPattern: searchPattern.value, + searchMode: searchMode.value, + fuzzyKeySearch: fuzzyKeySearch.value, + noExpiryOnly: noExpiryOnly.value, + }); +} + +watch([searchPattern, searchMode, fuzzyKeySearch, noExpiryOnly], persistRedisKeyBrowserState); const fetchAllFilteredKeyCount = ref(null); // 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销 const filteredFlatKeys = computed(() => { diff --git a/apps/desktop/src/lib/__tests__/tabs/redisKeyBrowserStateCache.spec.ts b/apps/desktop/src/lib/__tests__/tabs/redisKeyBrowserStateCache.spec.ts new file mode 100644 index 0000000000..c247bdb203 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/tabs/redisKeyBrowserStateCache.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { clearRedisKeyBrowserState, restoreRedisKeyBrowserState, saveRedisKeyBrowserState, type RedisKeyBrowserStateSnapshot } from "@/lib/tabs/redisKeyBrowserStateCache"; + +function snapshot(suffix: number): RedisKeyBrowserStateSnapshot { + return { + searchPattern: `user:${suffix}*`, + searchMode: suffix % 2 === 0 ? "key" : "value", + fuzzyKeySearch: suffix % 3 === 0, + noExpiryOnly: suffix % 2 === 1, + }; +} + +describe("redisKeyBrowserStateCache", () => { + it("round-trips a snapshot for the same key", () => { + saveRedisKeyBrowserState("round-trip", snapshot(3)); + + expect(restoreRedisKeyBrowserState("round-trip")).toEqual(snapshot(3)); + }); + + it("returns undefined for an unknown key", () => { + expect(restoreRedisKeyBrowserState("missing")).toBeUndefined(); + }); + + it("keeps the most recent write for a key", () => { + saveRedisKeyBrowserState("overwrite", snapshot(1)); + saveRedisKeyBrowserState("overwrite", snapshot(2)); + + expect(restoreRedisKeyBrowserState("overwrite")).toEqual(snapshot(2)); + }); + + it("evicts the oldest entry beyond 32 keys", () => { + for (let index = 0; index < 33; index += 1) { + saveRedisKeyBrowserState(`evict-${index}`, snapshot(index)); + } + + expect(restoreRedisKeyBrowserState("evict-0")).toBeUndefined(); + expect(restoreRedisKeyBrowserState("evict-1")).toBeDefined(); + expect(restoreRedisKeyBrowserState("evict-32")).toBeDefined(); + }); + + it("treats a restore as recency so a touched entry survives eviction", () => { + for (let index = 0; index < 32; index += 1) { + saveRedisKeyBrowserState(`touch-${index}`, snapshot(index)); + } + expect(restoreRedisKeyBrowserState("touch-0")).toEqual(snapshot(0)); + saveRedisKeyBrowserState("touch-new", snapshot(99)); + + expect(restoreRedisKeyBrowserState("touch-0")).toBeDefined(); + expect(restoreRedisKeyBrowserState("touch-1")).toBeUndefined(); + }); + + it("clears a key on demand", () => { + saveRedisKeyBrowserState("clear-me", snapshot(4)); + clearRedisKeyBrowserState("clear-me"); + + expect(restoreRedisKeyBrowserState("clear-me")).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/tabs/redisKeyBrowserStateCache.ts b/apps/desktop/src/lib/tabs/redisKeyBrowserStateCache.ts new file mode 100644 index 0000000000..90c548fa44 --- /dev/null +++ b/apps/desktop/src/lib/tabs/redisKeyBrowserStateCache.ts @@ -0,0 +1,43 @@ +export type RedisKeyBrowserSearchMode = "key" | "value" | "all"; + +export interface RedisKeyBrowserStateSnapshot { + /** Key / value / all search input text. */ + searchPattern: string; + /** Which Redis search mode the toolbar is in. */ + searchMode: RedisKeyBrowserSearchMode; + /** Fuzzy MATCH wrapping for key-mode searches. */ + fuzzyKeySearch: boolean; + /** Local filter: only show keys with TTL = -1 in the loaded result set. */ + noExpiryOnly: boolean; +} + +// ContentArea renders only the active tab, so RedisKeyBrowser is unmounted on +// every tab switch and remounted when the user comes back. Search conditions +// survive that round trip through this session-scoped cache keyed by tab id — +// the same role documentBrowserStateCache plays for Mongo/ES collection tabs. +// The bound keeps state for long tab sessions from accumulating forever. +const MAX_ENTRIES = 32; +const cache = new Map(); + +export function restoreRedisKeyBrowserState(stateKey: string): RedisKeyBrowserStateSnapshot | undefined { + const snapshot = cache.get(stateKey); + if (!snapshot) return undefined; + // Refresh LRU recency. + cache.delete(stateKey); + cache.set(stateKey, snapshot); + return snapshot; +} + +export function saveRedisKeyBrowserState(stateKey: string, snapshot: RedisKeyBrowserStateSnapshot): void { + cache.delete(stateKey); + cache.set(stateKey, snapshot); + while (cache.size > MAX_ENTRIES) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } +} + +export function clearRedisKeyBrowserState(stateKey: string): void { + cache.delete(stateKey); +}