feat(redis): add key search history

This commit is contained in:
一颗红心
2026-09-11 14:29:25 +08:00
committed by GitHub
parent 72faf4887b
commit 5e111145d3
14 changed files with 825 additions and 6 deletions
@@ -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"
+3
View File
@@ -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...",
+3
View File
@@ -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...",
+3
View File
@@ -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",
+3
View File
@@ -4991,6 +4991,9 @@ export default withEnglishFallback({
},
},
field: "campo",
keySearchHistory: "Cronologia ricerche",
keySearchHistoryForget: "Rimuovi dalla cronologia",
keySearchHistoryEmpty: "Nessuna cronologia",
},
mongo: {
documents: "{count} documenti",
+3
View File
@@ -5020,6 +5020,9 @@ export default withEnglishFallback({
},
},
field: "フィールド",
keySearchHistory: "検索履歴",
keySearchHistoryForget: "履歴から削除",
keySearchHistoryEmpty: "履歴はありません",
},
mongo: {
documents: "{count}ドキュメント",
+3
View File
@@ -4624,6 +4624,9 @@ export default withEnglishFallback({
},
monitorStop: "모니터링 중지",
monitorListening: "MONITOR 모니터링 중 · 최근 1000개 보관 · 너무 긴 레코드는 잘림 표시",
keySearchHistory: "검색 기록",
keySearchHistoryForget: "기록에서 제거",
keySearchHistoryEmpty: "검색 기록이 없습니다",
},
mongo: {
documents: "문서 {count}개",
+3
View File
@@ -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",
+3
View File
@@ -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...",
+3
View File
@@ -5017,6 +5017,9 @@ export default withEnglishFallback({
pattern: "匹配模式 (如 user:*)",
fuzzyPattern: "输入关键字搜索 key",
keyTemplateSuggestions: "Key 模板",
keySearchHistory: "搜索历史",
keySearchHistoryForget: "从历史中移除",
keySearchHistoryEmpty: "暂无历史记录",
fuzzyMatch: "模糊",
fuzzyMatchTitle: "模糊匹配:自动按包含关系搜索 key",
valueSearchPlaceholder: "按值内容搜索...",
+3
View File
@@ -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);
}
+98
View File
@@ -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"]);
});