feat(redis): add configurable key search templates

This commit is contained in:
whoops
2026-08-25 22:34:04 +08:00
committed by GitHub
parent bfd7c7def2
commit 5e307f56cc
47 changed files with 407 additions and 11 deletions
@@ -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
+8
View File
@@ -619,6 +619,9 @@ export default {
redisSentinelTls: "Sentinel TLS",
redisSentinelTlsHint: "Use TLS when connecting to Sentinel nodes",
redisKeySeparator: "Key Namespace Separator",
redisKeyTemplates: "Key search templates",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesHint: "One template per line. When set, these replace the global Redis key templates for this connection.",
etcdEndpoints: "Endpoints",
etcdEndpointsHint: "One endpoint per line. Leave blank to use the host and port above.",
etcdGrpcMaxInbound: "gRPC receive limit (MiB)",
@@ -4536,6 +4539,7 @@ export default {
noKeysInScanHint: "No keys found in current scan range",
pattern: "pattern (e.g. user:*)",
fuzzyPattern: "key contains...",
keyTemplateSuggestions: "Key templates",
fuzzyMatch: "Fuzzy",
fuzzyMatchTitle: "Fuzzy match: search keys by plain text contains",
valueSearchPlaceholder: "value contains...",
@@ -6490,6 +6494,10 @@ export default {
sqlVariableSyntax_atSetDescription: "Expand inline variable declarations and substitute their values before running.",
autoAliasTables: "Automatically add table aliases",
autoAliasTablesDescription: "When selecting a table completion in FROM or JOIN, insert a generated alias such as order_items AS oi.",
redisKeyTemplatesSection: "Redis",
redisKeyTemplates: "Key search templates",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesDescription: "One template per line. Used as fallback suggestions when a Redis connection has no templates of its own.",
redisScanPageSize: "Redis scan count",
redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.",
redisScanPageSizeOption: "{count} keys",
+8
View File
@@ -594,6 +594,9 @@ export default withEnglishFallback({
redisSentinelTls: "TLS Sentinel",
redisSentinelTlsHint: "Usar TLS al conectar con nodos Sentinel",
redisKeySeparator: "Separador de espacio de nombres de clave",
redisKeyTemplates: "Plantillas de búsqueda de claves",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesHint: "Una plantilla por línea. Si se define, reemplaza las plantillas globales para esta conexión.",
etcdEndpoints: "Endpoints",
etcdEndpointsHint: "Una entrada por línea. Déjalo en blanco para usar el host y puerto de arriba.",
etcdGrpcMaxInbound: "Límite de recepción gRPC (MiB)",
@@ -4361,6 +4364,7 @@ export default withEnglishFallback({
noKeysInScanHint: "No se encontraron claves en el rango de escaneo actual",
pattern: "patrón (p. ej. usuario:*)",
fuzzyPattern: "la clave contiene...",
keyTemplateSuggestions: "Plantillas de clave",
fuzzyMatch: "Difusa",
fuzzyMatchTitle: "Coincidencia difusa: busca claves que contengan texto plano",
valueSearchPlaceholder: "el valor contiene...",
@@ -6172,6 +6176,10 @@ export default withEnglishFallback({
sqlVariableSyntax_atSetDescription: "Expande las declaraciones de variables en línea y sustituye sus valores antes de ejecutar.",
autoAliasTables: "Agregar alias de tabla automáticamente",
autoAliasTablesDescription: "Al seleccionar una tabla en FROM o JOIN, inserta un alias generado como order_items AS oi.",
redisKeyTemplatesSection: "Redis",
redisKeyTemplates: "Plantillas de búsqueda de claves",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesDescription: "Una plantilla por línea. Respaldo cuando la conexión no tiene plantillas propias.",
redisScanPageSize: "Cantidad de escaneo Redis",
redisScanPageSizeDescription: "Claves solicitadas por página SCAN al explorar claves Redis.",
redisScanPageSizeOption: "{count} claves",
+8
View File
@@ -593,6 +593,9 @@ export default withEnglishFallback({
redisSentinelTls: "TLS Sentinel",
redisSentinelTlsHint: "Usa TLS quando ti connetti ai nodi Sentinel",
redisKeySeparator: "Separatore namespace chiavi",
redisKeyTemplates: "Modelli di ricerca chiavi",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesHint: "Un modello per riga. Se impostato, sostituisce i modelli globali per questa connessione.",
etcdEndpoints: "Endpoint",
etcdEndpointsHint: "Un endpoint per riga. Lascia vuoto per usare l'host e la porta sopra.",
etcdGrpcMaxInbound: "Limite ricezione gRPC (MiB)",
@@ -4359,6 +4362,7 @@ export default withEnglishFallback({
noKeysInScanHint: "Nessuna chiave trovata nell'intervallo di scansione corrente",
pattern: "pattern (es. user:*)",
fuzzyPattern: "la chiave contiene...",
keyTemplateSuggestions: "Modelli di chiave",
fuzzyMatch: "Fuzzy",
fuzzyMatchTitle: "Corrispondenza fuzzy: cerca le chiavi per testo semplice contenuto",
valueSearchPlaceholder: "il valore contiene...",
@@ -6172,6 +6176,10 @@ export default withEnglishFallback({
sqlVariableSyntax_atSetDescription: "Espande le dichiarazioni di variabili inline e ne sostituisce i valori prima dell'esecuzione.",
autoAliasTables: "Aggiungi alias tabella automaticamente",
autoAliasTablesDescription: "Quando scegli una tabella in FROM o JOIN, inserisce un alias generato come order_items AS oi.",
redisKeyTemplatesSection: "Redis",
redisKeyTemplates: "Modelli di ricerca chiavi",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesDescription: "Un modello per riga. Fallback quando la connessione non ha modelli propri.",
redisScanPageSize: "Conteggio scansione Redis",
redisScanPageSizeDescription: "Chiavi richieste per pagina SCAN di Redis durante la navigazione delle chiavi.",
redisScanPageSizeOption: "{count} chiavi",
+8
View File
@@ -593,6 +593,9 @@ export default withEnglishFallback({
redisSentinelTls: "センチネルTLS",
redisSentinelTlsHint: "センチネルノード接続時にTLSを使用する",
redisKeySeparator: "キー名前空間セパレーター",
redisKeyTemplates: "キー検索テンプレート",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesHint: "1行に1つのテンプレート。設定するとこの接続のグローバルテンプレートを上書きします。",
etcdEndpoints: "エンドポイント",
etcdEndpointsHint: "1行に1つのエンドポイント。空白の場合、上のホストとポートを使用します。",
etcdGrpcMaxInbound: "gRPC 受信上限(MiB)",
@@ -4387,6 +4390,7 @@ export default withEnglishFallback({
noKeys: "キーが見つかりません",
pattern: "パターン(例: user:*)",
fuzzyPattern: "キーを含む...",
keyTemplateSuggestions: "キーテンプレート",
fuzzyMatch: "あいまい",
fuzzyMatchTitle: "あいまい検索: プレーンテキストの部分一致でキーを検索",
valueSearchPlaceholder: "値を含む...",
@@ -6190,6 +6194,10 @@ export default withEnglishFallback({
sqlVariableSyntax_atSetDescription: "実行前にインライン変数宣言を展開し、その値をインライン化します。",
autoAliasTables: "テーブル別名を自動追加",
autoAliasTablesDescription: "FROM または JOIN でテーブル補完を選択すると、order_items AS oi のような生成済み別名を挿入します。",
redisKeyTemplatesSection: "Redis",
redisKeyTemplates: "キー検索テンプレート",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesDescription: "1行に1つのテンプレート。接続にテンプレートがない場合のフォールバックです。",
redisScanPageSize: "Redisスキャンカウント",
redisScanPageSizeDescription: "キー参照時のRedis SCANページあたりの要求キー数。",
redisScanPageSizeOption: "{count}キー",
+8
View File
@@ -613,6 +613,9 @@ export default withEnglishFallback({
redisSentinelTls: "센티널 TLS",
redisSentinelTlsHint: "센티널 노드에 연결할 때 TLS 사용",
redisKeySeparator: "키 네임스페이스 구분자",
redisKeyTemplates: "키 검색 템플릿",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesHint: "한 줄에 하나의 템플릿. 설정하면 이 연결의 전역 템플릿을 덮어씁니다.",
etcdEndpoints: "엔드포인트",
etcdEndpointsHint: "한 줄에 하나의 엔드포인트. 비워두면 위의 호스트와 포트를 사용합니다.",
etcdGrpcMaxInbound: "gRPC 수신 제한 (MiB)",
@@ -3972,6 +3975,7 @@ export default withEnglishFallback({
noKeysInScanHint: "현재 스캔 범위에 키가 없습니다",
pattern: "패턴 (예: user:*)",
fuzzyPattern: "키에 포함된...",
keyTemplateSuggestions: "키 템플릿",
fuzzyMatch: "퍼지",
fuzzyMatchTitle: "퍼지 일치: 일반 텍스트 포함으로 키 검색",
valueSearchPlaceholder: "값에 포함된...",
@@ -5873,6 +5877,10 @@ export default withEnglishFallback({
sqlVariableSyntax_atSetDescription: "실행 전에 인라인 변수 선언을 확장하고 값을 치환합니다.",
autoAliasTables: "자동으로 테이블 별칭 추가",
autoAliasTablesDescription: "FROM이나 JOIN에서 테이블 완성을 선택할 때 order_items AS oi 같은 생성된 별칭을 삽입합니다.",
redisKeyTemplatesSection: "Redis",
redisKeyTemplates: "키 검색 템플릿",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesDescription: "한 줄에 하나의 템플릿. 연결에 템플릿이 없을 때 전역 폴백으로 사용됩니다.",
redisScanPageSize: "Redis 스캔 개수",
redisScanPageSizeDescription: "키를 탐색할 때 Redis SCAN 페이지당 요청하는 키 수입니다.",
redisScanPageSizeOption: "키 {count}개",
+8
View File
@@ -594,6 +594,9 @@ export default withEnglishFallback({
redisSentinelTls: "TLS do Sentinel",
redisSentinelTlsHint: "Usar TLS ao conectar aos nós Sentinel",
redisKeySeparator: "Separador de namespace de chave",
redisKeyTemplates: "Modelos de busca de chave",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesHint: "Um modelo por linha. Quando definido, substitui os modelos globais nesta conexão.",
etcdEndpoints: "Endpoints",
etcdEndpointsHint: "Um endpoint por linha. Deixe em branco para usar o host e porta acima.",
etcdGrpcMaxInbound: "Limite de recebimento gRPC (MiB)",
@@ -4361,6 +4364,7 @@ export default withEnglishFallback({
noKeysInScanHint: "Nenhuma chave encontrada no intervalo atual de verificação",
pattern: "padrão (ex.: user:*)",
fuzzyPattern: "a chave contém...",
keyTemplateSuggestions: "Modelos de chave",
fuzzyMatch: "Aproximado",
fuzzyMatchTitle: "Correspondência aproximada: pesquisa chaves por texto contido",
valueSearchPlaceholder: "o valor contém...",
@@ -6174,6 +6178,10 @@ export default withEnglishFallback({
sqlVariableSyntax_atSetDescription: "Expande as declarações de variáveis inline e substitui seus valores antes de executar.",
autoAliasTables: "Adicionar alias de tabela automaticamente",
autoAliasTablesDescription: "Ao escolher uma tabela em FROM ou JOIN, insere um alias gerado como order_items AS oi.",
redisKeyTemplatesSection: "Redis",
redisKeyTemplates: "Modelos de busca de chave",
redisKeyTemplatesPlaceholder: "Example:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesDescription: "Um modelo por linha. Fallback quando a conexão não tem modelos próprios.",
redisScanPageSize: "Contagem de scan do Redis",
redisScanPageSizeDescription: "Chaves solicitadas por página do Redis SCAN ao navegar pelas chaves.",
redisScanPageSizeOption: "{count} chaves",
+8
View File
@@ -544,6 +544,9 @@ export default withEnglishFallback({
redisSentinelTls: "哨兵 TLS",
redisSentinelTlsHint: "连接 Sentinel 节点时使用 TLS",
redisKeySeparator: "键名分隔符(可选)",
redisKeyTemplates: "Key 搜索模板",
redisKeyTemplatesPlaceholder: "例如:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesHint: "每行一个模板。填写后将覆盖全局 Redis Key 模板,仅对本连接生效。",
etcdEndpoints: "Endpoints",
etcdEndpointsHint: "每行一个 endpoint。留空时使用上面的 host 和端口。",
etcdGrpcMaxInbound: "gRPC 接收上限(MiB)",
@@ -4521,6 +4524,7 @@ export default withEnglishFallback({
noKeysInScanHint: "当前扫描范围内未命中匹配的 key",
pattern: "匹配模式 (如 user:*)",
fuzzyPattern: "输入关键字搜索 key",
keyTemplateSuggestions: "Key 模板",
fuzzyMatch: "模糊",
fuzzyMatchTitle: "模糊匹配:自动按包含关系搜索 key",
valueSearchPlaceholder: "按值内容搜索...",
@@ -6473,6 +6477,10 @@ export default withEnglishFallback({
sqlVariableSyntax_atSetDescription: "执行前展开内联变量声明并就地代入其值。",
autoAliasTables: "自动添加表别名",
autoAliasTablesDescription: "在 FROM 或 JOIN 中选择表名补全时,自动插入类似 order_items AS oi 的表别名。",
redisKeyTemplatesSection: "Redis",
redisKeyTemplates: "Key 搜索模板",
redisKeyTemplatesPlaceholder: "例如:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesDescription: "每行一个模板。当连接未配置模板时,作为全局回退建议。",
redisScanPageSize: "Redis 扫描数量",
redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。",
redisScanPageSizeOption: "{count} 个 Key",
+8
View File
@@ -594,6 +594,9 @@ export default withEnglishFallback({
redisSentinelTls: "Sentinel TLS",
redisSentinelTlsHint: "連線 Sentinel 節點時使用 TLS",
redisKeySeparator: "鍵名分隔符(可選)",
redisKeyTemplates: "Key 搜尋模板",
redisKeyTemplatesPlaceholder: "例如:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesHint: "每行一個模板。設定後會覆寫全域 Redis Key 模板,僅對此連線生效。",
etcdEndpoints: "端點",
etcdEndpointsHint: "每行一個端點。留空則使用上方的主機和連接埠。",
etcdGrpcMaxInbound: "gRPC 接收上限(MiB)",
@@ -3685,6 +3688,7 @@ export default withEnglishFallback({
noKeysInScanHint: "目前掃描範圍內未找到匹配的 key",
pattern: "模式 (例如 user:*)",
fuzzyPattern: "輸入關鍵字搜尋 key",
keyTemplateSuggestions: "Key 模板",
fuzzyMatch: "模糊",
fuzzyMatchTitle: "模糊匹配:自動按包含關係搜尋 key",
valueSearchPlaceholder: "值包含……",
@@ -5490,6 +5494,10 @@ export default withEnglishFallback({
sqlVariableSyntax_atSetDescription: "執行前展開內聯變數宣告並就地代入其值。",
autoAliasTables: "自動加入資料表別名",
autoAliasTablesDescription: "在 FROM 或 JOIN 中選擇資料表補全時,自動插入類似 order_items AS oi 的資料表別名。",
redisKeyTemplatesSection: "Redis",
redisKeyTemplates: "Key 搜尋模板",
redisKeyTemplatesPlaceholder: "例如:\nuser:{'{'}$userId{'}'}\nv3:adui:{'{'}$positionID{'}'}",
redisKeyTemplatesDescription: "每行一個模板。當連線未設定模板時,作為全域後備建議。",
redisScanPageSize: "Redis 掃描數量",
redisScanPageSizeDescription: "瀏覽 Redis Key 時每次 SCAN 請求的 Key 數量。",
redisScanPageSizeOption: "{count} 個 Key",
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { filterRedisKeyTemplates, normalizeRedisKeyTemplates, redisKeyTemplatesToTextarea, resolveRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
describe("redisKeyTemplates", () => {
it("normalizes arrays and newline text, trimming and deduping", () => {
expect(normalizeRedisKeyTemplates([" user:{$id} ", "", "v3:adui:{$positionID}", "user:{$id}", " "])).toEqual(["user:{$id}", "v3:adui:{$positionID}"]);
expect(normalizeRedisKeyTemplates("user:{$id}\n\nv3:adui\nuser:{$id}\n")).toEqual(["user:{$id}", "v3:adui"]);
expect(normalizeRedisKeyTemplates(null)).toEqual([]);
expect(normalizeRedisKeyTemplates(undefined)).toEqual([]);
expect(normalizeRedisKeyTemplates([1, "ok"] as unknown[])).toEqual(["ok"]);
});
it("resolves connection templates over global when connection list is non-empty", () => {
expect(resolveRedisKeyTemplates(["conn:a"], ["global:a", "global:b"])).toEqual(["conn:a"]);
expect(resolveRedisKeyTemplates([], ["global:a"])).toEqual(["global:a"]);
expect(resolveRedisKeyTemplates(undefined, ["global:a"])).toEqual(["global:a"]);
expect(resolveRedisKeyTemplates([" ", ""], ["global:a"])).toEqual(["global:a"]);
expect(resolveRedisKeyTemplates(undefined, undefined)).toEqual([]);
});
it("filters by case-insensitive substring and returns all when query is blank", () => {
const templates = ["v3:adui:{$positionID}", "v4:adui:creative_feed:{$arg1}", "cache:session"];
expect(filterRedisKeyTemplates(templates, "")).toEqual(templates);
expect(filterRedisKeyTemplates(templates, " ")).toEqual(templates);
expect(filterRedisKeyTemplates(templates, "ADUI")).toEqual(["v3:adui:{$positionID}", "v4:adui:creative_feed:{$arg1}"]);
expect(filterRedisKeyTemplates(templates, "session")).toEqual(["cache:session"]);
expect(filterRedisKeyTemplates(templates, "missing")).toEqual([]);
});
it("joins templates for textarea editing", () => {
expect(redisKeyTemplatesToTextarea(["a", "b"])).toBe("a\nb");
expect(redisKeyTemplatesToTextarea(undefined)).toBe("");
});
});
@@ -0,0 +1,35 @@
/**
* Helpers for configurable Redis key-search templates.
* Connection-level templates override the global list when non-empty.
*/
export function normalizeRedisKeyTemplates(value: unknown): string[] {
const rawLines = typeof value === "string" ? value.split(/\r?\n/) : Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
const seen = new Set<string>();
const templates: string[] = [];
for (const raw of rawLines) {
const template = raw.trim();
if (!template || seen.has(template)) continue;
seen.add(template);
templates.push(template);
}
return templates;
}
/** Empty connection list means inherit global (not “no templates”). */
export function resolveRedisKeyTemplates(connectionTemplates: string[] | undefined | null, globalTemplates: string[] | undefined | null): string[] {
const fromConnection = normalizeRedisKeyTemplates(connectionTemplates);
if (fromConnection.length > 0) return fromConnection;
return normalizeRedisKeyTemplates(globalTemplates);
}
export function filterRedisKeyTemplates(templates: readonly string[], query: string): string[] {
const q = query.trim().toLowerCase();
if (!q) return [...templates];
return templates.filter((template) => template.toLowerCase().includes(q));
}
export function redisKeyTemplatesToTextarea(templates: readonly string[] | undefined | null): string {
return normalizeRedisKeyTemplates(templates).join("\n");
}
@@ -2,6 +2,7 @@ import type { EditorSettings } from "@/stores/settingsStore";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { normalizeQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit";
import { normalizeCompletionTriggerMode } from "@/lib/sql/sqlCompletionTriggerPolicy";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
export const EDITOR_SETTINGS_DRAFT_KEYS = [
"fontFamily",
@@ -76,6 +77,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"sidebarIndent",
"sidebarFontSize",
"sidebarHiddenTablePrefixes",
"redisKeyTemplates",
"exportBatchSize",
"exportRowLimitEnabled",
"exportRowLimit",
@@ -114,6 +116,7 @@ function normalizedDraftValue(key: EditorSettingsDraftKey, value: unknown): unkn
if (key === "pageSize" || key === "tableOpenPageSize") return normalizeTableOpenPageSizeDraft(value);
if (key === "queryResultMaxRows") return normalizeQueryResultMaxRowsDraft(value);
if (key === "completionTriggerMode") return normalizeCompletionTriggerMode(value);
if (key === "redisKeyTemplates") return normalizeRedisKeyTemplates(value);
return value;
}
@@ -188,6 +188,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "data-grid-compact-header-actions", category: "data", titleKey: "settings.compactColumnHeaderActions", descriptionKey: "settings.compactColumnHeaderActionsDescription", targetId: "data" },
{ id: "data-grid-auto-total", category: "data", titleKey: "settings.autoCalculateTotalRows", descriptionKey: "settings.autoCalculateTotalRowsDescription", targetId: "data" },
{ id: "data-grid-infinite-scroll", category: "data", titleKey: "settings.infiniteScroll", descriptionKey: "settings.infiniteScrollDescription", targetId: "data" },
{ id: "redis-key-templates", category: "data", titleKey: "settings.redisKeyTemplates", descriptionKey: "settings.redisKeyTemplatesDescription", targetId: "redis-key-templates" },
{ id: "data-grid-auto-transpose", category: "data", titleKey: "settings.dataGridAutoTransposeSingleRow", descriptionKey: "settings.dataGridAutoTransposeSingleRowDescription", targetId: "data" },
{ id: "data-grid-quick-entry", category: "data", titleKey: "settings.dataGridQuickEntry", descriptionKey: "settings.dataGridQuickEntryDescription", targetId: "data" },
{ id: "data-grid-filter-view", category: "data", titleKey: "settings.dataGridFilterView", descriptionKey: "settings.dataGridFilterViewDescription", targetId: "data" },
@@ -139,6 +139,7 @@ import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metad
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
import { appendConnectionErrorHints, isMysqlMissingPasswordFailure } from "@/lib/connection/connectionErrorHints";
import { connectionNeedsPasswordPrompt } from "@/lib/connection/connectionPassword";
@@ -1259,6 +1260,10 @@ export const useConnectionStore = defineStore("connection", () => {
idle_timeout_secs: config.idle_timeout_secs ?? 60,
keepalive_interval_secs: config.keepalive_interval_secs ?? DEFAULT_KEEPALIVE_INTERVAL_SECS,
redis_database_aliases: normalizeRedisDatabaseAliases(config.redis_database_aliases),
redis_key_templates: (() => {
const templates = normalizeRedisKeyTemplates(config.redis_key_templates);
return templates.length > 0 ? templates : undefined;
})(),
database_info: normalizeDatabaseConnectionInfo(config.database_info),
};
}
+6
View File
@@ -16,6 +16,7 @@ import { needsTabNavigationHistoryShortcutMigration, normalizeShortcutSettings,
import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort";
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates";
import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
@@ -621,6 +622,8 @@ export interface EditorSettings {
snippets: SqlSnippet[];
tableColumnTemplateFields: string[];
exportBatchSize: number;
/** Global Redis key-search templates; overridden by non-empty connection templates. */
redisKeyTemplates: string[];
exportRowLimitEnabled: boolean;
exportRowLimit: number;
queryExportKeysetOptimizationEnabled: boolean;
@@ -832,6 +835,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
snippets: DEFAULT_SQL_SNIPPETS,
tableColumnTemplateFields: [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS],
exportBatchSize: 2000,
redisKeyTemplates: [],
exportRowLimitEnabled: false,
exportRowLimit: 100000,
queryExportKeysetOptimizationEnabled: true,
@@ -1246,6 +1250,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
snippets: normalizeSqlSnippets(settings.snippets, existing?.snippets),
tableColumnTemplateFields: normalizeTableColumnTemplateFields(settings.tableColumnTemplateFields),
exportBatchSize: typeof settings.exportBatchSize === "number" && settings.exportBatchSize >= 100 && settings.exportBatchSize <= 100000 ? Math.round(settings.exportBatchSize) : DEFAULT_EDITOR_SETTINGS.exportBatchSize,
redisKeyTemplates: normalizeRedisKeyTemplates(settings.redisKeyTemplates),
exportRowLimitEnabled: typeof settings.exportRowLimitEnabled === "boolean" ? settings.exportRowLimitEnabled : DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled,
exportRowLimit: typeof settings.exportRowLimit === "number" && settings.exportRowLimit >= 100 && settings.exportRowLimit <= 2147483647 ? Math.round(settings.exportRowLimit) : DEFAULT_EDITOR_SETTINGS.exportRowLimit,
queryExportKeysetOptimizationEnabled: typeof settings.queryExportKeysetOptimizationEnabled === "boolean" ? settings.queryExportKeysetOptimizationEnabled : DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled,
@@ -1823,6 +1828,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.snippets !== undefined) editorSettings.value.snippets = normalizeSqlSnippets(partial.snippets);
if (partial.tableColumnTemplateFields !== undefined) editorSettings.value.tableColumnTemplateFields = normalizeTableColumnTemplateFields(partial.tableColumnTemplateFields);
if (partial.exportBatchSize !== undefined) editorSettings.value.exportBatchSize = Math.min(100000, Math.max(100, Math.round(partial.exportBatchSize)));
if (partial.redisKeyTemplates !== undefined) editorSettings.value.redisKeyTemplates = normalizeRedisKeyTemplates(partial.redisKeyTemplates);
if (partial.exportRowLimitEnabled !== undefined) editorSettings.value.exportRowLimitEnabled = partial.exportRowLimitEnabled;
if (partial.exportRowLimit !== undefined) editorSettings.value.exportRowLimit = Math.min(2147483647, Math.max(100, Math.round(partial.exportRowLimit)));
if (partial.queryExportKeysetOptimizationEnabled !== undefined) editorSettings.value.queryExportKeysetOptimizationEnabled = partial.queryExportKeysetOptimizationEnabled;
+2
View File
@@ -115,6 +115,8 @@ export interface ConnectionConfig {
redis_key_separator?: string;
redis_scan_page_size?: number;
redis_database_aliases?: Record<string, string>;
/** Key-search templates for the Redis browser. Non-empty overrides global settings. */
redis_key_templates?: string[];
etcd_endpoints?: string;
gbase_server?: string;
informix_server?: string;
@@ -197,6 +197,7 @@ fn connection_config(id: &str, database: BenchDatabase) -> Result<ConnectionConf
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -738,6 +738,7 @@ mod tests {
redis_key_separator: default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -1340,6 +1340,7 @@ for line in sys.stdin:
redis_key_separator: default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+3
View File
@@ -1797,6 +1797,7 @@ mod tests {
redis_key_separator: default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -1857,6 +1858,7 @@ mod tests {
redis_key_separator: default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -2115,6 +2117,7 @@ mod tests {
redis_key_separator: default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -5772,6 +5772,7 @@ mod tests {
redis_key_separator: default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -867,6 +867,7 @@ mod tests {
redis_key_separator: crate::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -5632,6 +5632,7 @@ mod tests {
redis_key_separator: crate::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+8
View File
@@ -153,6 +153,10 @@ pub struct ConnectionConfig {
pub redis_scan_page_size: Option<u64>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub redis_database_aliases: HashMap<String, String>,
/// Optional key-search templates for the Redis key browser (one pattern per entry).
/// Empty means inherit the global editor setting.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redis_key_templates: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub etcd_endpoints: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
@@ -577,6 +581,8 @@ struct ConnectionConfigData {
#[serde(default)]
pub redis_database_aliases: HashMap<String, String>,
#[serde(default)]
pub redis_key_templates: Vec<String>,
#[serde(default)]
pub etcd_endpoints: String,
#[serde(default)]
pub gbase_server: String,
@@ -648,6 +654,7 @@ impl From<ConnectionConfigData> for ConnectionConfig {
redis_key_separator: data.redis_key_separator,
redis_scan_page_size: data.redis_scan_page_size,
redis_database_aliases: data.redis_database_aliases,
redis_key_templates: data.redis_key_templates,
etcd_endpoints: data.etcd_endpoints,
gbase_server: data.gbase_server,
informix_server: data.informix_server,
@@ -2631,6 +2638,7 @@ mod tests {
redis_key_separator: default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -222,6 +222,7 @@ mod tests {
redis_key_separator: String::new(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -917,6 +917,7 @@ mod tests {
redis_key_separator: ":".to_string(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -409,6 +409,7 @@ mod tests {
redis_key_separator: ":".to_string(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+2
View File
@@ -676,6 +676,7 @@ mod tests {
redis_key_separator: ":".to_string(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -751,6 +752,7 @@ mod tests {
redis_key_separator: ":".to_string(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -686,6 +686,7 @@ mod tests {
redis_key_separator: ":".to_string(),
redis_scan_page_size: Some(1000),
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+2
View File
@@ -5658,6 +5658,7 @@ for line in sys.stdin:
redis_key_separator: default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -7648,6 +7649,7 @@ for line in sys.stdin:
redis_key_separator: default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -3259,6 +3259,7 @@ mod tests {
redis_key_separator: crate::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+2
View File
@@ -4799,6 +4799,7 @@ mod tests {
redis_key_separator: ":".to_string(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -4866,6 +4867,7 @@ mod tests {
redis_key_separator: ":".to_string(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -11875,6 +11875,7 @@ SELECT 1 FROM dual"#
redis_key_separator: crate::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -64,6 +64,7 @@ fn live_postgres_config(
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -60,6 +60,7 @@ fn live_postgres_config(
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -55,6 +55,7 @@ fn live_postgres_config(
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -60,6 +60,7 @@ fn live_postgres_config(
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -52,6 +52,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -61,6 +61,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
@@ -50,6 +50,7 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -174,6 +174,7 @@ pub fn postgres_test_config(id: &str, port: u16) -> ConnectionConfig {
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -813,6 +813,7 @@ mod tests {
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -594,6 +594,7 @@ mod tests {
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
+1
View File
@@ -269,6 +269,7 @@ mod tests {
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),