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 -->
|
<!-- Redis mode: key browser -->
|
||||||
<template v-else-if="activeTab.mode === 'redis'">
|
<template v-else-if="activeTab.mode === 'redis'">
|
||||||
<div class="flex-1 min-h-0">
|
<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>
|
</div>
|
||||||
</template>
|
</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 { formatTtl } from "@/lib/common/ttlFormat";
|
||||||
import { computeTtlCountdownValue } from "@/lib/redis/redisAutoRefresh";
|
import { computeTtlCountdownValue } from "@/lib/redis/redisAutoRefresh";
|
||||||
import { createRedisKeyViewYield } from "@/lib/redis/redisKeyViewScheduler";
|
import { createRedisKeyViewYield } from "@/lib/redis/redisKeyViewScheduler";
|
||||||
|
import { restoreRedisKeyBrowserState, saveRedisKeyBrowserState } from "@/lib/tabs/redisKeyBrowserStateCache";
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -121,8 +122,14 @@ const props = defineProps<{
|
|||||||
connectionId: string;
|
connectionId: string;
|
||||||
db: number;
|
db: number;
|
||||||
blockDangerousRedisCommands: boolean;
|
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 = {
|
const redisExpiryTransport = {
|
||||||
setTtl: api.redisSetTtl,
|
setTtl: api.redisSetTtl,
|
||||||
setExpireAt: api.redisSetExpireAt,
|
setExpireAt: api.redisSetExpireAt,
|
||||||
@@ -145,9 +152,9 @@ let redisKeyScrollRevision = 0;
|
|||||||
let latestRedisKeyScrollAnchor: RedisKeyViewportAnchor | null = null;
|
let latestRedisKeyScrollAnchor: RedisKeyViewportAnchor | null = null;
|
||||||
const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null);
|
const valueViewerRef = ref<{ focusSearch: () => boolean } | null>(null);
|
||||||
const commandTerminalRef = ref<HTMLElement>();
|
const commandTerminalRef = ref<HTMLElement>();
|
||||||
const searchPattern = ref("");
|
const searchPattern = ref(restoredRedisKeyBrowserState?.searchPattern ?? "");
|
||||||
const searchMode = ref<RedisSearchMode>("key");
|
const searchMode = ref<RedisSearchMode>(restoredRedisKeyBrowserState?.searchMode ?? "key");
|
||||||
const fuzzyKeySearch = ref(false);
|
const fuzzyKeySearch = ref(restoredRedisKeyBrowserState?.fuzzyKeySearch ?? false);
|
||||||
const keyTemplateMenuOpen = ref(false);
|
const keyTemplateMenuOpen = ref(false);
|
||||||
const keyTemplateSelectedIndex = ref(0);
|
const keyTemplateSelectedIndex = ref(0);
|
||||||
const keyTemplateListboxId = `redis-key-template-suggestions-${uuid()}`;
|
const keyTemplateListboxId = `redis-key-template-suggestions-${uuid()}`;
|
||||||
@@ -312,7 +319,19 @@ watch(redisKeySeparator, () => {
|
|||||||
const lastTotalKeys = ref(0);
|
const lastTotalKeys = ref(0);
|
||||||
// “仅看无过期”过滤开关:开启后只保留 TTL 为 -1(永不过期)的已加载 key。
|
// “仅看无过期”过滤开关:开启后只保留 TTL 为 -1(永不过期)的已加载 key。
|
||||||
// TTL 为 -2 的行(fetch-all 链路未查询 TTL)不会出现在过滤结果里。
|
// 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);
|
const fetchAllFilteredKeyCount = ref<number | null>(null);
|
||||||
// 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销
|
// 过滤后的平铺 key 列表:未开启过滤时与 flatKeys 完全一致,避免额外开销
|
||||||
const filteredFlatKeys = computed(() => {
|
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user