feat(redis): add configurable key search templates
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user