mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(redis): add key search history
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { forgetRedisKeySearchHistory, loadRedisKeySearchHistory, rememberRedisKeySearchHistory } from "@/lib/redis/redisKeySearchHistory";
|
||||
|
||||
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, redis_key_templates: ["user:{$id}", "session:{$token}"] }),
|
||||
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(overrides?: { connectionId?: string; db?: number; stateKey?: string }) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(RedisKeyBrowser, {
|
||||
connectionId: overrides?.connectionId ?? "connection",
|
||||
db: overrides?.db ?? 0,
|
||||
blockDangerousRedisCommands: false,
|
||||
stateKey: overrides?.stateKey,
|
||||
});
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: {
|
||||
en: {
|
||||
redis: {
|
||||
keySearchHistory: "Search history",
|
||||
keySearchHistoryForget: "Remove from history",
|
||||
keySearchHistoryEmpty: "No history yet",
|
||||
keyTemplateSuggestions: "Key templates",
|
||||
},
|
||||
},
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
}),
|
||||
);
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
return host;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedApps.length > 0) {
|
||||
const mounted = mountedApps.pop()!;
|
||||
mounted.unmount();
|
||||
mounted.host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
async function submitSearch(host: HTMLElement, pattern: string) {
|
||||
const input = host.querySelector("[data-redis-search-input]") as HTMLInputElement;
|
||||
input.value = pattern;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await settle();
|
||||
}
|
||||
|
||||
describe("RedisKeyBrowser search history", () => {
|
||||
it("remembers a submitted pattern and fills the input when selected from history", async () => {
|
||||
const host = mountBrowser();
|
||||
await settle();
|
||||
|
||||
await submitSearch(host, "monitor:job:*");
|
||||
expect(loadRedisKeySearchHistory({ connectionId: "connection", db: 0 })).toEqual(["monitor:job:*"]);
|
||||
|
||||
const input = host.querySelector("[data-redis-search-input]") as HTMLInputElement;
|
||||
input.value = "";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
|
||||
(host.querySelector("[data-redis-search-history-toggle]") as HTMLButtonElement).click();
|
||||
await settle();
|
||||
|
||||
expect(host.querySelector("[data-redis-search-history-list]")).not.toBeNull();
|
||||
const item = host.querySelector("[data-redis-search-history-item]") as HTMLElement;
|
||||
expect(item.textContent).toContain("monitor:job:*");
|
||||
item.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
await settle();
|
||||
|
||||
expect((host.querySelector("[data-redis-search-input]") as HTMLInputElement).value).toBe("monitor:job:*");
|
||||
expect(host.querySelector("[data-redis-search-history-list]")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps earlier patterns after switching to another key search like queue_update then insert", async () => {
|
||||
const host = mountBrowser();
|
||||
await settle();
|
||||
|
||||
await submitSearch(host, "queue_update");
|
||||
await submitSearch(host, "insert");
|
||||
|
||||
expect(loadRedisKeySearchHistory({ connectionId: "connection", db: 0 })).toEqual(["insert", "queue_update"]);
|
||||
|
||||
// Input still holds the latest search; dropdown must still list earlier keys.
|
||||
(host.querySelector("[data-redis-search-history-toggle]") as HTMLButtonElement).click();
|
||||
await settle();
|
||||
|
||||
const text = host.querySelector("[data-redis-search-history-list]")?.textContent ?? "";
|
||||
expect(text).toContain("insert");
|
||||
expect(text).toContain("queue_update");
|
||||
});
|
||||
|
||||
it("forgets an individual history entry from the dropdown", async () => {
|
||||
rememberRedisKeySearchHistory({ connectionId: "connection", db: 0 }, "cache:*");
|
||||
rememberRedisKeySearchHistory({ connectionId: "connection", db: 0 }, "user:*");
|
||||
|
||||
const host = mountBrowser();
|
||||
await settle();
|
||||
|
||||
(host.querySelector("[data-redis-search-history-toggle]") as HTMLButtonElement).click();
|
||||
await settle();
|
||||
|
||||
const items = [...host.querySelectorAll("[data-redis-search-history-item]")];
|
||||
const forgetUser = items.find((el) => el.textContent?.includes("user:*"))!.querySelector("[data-redis-search-history-forget]") as HTMLButtonElement;
|
||||
forgetUser.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||
await settle();
|
||||
|
||||
expect(loadRedisKeySearchHistory({ connectionId: "connection", db: 0 })).toEqual(["cache:*"]);
|
||||
expect(host.querySelector("[data-redis-search-history-list]")?.textContent).toContain("cache:*");
|
||||
expect(host.querySelector("[data-redis-search-history-list]")?.textContent).not.toContain("user:*");
|
||||
});
|
||||
|
||||
it("does not mix history across connectionId+db scopes", async () => {
|
||||
rememberRedisKeySearchHistory({ connectionId: "connection", db: 0 }, "db0:*");
|
||||
rememberRedisKeySearchHistory({ connectionId: "connection", db: 1 }, "db1:*");
|
||||
rememberRedisKeySearchHistory({ connectionId: "other", db: 0 }, "other:*");
|
||||
|
||||
const host = mountBrowser({ connectionId: "connection", db: 0 });
|
||||
await settle();
|
||||
(host.querySelector("[data-redis-search-history-toggle]") as HTMLButtonElement).click();
|
||||
await settle();
|
||||
|
||||
const text = host.querySelector("[data-redis-search-history-list]")?.textContent ?? "";
|
||||
expect(text).toContain("db0:*");
|
||||
expect(text).not.toContain("db1:*");
|
||||
expect(text).not.toContain("other:*");
|
||||
});
|
||||
|
||||
it("keeps history and key-template menus mutually exclusive", async () => {
|
||||
rememberRedisKeySearchHistory({ connectionId: "connection", db: 0 }, "legacy:*");
|
||||
|
||||
const host = mountBrowser();
|
||||
await settle();
|
||||
|
||||
const input = host.querySelector("[data-redis-search-input]") as HTMLInputElement;
|
||||
input.dispatchEvent(new FocusEvent("focus", { bubbles: true }));
|
||||
await settle();
|
||||
expect(host.querySelector('[role="listbox"][aria-label="Key templates"]')).not.toBeNull();
|
||||
expect(host.querySelector("[data-redis-search-history-list]")).toBeNull();
|
||||
|
||||
(host.querySelector("[data-redis-search-history-toggle]") as HTMLButtonElement).click();
|
||||
await settle();
|
||||
expect(host.querySelector("[data-redis-search-history-list]")).not.toBeNull();
|
||||
expect(host.querySelector('[role="listbox"][aria-label="Key templates"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not remember empty search submissions", async () => {
|
||||
const host = mountBrowser();
|
||||
await settle();
|
||||
|
||||
const input = host.querySelector("[data-redis-search-input]") as HTMLInputElement;
|
||||
input.value = " ";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await settle();
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await settle();
|
||||
|
||||
expect(loadRedisKeySearchHistory({ connectionId: "connection", db: 0 })).toEqual([]);
|
||||
expect(forgetRedisKeySearchHistory({ connectionId: "connection", db: 0 }, "noop")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, markRaw, nextTick, ref, shallowRef, onMounted, onUnmounted, onActivated, onDeactivated, watch } from "vue";
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio, Clock, Copy } from "@lucide/vue";
|
||||
import { Search, RefreshCw, Loader2, ChevronRight, ChevronDown, FolderClosed, FolderOpen, Trash2, Plus, KeyRound, TerminalSquare, Asterisk, History, Radio, Clock, Copy, X } from "@lucide/vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
@@ -60,6 +60,7 @@ import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { redisFuzzySearchScanBudget, redisKeySearchPattern, redisGroupSubtreePattern } from "@/lib/redis/redisKeyPattern";
|
||||
import { filterRedisKeyTemplates, resolveRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
|
||||
import { forgetRedisKeySearchHistory, loadRedisKeySearchHistory, rememberRedisKeySearchHistory, type RedisKeySearchHistoryScope } from "@/lib/redis/redisKeySearchHistory";
|
||||
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
|
||||
import { chunkRedisKeyRaws, collectUniqueRedisKeys } from "@/lib/redis/redisKeyBatch";
|
||||
import { getRedisCreateKeyTypeHelp, redisCreateKeyTypeHelpOptionOnOpen, shouldActivateRedisCreateKeyTypeHelpOnFocus } from "@/lib/redis/redisCreateKeyTypeHelp";
|
||||
@@ -158,6 +159,10 @@ const fuzzyKeySearch = ref(restoredRedisKeyBrowserState?.fuzzyKeySearch ?? false
|
||||
const keyTemplateMenuOpen = ref(false);
|
||||
const keyTemplateSelectedIndex = ref(0);
|
||||
const keyTemplateListboxId = `redis-key-template-suggestions-${uuid()}`;
|
||||
const searchHistoryMenuOpen = ref(false);
|
||||
const searchHistoryItems = ref<string[]>([]);
|
||||
const searchHistorySelectedIndex = ref(-1);
|
||||
const searchHistoryListboxId = `redis-key-search-history-${uuid()}`;
|
||||
let keyTemplateBlurTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const selectedKeyRaw = ref<string | null>(null);
|
||||
const hasMore = ref(false);
|
||||
@@ -298,8 +303,22 @@ const searchPlaceholder = computed(() => {
|
||||
});
|
||||
const redisKeyTemplates = computed(() => resolveRedisKeyTemplates(connectionStore.getConfig(props.connectionId)?.redis_key_templates, settingsStore.editorSettings.redisKeyTemplates ?? []));
|
||||
const keyTemplateSuggestions = computed(() => (searchMode.value === "key" ? filterRedisKeyTemplates(redisKeyTemplates.value, searchPattern.value) : []));
|
||||
const keyTemplateMenuVisible = computed(() => keyTemplateMenuOpen.value && searchMode.value === "key" && keyTemplateSuggestions.value.length > 0);
|
||||
const keyTemplateMenuVisible = computed(() => keyTemplateMenuOpen.value && !searchHistoryMenuOpen.value && searchMode.value === "key" && keyTemplateSuggestions.value.length > 0);
|
||||
const keyTemplateActiveDescendant = computed(() => (keyTemplateMenuVisible.value ? `${keyTemplateListboxId}-option-${keyTemplateSelectedIndex.value}` : undefined));
|
||||
const searchHistoryScope = computed<RedisKeySearchHistoryScope>(() => ({
|
||||
connectionId: props.connectionId,
|
||||
db: props.db,
|
||||
}));
|
||||
const searchHistoryMenuVisible = computed(() => searchHistoryMenuOpen.value);
|
||||
const searchHistoryEmptyText = computed(() => t("redis.keySearchHistoryEmpty"));
|
||||
const searchHistoryActiveDescendant = computed(() => (searchHistoryMenuVisible.value && searchHistorySelectedIndex.value >= 0 ? `${searchHistoryListboxId}-option-${searchHistorySelectedIndex.value}` : undefined));
|
||||
const searchComboboxExpanded = computed(() => keyTemplateMenuVisible.value || searchHistoryMenuVisible.value);
|
||||
const searchComboboxControls = computed(() => {
|
||||
if (searchHistoryMenuVisible.value) return searchHistoryListboxId;
|
||||
if (keyTemplateMenuVisible.value) return keyTemplateListboxId;
|
||||
return undefined;
|
||||
});
|
||||
const searchComboboxActiveDescendant = computed(() => searchHistoryActiveDescendant.value ?? keyTemplateActiveDescendant.value);
|
||||
const loadingEmptyText = computed(() => (isValueSearchMode.value && valueQuery.value ? t(searchMode.value === "all" ? "redis.searchingAll" : "redis.searchingValues") : t("redis.loadingKeys")));
|
||||
const redisKeySeparator = computed(() => connectionStore.getConfig(props.connectionId)?.redis_key_separator ?? ":");
|
||||
const redisScanPageSize = computed(() => connectionStore.getConfig(props.connectionId)?.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT);
|
||||
@@ -990,6 +1009,7 @@ async function loadKeys() {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = null;
|
||||
searchPending.value = false;
|
||||
rememberRedisKeySearchHistory(searchHistoryScope.value, searchPattern.value);
|
||||
const requestId = invalidateScanRequests();
|
||||
isFetchingAll.value = false;
|
||||
fetchAllStopRequested.value = false;
|
||||
@@ -2262,11 +2282,77 @@ function dismissKeyTemplateMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
function dismissSearchHistoryMenu() {
|
||||
searchHistoryMenuOpen.value = false;
|
||||
searchHistoryItems.value = [];
|
||||
searchHistorySelectedIndex.value = -1;
|
||||
}
|
||||
|
||||
function refreshSearchHistoryItems() {
|
||||
// ChevronDown shows the full connection+db history so earlier patterns stay
|
||||
// visible after the user switches to another search (or key/value mode).
|
||||
searchHistoryItems.value = loadRedisKeySearchHistory(searchHistoryScope.value);
|
||||
if (searchHistorySelectedIndex.value >= searchHistoryItems.value.length) {
|
||||
searchHistorySelectedIndex.value = searchHistoryItems.value.length > 0 ? searchHistoryItems.value.length - 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
function openSearchHistoryMenu() {
|
||||
dismissKeyTemplateMenu();
|
||||
refreshSearchHistoryItems();
|
||||
searchHistoryMenuOpen.value = true;
|
||||
searchHistorySelectedIndex.value = -1;
|
||||
}
|
||||
|
||||
function toggleSearchHistoryMenu() {
|
||||
if (searchHistoryMenuOpen.value) {
|
||||
dismissSearchHistoryMenu();
|
||||
return;
|
||||
}
|
||||
openSearchHistoryMenu();
|
||||
}
|
||||
|
||||
function selectSearchHistory(index: number) {
|
||||
const entry = searchHistoryItems.value[index];
|
||||
if (!entry) return;
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = null;
|
||||
}
|
||||
searchPending.value = false;
|
||||
searchPattern.value = entry;
|
||||
dismissSearchHistoryMenu();
|
||||
dismissKeyTemplateMenu();
|
||||
void nextTick(() => getSearchInput()?.focus());
|
||||
}
|
||||
|
||||
function forgetSearchHistory(value: string) {
|
||||
forgetRedisKeySearchHistory(searchHistoryScope.value, value);
|
||||
refreshSearchHistoryItems();
|
||||
searchHistoryMenuOpen.value = true;
|
||||
void nextTick(() => getSearchInput()?.focus());
|
||||
}
|
||||
|
||||
function moveSearchHistorySelection(direction: number): boolean {
|
||||
if (!searchHistoryMenuVisible.value || searchHistoryItems.value.length === 0) return false;
|
||||
const count = searchHistoryItems.value.length;
|
||||
if (searchHistorySelectedIndex.value < 0) {
|
||||
searchHistorySelectedIndex.value = direction > 0 ? 0 : count - 1;
|
||||
} else {
|
||||
searchHistorySelectedIndex.value = Math.min(Math.max(searchHistorySelectedIndex.value + direction, 0), count - 1);
|
||||
}
|
||||
void nextTick(() => {
|
||||
document.getElementById(`${searchHistoryListboxId}-option-${searchHistorySelectedIndex.value}`)?.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function openKeyTemplateMenu() {
|
||||
if (searchMode.value !== "key" || redisKeyTemplates.value.length === 0) {
|
||||
dismissKeyTemplateMenu();
|
||||
return;
|
||||
}
|
||||
dismissSearchHistoryMenu();
|
||||
keyTemplateMenuOpen.value = true;
|
||||
keyTemplateSelectedIndex.value = 0;
|
||||
}
|
||||
@@ -2308,11 +2394,13 @@ function onSearchBlur() {
|
||||
if (keyTemplateBlurTimer) clearTimeout(keyTemplateBlurTimer);
|
||||
keyTemplateBlurTimer = setTimeout(() => {
|
||||
dismissKeyTemplateMenu();
|
||||
dismissSearchHistoryMenu();
|
||||
keyTemplateBlurTimer = null;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
dismissSearchHistoryMenu();
|
||||
if (searchMode.value === "key" && redisKeyTemplates.value.length > 0) {
|
||||
keyTemplateMenuOpen.value = true;
|
||||
keyTemplateSelectedIndex.value = 0;
|
||||
@@ -2357,6 +2445,7 @@ function onSearchInput() {
|
||||
function setSearchMode(mode: RedisSearchMode) {
|
||||
if (searchMode.value === mode) return;
|
||||
searchMode.value = mode;
|
||||
dismissSearchHistoryMenu();
|
||||
if (mode !== "key") dismissKeyTemplateMenu();
|
||||
void loadKeys();
|
||||
}
|
||||
@@ -2544,6 +2633,35 @@ function focusSearch(): boolean {
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.isComposing) return;
|
||||
|
||||
if (searchHistoryMenuVisible.value) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveSearchHistorySelection(1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveSearchHistorySelection(-1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (searchHistorySelectedIndex.value >= 0) {
|
||||
selectSearchHistory(searchHistorySelectedIndex.value);
|
||||
} else {
|
||||
dismissSearchHistoryMenu();
|
||||
void loadKeys();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
dismissSearchHistoryMenu();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (keyTemplateMenuVisible.value) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
@@ -2574,6 +2692,7 @@ function onSearchKeydown(event: KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
searchPattern.value = "";
|
||||
dismissKeyTemplateMenu();
|
||||
dismissSearchHistoryMenu();
|
||||
void loadKeys();
|
||||
}
|
||||
|
||||
@@ -2942,17 +3061,56 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
|
||||
v-model="searchPattern"
|
||||
data-redis-search-input
|
||||
role="combobox"
|
||||
class="h-8 border-border/70 bg-background pl-8 pr-3 text-xs shadow-sm caret-primary placeholder:text-muted-foreground/80 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-primary/20"
|
||||
class="h-8 border-border/70 bg-background pl-8 pr-8 text-xs shadow-sm caret-primary placeholder:text-muted-foreground/80 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-primary/20"
|
||||
:placeholder="searchPlaceholder"
|
||||
:aria-expanded="keyTemplateMenuVisible"
|
||||
:aria-controls="keyTemplateMenuVisible ? keyTemplateListboxId : undefined"
|
||||
:aria-activedescendant="keyTemplateActiveDescendant"
|
||||
:aria-expanded="searchComboboxExpanded"
|
||||
:aria-controls="searchComboboxControls"
|
||||
:aria-activedescendant="searchComboboxActiveDescendant"
|
||||
autocomplete="off"
|
||||
@input="onSearchInput"
|
||||
@keydown="onSearchKeydown"
|
||||
@focus="onSearchFocus"
|
||||
@blur="onSearchBlur"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-redis-search-history-toggle
|
||||
class="absolute right-1.5 top-1/2 z-[1] -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
|
||||
:title="t('redis.keySearchHistory')"
|
||||
:aria-label="t('redis.keySearchHistory')"
|
||||
:aria-expanded="searchHistoryMenuVisible"
|
||||
:aria-controls="searchHistoryMenuVisible ? searchHistoryListboxId : undefined"
|
||||
@mousedown.prevent
|
||||
@click="toggleSearchHistoryMenu"
|
||||
>
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
<div
|
||||
v-if="searchHistoryMenuVisible"
|
||||
:id="searchHistoryListboxId"
|
||||
role="listbox"
|
||||
data-redis-search-history-list
|
||||
:aria-label="t('redis.keySearchHistory')"
|
||||
class="absolute left-0 right-0 top-[calc(100%+0.25rem)] z-30 max-h-60 overflow-y-auto rounded-md border bg-popover py-1 text-popover-foreground shadow-md"
|
||||
>
|
||||
<div
|
||||
v-for="(entry, index) in searchHistoryItems"
|
||||
:id="`${searchHistoryListboxId}-option-${index}`"
|
||||
:key="entry"
|
||||
role="option"
|
||||
data-redis-search-history-item
|
||||
class="dbx-editor-font-family flex w-full cursor-pointer items-center px-3 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
:class="searchHistorySelectedIndex === index ? 'bg-accent text-accent-foreground' : ''"
|
||||
:aria-selected="searchHistorySelectedIndex === index"
|
||||
@mousedown.prevent="selectSearchHistory(index)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate" :title="entry">{{ entry }}</span>
|
||||
<button type="button" data-redis-search-history-forget class="ml-2 shrink-0 text-muted-foreground hover:text-foreground" :aria-label="t('redis.keySearchHistoryForget')" @mousedown.stop.prevent="forgetSearchHistory(entry)">
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchHistoryItems.length === 0" class="px-3 py-2 text-xs text-muted-foreground">{{ searchHistoryEmptyText }}</div>
|
||||
</div>
|
||||
<div v-if="keyTemplateMenuVisible" :id="keyTemplateListboxId" role="listbox" :aria-label="t('redis.keyTemplateSuggestions')" class="absolute left-0 right-0 top-[calc(100%+0.25rem)] z-30 max-h-60 overflow-y-auto rounded-md border bg-popover py-1 text-popover-foreground shadow-md">
|
||||
<button
|
||||
v-for="(template, index) in keyTemplateSuggestions"
|
||||
|
||||
@@ -5035,6 +5035,9 @@ export default withEnglishFallback({
|
||||
pattern: "nümunə (məsələn, user:*)",
|
||||
fuzzyPattern: "açarda bu mətn var...",
|
||||
keyTemplateSuggestions: "Açar şablonları",
|
||||
keySearchHistory: "Axtarış tarixçəsi",
|
||||
keySearchHistoryForget: "Tarixçədən sil",
|
||||
keySearchHistoryEmpty: "Hələ tarixçə yoxdur",
|
||||
fuzzyMatch: "Qeyri-dəqiq",
|
||||
fuzzyMatchTitle: "Qeyri-dəqiq uyğunluq: daxilində adi mətn olan açarları axtar",
|
||||
valueSearchPlaceholder: "dəyərdə bu mətn var...",
|
||||
|
||||
@@ -5033,6 +5033,9 @@ export default {
|
||||
pattern: "pattern (e.g. user:*)",
|
||||
fuzzyPattern: "key contains...",
|
||||
keyTemplateSuggestions: "Key templates",
|
||||
keySearchHistory: "Search history",
|
||||
keySearchHistoryForget: "Remove from history",
|
||||
keySearchHistoryEmpty: "No history yet",
|
||||
fuzzyMatch: "Fuzzy",
|
||||
fuzzyMatchTitle: "Fuzzy match: search keys by plain text contains",
|
||||
valueSearchPlaceholder: "value contains...",
|
||||
|
||||
@@ -4993,6 +4993,9 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
field: "campo",
|
||||
keySearchHistory: "Historial de búsqueda",
|
||||
keySearchHistoryForget: "Eliminar del historial",
|
||||
keySearchHistoryEmpty: "No hay historial",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documentos",
|
||||
|
||||
@@ -4991,6 +4991,9 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
field: "campo",
|
||||
keySearchHistory: "Cronologia ricerche",
|
||||
keySearchHistoryForget: "Rimuovi dalla cronologia",
|
||||
keySearchHistoryEmpty: "Nessuna cronologia",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documenti",
|
||||
|
||||
@@ -5020,6 +5020,9 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
field: "フィールド",
|
||||
keySearchHistory: "検索履歴",
|
||||
keySearchHistoryForget: "履歴から削除",
|
||||
keySearchHistoryEmpty: "履歴はありません",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count}ドキュメント",
|
||||
|
||||
@@ -4624,6 +4624,9 @@ export default withEnglishFallback({
|
||||
},
|
||||
monitorStop: "모니터링 중지",
|
||||
monitorListening: "MONITOR 모니터링 중 · 최근 1000개 보관 · 너무 긴 레코드는 잘림 표시",
|
||||
keySearchHistory: "검색 기록",
|
||||
keySearchHistoryForget: "기록에서 제거",
|
||||
keySearchHistoryEmpty: "검색 기록이 없습니다",
|
||||
},
|
||||
mongo: {
|
||||
documents: "문서 {count}개",
|
||||
|
||||
@@ -4993,6 +4993,9 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
field: "campo",
|
||||
keySearchHistory: "Histórico de pesquisa",
|
||||
keySearchHistoryForget: "Remover do histórico",
|
||||
keySearchHistoryEmpty: "Nenhum histórico",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} documentos",
|
||||
|
||||
@@ -4929,6 +4929,9 @@ export default withEnglishFallback({
|
||||
pattern: "desen (örn. user:*)",
|
||||
fuzzyPattern: "anahtar şunu içerir...",
|
||||
keyTemplateSuggestions: "Anahtar şablonları",
|
||||
keySearchHistory: "Arama geçmişi",
|
||||
keySearchHistoryForget: "Geçmişten kaldır",
|
||||
keySearchHistoryEmpty: "Henüz geçmiş yok",
|
||||
fuzzyMatch: "Bulanık",
|
||||
fuzzyMatchTitle: "Bulanık eşleşme: anahtarları düz metin içeriğine göre ara",
|
||||
valueSearchPlaceholder: "değer şunu içerir...",
|
||||
|
||||
@@ -5017,6 +5017,9 @@ export default withEnglishFallback({
|
||||
pattern: "匹配模式 (如 user:*)",
|
||||
fuzzyPattern: "输入关键字搜索 key",
|
||||
keyTemplateSuggestions: "Key 模板",
|
||||
keySearchHistory: "搜索历史",
|
||||
keySearchHistoryForget: "从历史中移除",
|
||||
keySearchHistoryEmpty: "暂无历史记录",
|
||||
fuzzyMatch: "模糊",
|
||||
fuzzyMatchTitle: "模糊匹配:自动按包含关系搜索 key",
|
||||
valueSearchPlaceholder: "按值内容搜索...",
|
||||
|
||||
@@ -4313,6 +4313,9 @@ export default withEnglishFallback({
|
||||
keyspace: "鍵空間",
|
||||
},
|
||||
},
|
||||
keySearchHistory: "搜尋紀錄",
|
||||
keySearchHistoryForget: "從紀錄中移除",
|
||||
keySearchHistoryEmpty: "暫無歷史紀錄",
|
||||
},
|
||||
mongo: {
|
||||
documents: "{count} 個文件",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
||||
const STORAGE_KEY = "dbx-redis-key-search-history";
|
||||
const MAX_HISTORY_PER_SCOPE = 20;
|
||||
|
||||
export interface RedisKeySearchHistoryScope {
|
||||
connectionId?: string;
|
||||
db?: number;
|
||||
}
|
||||
|
||||
interface StoredKeySearchHistory {
|
||||
version: 1;
|
||||
scopes: Record<string, string[]>;
|
||||
}
|
||||
|
||||
function normalizeSearchInput(value: string | undefined): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
export function redisKeySearchHistoryScopeKey(scope: RedisKeySearchHistoryScope): string {
|
||||
return [scope.connectionId ?? "", String(scope.db ?? "")].join("\u0001");
|
||||
}
|
||||
|
||||
function emptyHistory(): StoredKeySearchHistory {
|
||||
return { version: 1, scopes: {} };
|
||||
}
|
||||
|
||||
function readHistory(): StoredKeySearchHistory {
|
||||
const raw = safeLocalStorageGet(STORAGE_KEY);
|
||||
if (!raw) return emptyHistory();
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<StoredKeySearchHistory>;
|
||||
if (parsed.version !== 1 || !parsed.scopes || typeof parsed.scopes !== "object") return emptyHistory();
|
||||
return { version: 1, scopes: parsed.scopes };
|
||||
} catch {
|
||||
return emptyHistory();
|
||||
}
|
||||
}
|
||||
|
||||
function writeHistory(history: StoredKeySearchHistory) {
|
||||
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(history));
|
||||
}
|
||||
|
||||
function mergeUniqueNewestFirst(entries: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const normalized = normalizeSearchInput(entry);
|
||||
if (!normalized) continue;
|
||||
const key = normalized.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(normalized);
|
||||
if (merged.length >= MAX_HISTORY_PER_SCOPE) break;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Resolve entries for a connection+db scope. */
|
||||
function scopeEntries(history: StoredKeySearchHistory, scope: RedisKeySearchHistoryScope): string[] {
|
||||
return history.scopes[redisKeySearchHistoryScopeKey(scope)] ?? [];
|
||||
}
|
||||
|
||||
export function loadRedisKeySearchHistory(scope: RedisKeySearchHistoryScope, query = ""): string[] {
|
||||
const normalizedQuery = normalizeSearchInput(query).toLowerCase();
|
||||
const entries = scopeEntries(readHistory(), scope);
|
||||
if (!normalizedQuery) return entries.slice(0, MAX_HISTORY_PER_SCOPE);
|
||||
return entries.filter((entry) => entry.toLowerCase().includes(normalizedQuery)).slice(0, MAX_HISTORY_PER_SCOPE);
|
||||
}
|
||||
|
||||
export function rememberRedisKeySearchHistory(scope: RedisKeySearchHistoryScope, value: string | undefined): string[] {
|
||||
const normalized = normalizeSearchInput(value);
|
||||
if (!normalized) return loadRedisKeySearchHistory(scope);
|
||||
|
||||
const history = readHistory();
|
||||
const key = redisKeySearchHistoryScopeKey(scope);
|
||||
const previous = scopeEntries(history, scope);
|
||||
history.scopes[key] = mergeUniqueNewestFirst([normalized, ...previous]);
|
||||
writeHistory(history);
|
||||
return history.scopes[key];
|
||||
}
|
||||
|
||||
export function forgetRedisKeySearchHistory(scope: RedisKeySearchHistoryScope, value: string | undefined): string[] {
|
||||
const normalized = normalizeSearchInput(value);
|
||||
if (!normalized) return loadRedisKeySearchHistory(scope);
|
||||
|
||||
const history = readHistory();
|
||||
const key = redisKeySearchHistoryScopeKey(scope);
|
||||
const previous = scopeEntries(history, scope);
|
||||
history.scopes[key] = previous.filter((entry) => entry.toLowerCase() !== normalized.toLowerCase());
|
||||
if (history.scopes[key].length === 0) delete history.scopes[key];
|
||||
writeHistory(history);
|
||||
return loadRedisKeySearchHistory(scope);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { beforeEach, test } from "vitest";
|
||||
import { forgetRedisKeySearchHistory, loadRedisKeySearchHistory, rememberRedisKeySearchHistory } from "../../apps/desktop/src/lib/redis/redisKeySearchHistory.ts";
|
||||
|
||||
class MemoryStorage {
|
||||
private values = new Map<string, string>();
|
||||
|
||||
getItem(key: string) {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: new MemoryStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("remembers connection+db scoped search history with newest entries first", () => {
|
||||
const scope = { connectionId: "c1", db: 0 };
|
||||
|
||||
rememberRedisKeySearchHistory(scope, "queue_update");
|
||||
rememberRedisKeySearchHistory(scope, "insert");
|
||||
rememberRedisKeySearchHistory(scope, "queue_update");
|
||||
|
||||
assert.deepEqual(loadRedisKeySearchHistory(scope), ["queue_update", "insert"]);
|
||||
});
|
||||
|
||||
test("does not store empty or whitespace-only patterns", () => {
|
||||
const scope = { connectionId: "c1", db: 0 };
|
||||
|
||||
rememberRedisKeySearchHistory(scope, "queue_update");
|
||||
rememberRedisKeySearchHistory(scope, "");
|
||||
rememberRedisKeySearchHistory(scope, " ");
|
||||
|
||||
assert.deepEqual(loadRedisKeySearchHistory(scope), ["queue_update"]);
|
||||
});
|
||||
|
||||
test("shares history across search modes and isolates connections and databases", () => {
|
||||
const scope = { connectionId: "c1", db: 0 };
|
||||
const otherDb = { connectionId: "c1", db: 1 };
|
||||
const otherConnection = { connectionId: "c2", db: 0 };
|
||||
|
||||
rememberRedisKeySearchHistory(scope, "queue_update");
|
||||
rememberRedisKeySearchHistory(scope, "insert");
|
||||
rememberRedisKeySearchHistory(otherDb, "cache:*");
|
||||
rememberRedisKeySearchHistory(otherConnection, "order:*");
|
||||
|
||||
assert.deepEqual(loadRedisKeySearchHistory(scope), ["insert", "queue_update"]);
|
||||
assert.deepEqual(loadRedisKeySearchHistory(otherDb), ["cache:*"]);
|
||||
assert.deepEqual(loadRedisKeySearchHistory(otherConnection), ["order:*"]);
|
||||
});
|
||||
|
||||
test("keeps the newest 20 history entries", () => {
|
||||
const scope = { connectionId: "c1", db: 0 };
|
||||
|
||||
for (let index = 1; index <= 21; index += 1) {
|
||||
rememberRedisKeySearchHistory(scope, `pattern ${index}`);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
loadRedisKeySearchHistory(scope),
|
||||
Array.from({ length: 20 }, (_, index) => `pattern ${21 - index}`),
|
||||
);
|
||||
});
|
||||
|
||||
test("filters history by partial input when a query is provided", () => {
|
||||
const scope = { connectionId: "c1", db: 0 };
|
||||
|
||||
rememberRedisKeySearchHistory(scope, "queue_update");
|
||||
rememberRedisKeySearchHistory(scope, "insert");
|
||||
rememberRedisKeySearchHistory(scope, "queue_update:pending");
|
||||
|
||||
assert.deepEqual(loadRedisKeySearchHistory(scope, "queue"), ["queue_update:pending", "queue_update"]);
|
||||
assert.deepEqual(loadRedisKeySearchHistory(scope), ["queue_update:pending", "insert", "queue_update"]);
|
||||
});
|
||||
|
||||
test("forgets a single pattern without clearing other scoped history", () => {
|
||||
const scope = { connectionId: "c1", db: 0 };
|
||||
const other = { connectionId: "c1", db: 1 };
|
||||
|
||||
rememberRedisKeySearchHistory(scope, "queue_update");
|
||||
rememberRedisKeySearchHistory(scope, "insert");
|
||||
rememberRedisKeySearchHistory(other, "active");
|
||||
|
||||
assert.deepEqual(forgetRedisKeySearchHistory(scope, "queue_update"), ["insert"]);
|
||||
assert.deepEqual(loadRedisKeySearchHistory(scope), ["insert"]);
|
||||
assert.deepEqual(loadRedisKeySearchHistory(other), ["active"]);
|
||||
});
|
||||
Reference in New Issue
Block a user