From 5e111145d34ffa2367bb65017b5e6768c236f645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E9=A2=97=E7=BA=A2=E5=BF=83?= Date: Fri, 11 Sep 2026 14:29:25 +0800 Subject: [PATCH] feat(redis): add key search history --- .../RedisKeyBrowser.searchHistory.spec.ts | 439 ++++++++++++++++++ .../src/components/redis/RedisKeyBrowser.vue | 170 ++++++- apps/desktop/src/i18n/locales/az.ts | 3 + apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/ko.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/tr.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../src/lib/redis/redisKeySearchHistory.ts | 94 ++++ .../app-tests/redisKeySearchHistory.test.ts | 98 ++++ 14 files changed, 825 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts create mode 100644 apps/desktop/src/lib/redis/redisKeySearchHistory.ts create mode 100644 packages/app-tests/redisKeySearchHistory.test.ts diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts new file mode 100644 index 0000000000..439c9e1953 --- /dev/null +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.searchHistory.spec.ts @@ -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([]); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 878804edb7..1ad7c4a873 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -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([]); +const searchHistorySelectedIndex = ref(-1); +const searchHistoryListboxId = `redis-key-search-history-${uuid()}`; let keyTemplateBlurTimer: ReturnType | null = null; const selectedKeyRaw = ref(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(() => ({ + 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" /> + +
+
+ {{ entry }} + +
+
{{ searchHistoryEmptyText }}
+