feat(redis): add key search history

This commit is contained in:
一颗红心 authored and GitHub committed 2026-09-11 14:29:25 +08:00
1 parent 72faf4887b
commit 5e111145d3
14 files changed
+825 -6

No files matched your search

@@ -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"