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);
+}