fix(redis): keep key browser filters across tab switches

This commit is contained in:
一颗红心 authored and GitHub committed 2026-09-10 11:23:57 +08:00
1 parent b2d2507aec
commit dc1b651949
5 files changed
+486 -5

No files matched your search

@@ -2346,7 +2346,7 @@ defineExpose({
<!-- Redis mode: key browser -->
<template v-else-if="activeTab.mode === 'redis'">
<div class="flex-1 min-h-0">
<RedisKeyBrowser ref="redisKeyBrowserRef" :key="`${activeTab.id}:${activeTab.connectionId}:${activeTab.database}`" :connection-id="activeTab.connectionId" :db="Number(activeTab.database)" :block-dangerous-redis-commands="props.blockDangerousRedisCommands" />
<RedisKeyBrowser ref="redisKeyBrowserRef" :key="`${activeTab.id}:${activeTab.connectionId}:${activeTab.database}`" :connection-id="activeTab.connectionId" :db="Number(activeTab.database)" :block-dangerous-redis-commands="props.blockDangerousRedisCommands" :state-key="activeTab.id" />
</div>
</template>
@@ -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:*");
});
});
@@ -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<HTMLElement>();
const searchPattern = ref("");
const searchMode = ref<RedisSearchMode>("key");
const fuzzyKeySearch = ref(false);
const searchPattern = ref(restoredRedisKeyBrowserState?.searchPattern ?? "");
const searchMode = ref<RedisSearchMode>(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<number | null>(null);
// 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销
const filteredFlatKeys = computed(() => {
@@ -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();
});
});
@@ -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<string, RedisKeyBrowserStateSnapshot>();
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);
}