feat(redis): add configurable key search templates

This commit is contained in:
whoops authored and GitHub committed 2026-08-25 22:34:04 +08:00
1 parent bfd7c7def2
commit 5e307f56cc
47 files changed
+407 -11

No files matched your search

@@ -29,6 +29,7 @@ import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/l
import { canPersistConnectionTestResult, connectionEditDraftSyncAction } from "./connectionEditDraftSync";
import { createConnectionNoteVisibilityDraft, persistConnectionNoteVisibilityDraft as persistConnectionNoteVisibilityDraftState, resetConnectionNoteVisibilityDraft, setConnectionNoteVisibilityDraft, syncConnectionNoteVisibilityDraft } from "./connectionNoteVisibilityDraft";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT, REDIS_SCAN_PAGE_SIZE_MIN, REDIS_SCAN_PAGE_SIZE_MAX, REDIS_SCAN_PAGE_SIZE_OPTIONS } from "@/lib/redis/redisKeyPattern";
import { normalizeRedisKeyTemplates, redisKeyTemplatesToTextarea } from "@/lib/redis/redisKeyTemplates";
import { normalizeGlobalConnectTimeoutSecs, normalizeGlobalQueryTimeoutSecs, useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
@@ -363,6 +364,7 @@ const defaultForm = (): ConnectionForm => ({
redis_cluster_nodes: "",
redis_key_separator: ":",
redis_scan_page_size: REDIS_SCAN_PAGE_SIZE_DEFAULT,
redis_key_templates: [],
etcd_endpoints: "",
gbase_server: "",
informix_server: "",
@@ -556,6 +558,7 @@ function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] {
}
const form = ref(defaultForm());
const redisKeyTemplatesText = ref("");
const noteTextareaRef = ref<HTMLTextAreaElement | null>(null);
const showGaussdbConnectionMode = computed(() => form.value.db_type === "gaussdb");
const gaussdbDriverMode = computed<GaussdbConnectionMode>({
@@ -2484,6 +2487,7 @@ watch(
redis_cluster_nodes: config.redis_cluster_nodes || "",
redis_key_separator: config.redis_key_separator ?? ":",
redis_scan_page_size: config.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT,
redis_key_templates: normalizeRedisKeyTemplates(config.redis_key_templates),
etcd_endpoints: config.etcd_endpoints || "",
gbase_server: config.gbase_server || "",
informix_server: config.informix_server || "",
@@ -2499,6 +2503,7 @@ watch(
visible_schemas: config.visible_schemas,
save_password: config.save_password !== false,
};
redisKeyTemplatesText.value = redisKeyTemplatesToTextarea(form.value.redis_key_templates);
oracleTnsAdminPath.value = parseOracleTnsConnectionString(config.connection_string)?.tnsAdmin || "";
productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0;
connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : "";
@@ -2570,6 +2575,7 @@ watch(
clearSavedDatabaseInfo();
editingId.value = null;
form.value = defaultForm();
redisKeyTemplatesText.value = "";
productionProtectionEnabled.value = false;
selectedTransportLayerId.value = null;
selectedType.value = "mysql";
@@ -3859,6 +3865,7 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
config.redis_key_separator = undefined;
config.redis_scan_page_size = undefined;
config.redis_database_aliases = undefined;
config.redis_key_templates = undefined;
} else if (config.redis_connection_mode === "sentinel") {
config.redis_sentinel_master = config.redis_sentinel_master?.trim() || "";
config.redis_sentinel_nodes = normalizeRedisSentinelNodes(config.redis_sentinel_nodes || "");
@@ -3894,6 +3901,11 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
config.redis_key_separator = config.redis_key_separator?.trim() ?? ":";
const scanSize = Number(config.redis_scan_page_size);
config.redis_scan_page_size = Number.isFinite(scanSize) && scanSize >= REDIS_SCAN_PAGE_SIZE_MIN && scanSize <= REDIS_SCAN_PAGE_SIZE_MAX ? Math.round(scanSize) : REDIS_SCAN_PAGE_SIZE_DEFAULT;
{
const templates = normalizeRedisKeyTemplates(redisKeyTemplatesText.value);
config.redis_key_templates = templates.length > 0 ? templates : undefined;
form.value.redis_key_templates = templates;
}
}
if (config.db_type === "zookeeper") {
const normalizedConnectString = normalizeZooKeeperConnectString(config.connection_string || "");
@@ -4760,6 +4772,7 @@ function openJdbcDriverManagerFromError() {
function resetForm(options: { preservePickerState?: boolean } = {}) {
editingId.value = null;
form.value = defaultForm();
redisKeyTemplatesText.value = "";
resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes);
editGlobalConnectTimeoutSecs.value = settingsStore.editorSettings.globalConnectTimeoutSecs;
editGlobalQueryTimeoutSecs.value = settingsStore.editorSettings.globalQueryTimeoutSecs;
@@ -6514,6 +6527,18 @@ function openExternalUrl(url: string) {
<Label :class="connectionLabelSmallClass">{{ t("connection.redisKeySeparator") }}</Label>
<Input v-model="form.redis_key_separator" class="col-span-3 h-8 text-xs" placeholder=":" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.redisKeyTemplates") }}</Label>
<div class="col-span-3 space-y-1">
<textarea
v-model="redisKeyTemplatesText"
class="flex min-h-[76px] w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:placeholder="t('connection.redisKeyTemplatesPlaceholder')"
spellcheck="false"
/>
<p class="text-xs text-muted-foreground">{{ t("connection.redisKeyTemplatesHint") }}</p>
</div>
</div>
</template>
<!-- Consul KV: HTTP endpoint, ACL token and scope -->
@@ -106,6 +106,7 @@ import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keybo
import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer";
import { buildQueryEditorLineNumbersExtension } from "@/lib/editor/queryEditorLineNumbers";
@@ -456,6 +457,7 @@ const editFormatSqlOnSqlFileSave = ref(settingsStore.editorSettings.formatSqlOnS
const editClickTableNavigationTarget = ref<ClickTableNavigationTarget>(settingsStore.editorSettings.clickTableNavigationTarget);
const editUpdateNotificationsEnabled = ref(settingsStore.editorSettings.updateNotificationsEnabled);
const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"));
const editRedisKeyTemplates = ref(normalizeRedisKeyTemplates(settingsStore.editorSettings.redisKeyTemplates).join("\n"));
const editSidebarObjectInfoMode = ref<SidebarObjectInfoMode>(settingsStore.editorSettings.sidebarObjectInfoMode);
const editSidebarAllowHorizontalScroll = ref(settingsStore.editorSettings.sidebarAllowHorizontalScroll);
const editSidebarIndent = ref(settingsStore.editorSettings.sidebarIndent);
@@ -593,6 +595,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
sidebarIndent: editSidebarIndent.value,
sidebarFontSize: editSidebarFontSize.value,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value),
redisKeyTemplates: normalizeRedisKeyTemplates(editRedisKeyTemplates.value),
exportBatchSize: editExportBatchSize.value,
globalDateTimeDisplayFormat: editGlobalDateTimeDisplayFormat.value,
globalDateTimeExportFormat: editGlobalDateTimeExportFormat.value,
@@ -882,6 +885,7 @@ function syncEditorSettingsDraftFromStore() {
editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget;
editUpdateNotificationsEnabled.value = settingsStore.editorSettings.updateNotificationsEnabled;
editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n");
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(settingsStore.editorSettings.redisKeyTemplates).join("\n");
editSidebarObjectInfoMode.value = settingsStore.editorSettings.sidebarObjectInfoMode;
editSidebarAllowHorizontalScroll.value = settingsStore.editorSettings.sidebarAllowHorizontalScroll;
editSidebarIndent.value = settingsStore.editorSettings.sidebarIndent;
@@ -1163,6 +1167,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editDuckDbWorkerProcessIsolation.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_process_isolation;
editDuckDbWorkerMaxProcesses.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_max_processes;
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields);
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n");
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat;
editGlobalDateTimeExportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeExportFormat;
@@ -1264,6 +1269,7 @@ function resetAllDefaults() {
editSidebarIndent.value = DEFAULT_EDITOR_SETTINGS.sidebarIndent;
editSidebarFontSize.value = DEFAULT_EDITOR_SETTINGS.sidebarFontSize;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n");
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat;
editGlobalDateTimeExportFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeExportFormat;
@@ -5511,6 +5517,27 @@ onUnmounted(() => {
<Separator />
<div id="redis-key-templates" class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.redisKeyTemplatesSection") }}
</div>
<div class="space-y-2">
<Label for="redis-key-templates-input">{{ t("settings.redisKeyTemplates") }}</Label>
<textarea
id="redis-key-templates-input"
v-model="editRedisKeyTemplates"
class="dbx-editor-font-family min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
:placeholder="t('settings.redisKeyTemplatesPlaceholder')"
spellcheck="false"
/>
<p class="text-xs text-muted-foreground">
{{ t("settings.redisKeyTemplatesDescription") }}
</p>
</div>
</div>
<Separator />
<div class="space-y-3">
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.exportSection") }}
@@ -1273,7 +1273,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
expect(document.body.textContent).toContain("first");
});
it("cancels a sparse scan immediately when the search text changes", async () => {
it("cancels a sparse scan immediately without searching until Enter", async () => {
mocks.redisScanPageSize = 1_000;
mountBrowser();
await settle();
@@ -1281,6 +1281,7 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
await settle();
const oldPage = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
const stale = { key_display: "old:result", key_raw: "b2xkOnJlc3VsdA==", key_type: "string", ttl: -1 };
const fresh = { key_display: "new:result", key_raw: "bmV3OnJlc3VsdA==", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockReset();
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, _cursor: number, pattern: string) => {
@@ -1291,14 +1292,22 @@ describe("RedisKeyBrowser fuzzy key hierarchy", () => {
await submitKeySearch("old");
await vi.waitFor(() => expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1));
const input = requiredElement<HTMLInputElement>("[data-redis-search-input]");
input.value = "new";
input.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
oldPage.resolve({ cursor: 1, keys: [], total_keys: 1_000_000 });
await setInput("[data-redis-search-input]", "new");
expect(mocks.redisScanKeysBatch).toHaveBeenCalledTimes(1);
mocks.updateRedisDbKeyStats.mockClear();
oldPage.resolve({ cursor: 0, keys: [stale], total_keys: 1 });
await settle();
expect(mocks.updateRedisDbKeyStats).not.toHaveBeenCalled();
expect(document.body.textContent).not.toContain("old:result");
expect(document.body.textContent).not.toContain("new:result");
expect(mocks.redisScanKeysBatch.mock.calls.filter((call) => call[3] === "*old*")).toHaveLength(1);
expect(mocks.redisScanKeysBatch.mock.calls.map((call) => call[3])).toEqual(["*old*"]);
requiredElement<HTMLInputElement>("[data-redis-search-input]").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
await vi.waitFor(() => expect(document.body.textContent).toContain("result"));
expect(mocks.redisScanKeysBatch.mock.calls.map((call) => call[3])).toEqual(["*old*", "*new*"]);
});
@@ -1731,6 +1740,7 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
it("does not select stale keys when a search changes during select-all scanning", async () => {
const oldPage = deferred<{ cursor: number; keys: RedisKeyInfo[]; total_keys: number }>();
const oldKey = { key_display: "old", key_raw: "b2xk", key_type: "string", ttl: -1 };
const lateOldKey = { key_display: "late-old", key_raw: "bGF0ZS1vbGQ=", key_type: "string", ttl: -1 };
const freshKey = { key_display: "fresh", key_raw: "ZnJlc2g=", key_type: "string", ttl: -1 };
mocks.redisScanKeysBatch.mockImplementation((_connectionId: string, _db: number, cursor: number, pattern: string) => {
if (pattern === "*" && cursor === 0) return Promise.resolve({ cursor: 1, keys: [oldKey], total_keys: 2 });
@@ -1742,11 +1752,16 @@ describe("RedisKeyBrowser interrupted Fetch All", () => {
requiredElement<HTMLButtonElement>("[data-redis-select-all]").click();
await vi.waitFor(() => expect(mocks.redisScanKeysBatch.mock.calls.length).toBeGreaterThan(1));
const input = requiredElement<HTMLInputElement>("[data-redis-search-input]");
input.value = "fresh";
input.dispatchEvent(new Event("input", { bubbles: true }));
await setInput("[data-redis-search-input]", "fresh");
oldPage.resolve({ cursor: 0, keys: [lateOldKey], total_keys: 0 });
await settle();
oldPage.resolve({ cursor: 0, keys: [oldKey], total_keys: 0 });
expect(document.body.textContent).not.toContain("late-old");
expect(document.body.textContent).not.toContain("redis.stopFetchAll");
expect(mocks.redisScanKeysBatch.mock.calls.every((call) => call[3] === "*")).toBe(true);
expect(document.querySelector("[data-redis-batch-delete]")).toBeNull();
requiredElement<HTMLInputElement>("[data-redis-search-input]").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await settle();
await vi.waitFor(() => expect(document.body.textContent).toContain("fresh"));
@@ -52,6 +52,7 @@ import { copyToClipboard } from "@/lib/common/clipboard";
import { useEditorFontFamilyStyle } from "@/composables/useEditorFontFamilyStyle";
import { useToast } from "@/composables/useToast";
import { redisKeySearchPattern } from "@/lib/redis/redisKeyPattern";
import { filterRedisKeyTemplates, resolveRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
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";
@@ -112,6 +113,10 @@ const commandTerminalRef = ref<HTMLElement>();
const searchPattern = ref("");
const searchMode = ref<RedisSearchMode>("key");
const fuzzyKeySearch = ref(false);
const keyTemplateMenuOpen = ref(false);
const keyTemplateSelectedIndex = ref(0);
const keyTemplateListboxId = `redis-key-template-suggestions-${uuid()}`;
let keyTemplateBlurTimer: ReturnType<typeof setTimeout> | null = null;
const selectedKeyRaw = ref<string | null>(null);
const hasMore = ref(false);
const scanCursor = ref(0);
@@ -216,6 +221,10 @@ const searchPlaceholder = computed(() => {
if (searchMode.value === "key") return fuzzyKeySearch.value ? t("redis.fuzzyPattern") : t("redis.pattern");
return searchMode.value === "all" ? t("redis.allSearchPlaceholder") : t("redis.valueSearchPlaceholder");
});
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 keyTemplateActiveDescendant = computed(() => (keyTemplateMenuVisible.value ? `${keyTemplateListboxId}-option-${keyTemplateSelectedIndex.value}` : undefined));
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);
@@ -1636,7 +1645,90 @@ function typeColor(type: string): string {
let searchTimer: ReturnType<typeof setTimeout> | null = null;
let hasAutoFocusedSearch = false;
function dismissKeyTemplateMenu() {
keyTemplateMenuOpen.value = false;
keyTemplateSelectedIndex.value = 0;
if (keyTemplateBlurTimer) {
clearTimeout(keyTemplateBlurTimer);
keyTemplateBlurTimer = null;
}
}
function openKeyTemplateMenu() {
if (searchMode.value !== "key" || redisKeyTemplates.value.length === 0) {
dismissKeyTemplateMenu();
return;
}
keyTemplateMenuOpen.value = true;
keyTemplateSelectedIndex.value = 0;
}
function clampKeyTemplateSelection() {
const max = Math.max(keyTemplateSuggestions.value.length - 1, 0);
if (keyTemplateSelectedIndex.value > max) keyTemplateSelectedIndex.value = max;
}
function selectKeyTemplate(index: number) {
const template = keyTemplateSuggestions.value[index];
if (!template) return;
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
searchPending.value = false;
searchPattern.value = template;
dismissKeyTemplateMenu();
void nextTick(() => getSearchInput()?.focus());
}
function moveKeyTemplateSelection(direction: number): boolean {
if (!keyTemplateMenuVisible.value) return false;
const count = keyTemplateSuggestions.value.length;
if (count === 0) return false;
keyTemplateSelectedIndex.value = Math.min(Math.max(keyTemplateSelectedIndex.value + direction, 0), count - 1);
void nextTick(() => {
document.getElementById(`${keyTemplateListboxId}-option-${keyTemplateSelectedIndex.value}`)?.scrollIntoView({ block: "nearest" });
});
return true;
}
function onSearchFocus() {
openKeyTemplateMenu();
}
function onSearchBlur() {
if (keyTemplateBlurTimer) clearTimeout(keyTemplateBlurTimer);
keyTemplateBlurTimer = setTimeout(() => {
dismissKeyTemplateMenu();
keyTemplateBlurTimer = null;
}, 150);
}
function onSearchInput() {
if (searchMode.value === "key" && redisKeyTemplates.value.length > 0) {
keyTemplateMenuOpen.value = true;
keyTemplateSelectedIndex.value = 0;
clampKeyTemplateSelection();
} else {
dismissKeyTemplateMenu();
}
// Key search is Enter-only so users can finish editing templates / patterns
// (including {$placeholders}) without triggering SCAN on every keystroke.
if (searchMode.value === "key") {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
searchPending.value = false;
invalidateScanRequests();
loading.value = false;
isFetchingAll.value = false;
fetchAllStopRequested.value = true;
fetchAllLoadedCount.value = 0;
selectedKeyRaw.value = null;
resetCheckedKeys();
return;
}
if (searchTimer) clearTimeout(searchTimer);
// Invalidate in-flight SCAN work as soon as the query changes instead of
// waiting for the debounce timer to start the replacement search.
@@ -1654,6 +1746,7 @@ function onSearchInput() {
function setSearchMode(mode: RedisSearchMode) {
if (searchMode.value === mode) return;
searchMode.value = mode;
if (mode !== "key") dismissKeyTemplateMenu();
void loadKeys();
}
@@ -1834,6 +1927,28 @@ function focusSearch(): boolean {
}
function onSearchKeydown(event: KeyboardEvent) {
if (keyTemplateMenuVisible.value) {
if (event.key === "ArrowDown") {
event.preventDefault();
moveKeyTemplateSelection(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveKeyTemplateSelection(-1);
return;
}
if (event.key === "Enter") {
event.preventDefault();
selectKeyTemplate(keyTemplateSelectedIndex.value);
return;
}
if (event.key === "Escape") {
event.preventDefault();
dismissKeyTemplateMenu();
return;
}
}
if (event.key === "Enter") {
void loadKeys();
return;
@@ -1841,6 +1956,7 @@ function onSearchKeydown(event: KeyboardEvent) {
if (!isCancelSearchShortcut(event)) return;
event.preventDefault();
searchPattern.value = "";
dismissKeyTemplateMenu();
void loadKeys();
}
@@ -2083,15 +2199,37 @@ defineExpose({ focusSearch, insertCommand, executeCommand: executeAiCommand });
</div>
<div class="redis-key-search-row mt-2">
<div class="relative min-w-0">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Search class="pointer-events-none absolute left-2.5 top-1/2 z-[1] h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
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"
:placeholder="searchPlaceholder"
:aria-expanded="keyTemplateMenuVisible"
:aria-controls="keyTemplateMenuVisible ? keyTemplateListboxId : undefined"
:aria-activedescendant="keyTemplateActiveDescendant"
autocomplete="off"
@input="onSearchInput"
@keydown="onSearchKeydown"
@focus="onSearchFocus"
@blur="onSearchBlur"
/>
<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"
:id="`${keyTemplateListboxId}-option-${index}`"
:key="template"
type="button"
role="option"
class="dbx-editor-font-family flex w-full items-center px-3 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
:class="keyTemplateSelectedIndex === index ? 'bg-accent text-accent-foreground' : ''"
:aria-selected="keyTemplateSelectedIndex === index"
@mousedown.prevent="selectKeyTemplate(index)"
>
<span class="truncate">{{ template }}</span>
</button>
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<Button