fix(redis): keep key browser filters across tab switches
This commit is contained in:
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(() => {
|
||||
|
||||
Reference in new issue
Block a user