From 08de206644e7dbdc705263d34bd7ba6080f73cd6 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 15 May 2026 17:36:51 +0800 Subject: [PATCH] feat(sidebar): add visible database filters --- crates/dbx-core/src/connection.rs | 1 + crates/dbx-core/src/connection_secrets.rs | 1 + crates/dbx-core/src/models/connection.rs | 23 +++ src-web/src/routes/connection.rs | 1 + src/components/sidebar/TreeItem.vue | 21 +++ .../sidebar/VisibleDatabasesDialog.vue | 177 ++++++++++++++++++ src/i18n/locales/en.ts | 15 ++ src/i18n/locales/es.ts | 15 ++ src/i18n/locales/zh-CN.ts | 15 ++ src/lib/visibleDatabases.ts | 19 ++ src/stores/connectionStore.ts | 88 +++++++-- src/types/database.ts | 1 + tests/visibleDatabases.test.ts | 32 ++++ 13 files changed, 395 insertions(+), 14 deletions(-) create mode 100644 src/components/sidebar/VisibleDatabasesDialog.vue create mode 100644 src/lib/visibleDatabases.ts create mode 100644 tests/visibleDatabases.test.ts diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index f1a506dee..3d9f313c5 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -485,6 +485,7 @@ mod tests { username: "root".to_string(), password: "secret".to_string(), database: database.map(str::to_string), + visible_databases: None, color: None, ssh_enabled: false, ssh_host: String::new(), diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index 0ef74783d..35b56759b 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -288,6 +288,7 @@ mod tests { username: "postgres".to_string(), password: password.to_string(), database: Some("postgres".to_string()), + visible_databases: None, color: None, ssh_enabled: !ssh_password.is_empty(), ssh_host: String::new(), diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 08838aec7..a06fc460a 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -17,6 +17,8 @@ pub struct ConnectionConfig { pub username: String, pub password: String, pub database: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visible_databases: Option>, #[serde(default)] pub color: Option, #[serde(default)] @@ -550,6 +552,7 @@ mod tests { username: username.to_string(), password: password.to_string(), database: database.map(str::to_string), + visible_databases: None, color: None, ssh_enabled: false, ssh_host: String::new(), @@ -622,6 +625,26 @@ mod tests { assert_eq!(config.proxy_password, ""); } + #[test] + fn visible_databases_round_trips_through_connection_config() { + let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ + "id": "id", + "name": "name", + "db_type": "mysql", + "host": "10.1.2.3", + "port": 3306, + "username": "root", + "password": "", + "database": null, + "visible_databases": ["app", "billing"] + })) + .unwrap(); + + let saved = serde_json::to_value(config).unwrap(); + + assert_eq!(saved["visible_databases"], serde_json::json!(["app", "billing"])); + } + #[test] fn ssh_connect_timeout_zero_uses_default() { let mut config = mysql_config("root", "", None); diff --git a/src-web/src/routes/connection.rs b/src-web/src/routes/connection.rs index ac8a4001b..389bacf14 100644 --- a/src-web/src/routes/connection.rs +++ b/src-web/src/routes/connection.rs @@ -141,6 +141,7 @@ mod tests { username: String::new(), password: String::new(), database: None, + visible_databases: None, color: None, ssh_enabled: false, ssh_host: String::new(), diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index c9771ed06..c76a68e4c 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -42,6 +42,7 @@ import { ScrollText, Braces, Code2, + ListFilter, } from "lucide-vue-next"; import { ContextMenu, @@ -91,6 +92,7 @@ import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue"; +import VisibleDatabasesDialog from "@/components/sidebar/VisibleDatabasesDialog.vue"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -108,6 +110,7 @@ const queryStore = useQueryStore(); const savedSqlStore = useSavedSqlStore(); const { toast } = useToast(); const { getDatabaseOptions } = useDatabaseOptions(); +const showVisibleDatabasesDialog = ref(false); const props = defineProps<{ node: TreeNode; @@ -1214,6 +1217,10 @@ const isConnected = computed( !!props.node.connectionId && connectionStore.connectedIds.has(props.node.connectionId), ); +const canConfigureVisibleDatabases = computed(() => { + if (props.node.type !== "connection" || !props.node.connectionId) return false; + return connectionStore.getConfig(props.node.connectionId)?.db_type !== "elasticsearch"; +}); function connectionIconType(connectionId?: string) { const config = connectionId ? connectionStore.getConfig(connectionId) : undefined; @@ -1240,6 +1247,10 @@ function togglePin() { connectionStore.toggleTreeNodePin(props.node.id); } +function openVisibleDatabasesDialog() { + showVisibleDatabasesDialog.value = true; +} + // --- Connection Group Management --- const isRenamingGroup = ref(false); const renameInput = ref(""); @@ -1581,6 +1592,9 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr {{ t("contextMenu.refreshChildren") }} + + {{ t("contextMenu.selectVisibleDatabases") }} + {{ t("contextMenu.editConnection") }} @@ -1787,6 +1801,13 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr + + diff --git a/src/components/sidebar/VisibleDatabasesDialog.vue b/src/components/sidebar/VisibleDatabasesDialog.vue new file mode 100644 index 000000000..5b29916b6 --- /dev/null +++ b/src/components/sidebar/VisibleDatabasesDialog.vue @@ -0,0 +1,177 @@ + + + diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 41a3c5c54..6ad2d9175 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -561,6 +561,7 @@ export default { confirmDeleteTitle: "Delete Connection", confirmDeleteMessage: 'Are you sure you want to delete "{name}"? This cannot be undone.', editConnection: "Edit Connection", + selectVisibleDatabases: "Select Visible Databases", duplicateConnection: "Duplicate Connection", newQuery: "New Query", openObjectBrowser: "Browse Objects", @@ -631,6 +632,17 @@ export default { dropSchemaSuccess: 'Schema "{name}" dropped', createSchemaNamePlaceholder: "Schema name", }, + visibleDatabases: { + title: "Visible Databases", + description: 'Choose which databases are shown under "{connection}".', + searchPlaceholder: "Search databases...", + selectedCount: "{selected}/{total} selected", + selectAll: "Select all", + clear: "Clear", + showAll: "Show all", + save: "Save", + loadFailed: "Failed to load databases: {message}", + }, tree: { savedSql: "SQL Library", columns: "Columns", @@ -1014,6 +1026,9 @@ export default { executeModeCurrent: "Execute statement at cursor", wordWrap: "Word wrap", wordWrapDescription: "Wrap long SQL lines within the editor width", + redisScanPageSize: "Redis scan count", + redisScanPageSizeDescription: "Keys requested per Redis SCAN page when browsing keys.", + redisScanPageSizeOption: "{count} keys", preview: "Live Preview", jdbcPlugin: "DBX JDBC plugin", jdbcPluginInstall: "Install JDBC plugin", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index d35d28723..a6d77ed55 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -539,6 +539,7 @@ export default { confirmDeleteTitle: "Eliminar conexión", confirmDeleteMessage: '¿Estás seguro de que deseas eliminar "{name}"? Esta acción no se puede deshacer.', editConnection: "Editar conexión", + selectVisibleDatabases: "Seleccionar bases visibles", duplicateConnection: "Duplicar conexión", newQuery: "Nueva consulta", openObjectBrowser: "Explorar objetos", @@ -609,6 +610,17 @@ export default { dropSchemaSuccess: 'Esquema "{name}" eliminado', createSchemaNamePlaceholder: "Nombre del esquema", }, + visibleDatabases: { + title: "Bases de datos visibles", + description: 'Elige qué bases de datos se muestran bajo "{connection}".', + searchPlaceholder: "Buscar bases de datos...", + selectedCount: "{selected}/{total} seleccionadas", + selectAll: "Seleccionar todo", + clear: "Limpiar", + showAll: "Mostrar todo", + save: "Guardar", + loadFailed: "No se pudieron cargar las bases de datos: {message}", + }, tree: { savedSql: "Biblioteca SQL", columns: "Columnas", @@ -991,6 +1003,9 @@ export default { executeModeCurrent: "Ejecutar sentencia en el cursor", wordWrap: "Ajuste de línea", wordWrapDescription: "Ajustar las líneas largas de SQL al ancho del editor", + redisScanPageSize: "Cantidad de escaneo Redis", + redisScanPageSizeDescription: "Claves solicitadas por página SCAN al explorar claves Redis.", + redisScanPageSizeOption: "{count} claves", preview: "Vista previa en tiempo real", jdbcPlugin: "Plugin JDBC de DBX", jdbcPluginInstall: "Instalar plugin JDBC", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index f9a8fd630..da4dced95 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -549,6 +549,7 @@ export default { confirmDeleteTitle: "删除连接", confirmDeleteMessage: "确定要删除「{name}」吗?此操作不可撤销。", editConnection: "编辑连接", + selectVisibleDatabases: "选择显示数据库", duplicateConnection: "复制连接", newQuery: "新建查询", openObjectBrowser: "浏览对象", @@ -616,6 +617,17 @@ export default { dropSchemaSuccess: "Schema「{name}」已删除", createSchemaNamePlaceholder: "Schema 名称", }, + visibleDatabases: { + title: "显示数据库", + description: "选择「{connection}」下要在侧边栏显示的数据库。", + searchPlaceholder: "搜索数据库...", + selectedCount: "已选择 {selected}/{total}", + selectAll: "全选", + clear: "清空", + showAll: "显示全部", + save: "保存", + loadFailed: "加载数据库失败:{message}", + }, tree: { savedSql: "SQL 库", columns: "字段", @@ -996,6 +1008,9 @@ export default { executeModeCurrent: "执行光标所在语句", wordWrap: "自动换行", wordWrapDescription: "长 SQL 在编辑器宽度内自动折行显示", + redisScanPageSize: "Redis 扫描数量", + redisScanPageSizeDescription: "浏览 Redis Key 时每次 SCAN 请求的 Key 数量。", + redisScanPageSizeOption: "{count} 个 Key", preview: "实时预览", jdbcPlugin: "DBX JDBC 插件", jdbcPluginInstall: "安装 JDBC 插件", diff --git a/src/lib/visibleDatabases.ts b/src/lib/visibleDatabases.ts new file mode 100644 index 000000000..7cd6a3063 --- /dev/null +++ b/src/lib/visibleDatabases.ts @@ -0,0 +1,19 @@ +export function visibleDatabaseFilterIsEnabled(visibleDatabases: string[] | undefined): boolean { + return Array.isArray(visibleDatabases); +} + +export function filterVisibleDatabaseNames(databaseNames: string[], visibleDatabases: string[] | undefined): string[] { + if (!visibleDatabaseFilterIsEnabled(visibleDatabases)) return databaseNames; + const visible = new Set(visibleDatabases); + return databaseNames.filter((name) => visible.has(name)); +} + +export function normalizeVisibleDatabaseSelection(selectedNames: string[], databaseNames: string[]): string[] { + const available = new Set(databaseNames); + const seen = new Set(); + return selectedNames.filter((name) => { + if (!available.has(name) || seen.has(name)) return false; + seen.add(name); + return true; + }); +} diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 5e3adb6a6..738225f88 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -24,6 +24,7 @@ import { isSchemaAware, usesTreeSchemaMode } from "@/lib/databaseCapabilities"; import { buildDatabaseTreeNodes } from "@/lib/databaseTree"; import { buildSqlServerDatabaseTreeNodes, SQLSERVER_DEFAULT_SCHEMA } from "@/lib/sqlServerTree"; import { shouldMarkDisconnected } from "@/lib/connectionHealth"; +import { filterVisibleDatabaseNames, normalizeVisibleDatabaseSelection } from "@/lib/visibleDatabases"; import { buildGroupedObjectTreeNodes, buildTableTreeNodes, @@ -496,6 +497,46 @@ export const useConnectionStore = defineStore("connection", () => { return getConfig(connectionId)?.database === database && database !== ""; } + async function setVisibleDatabases(connectionId: string, databaseNames: string[]) { + const config = getConfig(connectionId); + if (!config) return; + await updateVisibleDatabasesConfig(connectionId, normalizeVisibleDatabaseSelection(databaseNames, databaseNames)); + await reloadConnectionDatabaseChildren(connectionId); + } + + async function clearVisibleDatabases(connectionId: string) { + const config = getConfig(connectionId); + if (!config || !Array.isArray(config.visible_databases)) return; + await updateVisibleDatabasesConfig(connectionId, undefined); + await reloadConnectionDatabaseChildren(connectionId); + } + + async function updateVisibleDatabasesConfig(connectionId: string, visibleDatabases: string[] | undefined) { + const idx = connections.value.findIndex((connection) => connection.id === connectionId); + if (idx < 0) return; + const nextConnections = [...connections.value]; + nextConnections[idx] = { + ...nextConnections[idx], + visible_databases: visibleDatabases, + }; + await persistConnections(nextConnections); + connections.value = nextConnections; + rebuildTreeNodes(); + } + + async function reloadConnectionDatabaseChildren(connectionId: string) { + const config = getConfig(connectionId); + if (!config) return; + clearLoadedChildrenCache(connectionId); + if (config.db_type === "redis") { + await loadRedisDatabases(connectionId); + } else if (config.db_type === "mongodb") { + await loadMongoDatabases(connectionId); + } else { + await loadDatabases(connectionId, { force: true }); + } + } + async function connect(config: ConnectionConfig) { config = normalizeConnection(config); const pendingNode = findNode(treeNodes.value, config.id); @@ -579,7 +620,8 @@ export const useConnectionStore = defineStore("connection", () => { const cacheKey = schemaCacheKey(connectionId, effectiveDb, "schemas"); if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return; const schemas = await api.listSchemas(connectionId, effectiveDb); - const schemaNodes: TreeNode[] = schemas.map((s) => ({ + const visibleSchemas = filterVisibleDatabaseNames(schemas, config?.visible_databases); + const schemaNodes: TreeNode[] = visibleSchemas.map((s) => ({ id: `${connectionId}:${s}:${s}`, label: s, type: "schema" as const, @@ -595,7 +637,13 @@ export const useConnectionStore = defineStore("connection", () => { const cacheKey = schemaCacheKey(connectionId, "databases"); if (!options?.force && (await loadPersistedTreeChildren(node, cacheKey))) return; const databases = await api.listDatabases(connectionId); - const children = withSavedSqlRoot(connectionId, buildDatabaseTreeNodes(connectionId, databases), node); + const visibleNames = filterVisibleDatabaseNames( + databases.map((database) => database.name), + config?.visible_databases, + ); + const visibleNameSet = new Set(visibleNames); + const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name)); + const children = withSavedSqlRoot(connectionId, buildDatabaseTreeNodes(connectionId, visibleDatabases), node); setChildren(node, children); await savePersistedTreeChildren(cacheKey, children); } @@ -616,21 +664,29 @@ export const useConnectionStore = defineStore("connection", () => { try { await ensureConnected(connectionId); const dbs = await api.redisListDatabases(connectionId); + const config = getConfig(connectionId); + const visibleNames = filterVisibleDatabaseNames( + dbs.map((db) => String(db.db)), + config?.visible_databases, + ); + const visibleNameSet = new Set(visibleNames); setChildren( node, withSavedSqlRoot( connectionId, - dbs.map((db) => ({ - id: `${connectionId}:db${db.db}`, - label: redisDbLabel(db.db, 0, db.keys), - type: "redis-db" as const, - connectionId, - database: String(db.db), - loadedKeyCount: 0, - totalKeyCount: db.keys, - isExpanded: false, - children: [], - })), + dbs + .filter((db) => visibleNameSet.has(String(db.db))) + .map((db) => ({ + id: `${connectionId}:db${db.db}`, + label: redisDbLabel(db.db, 0, db.keys), + type: "redis-db" as const, + connectionId, + database: String(db.db), + loadedKeyCount: 0, + totalKeyCount: db.keys, + isExpanded: false, + children: [], + })), node, ), ); @@ -666,11 +722,13 @@ export const useConnectionStore = defineStore("connection", () => { try { await ensureConnected(connectionId); const dbs = await api.mongoListDatabases(connectionId); + const config = getConfig(connectionId); + const visibleDbs = filterVisibleDatabaseNames(dbs, config?.visible_databases); setChildren( node, withSavedSqlRoot( connectionId, - dbs.map((db) => ({ + visibleDbs.map((db) => ({ id: `${connectionId}:${db}`, label: db, type: "mongo-db" as const, @@ -1524,6 +1582,8 @@ export const useConnectionStore = defineStore("connection", () => { setDefaultDatabase, clearDefaultDatabase, isDefaultDatabase, + setVisibleDatabases, + clearVisibleDatabases, removeConnection, editingConnectionId, newConnectionGroupId, diff --git a/src/types/database.ts b/src/types/database.ts index 561539ce8..dcdbd9149 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -43,6 +43,7 @@ export interface ConnectionConfig { username: string; password: string; database?: string; + visible_databases?: string[]; color?: string; ssh_enabled?: boolean; ssh_host?: string; diff --git a/tests/visibleDatabases.test.ts b/tests/visibleDatabases.test.ts new file mode 100644 index 000000000..65852a13d --- /dev/null +++ b/tests/visibleDatabases.test.ts @@ -0,0 +1,32 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + filterVisibleDatabaseNames, + normalizeVisibleDatabaseSelection, + visibleDatabaseFilterIsEnabled, +} from "../src/lib/visibleDatabases.ts"; + +test("undefined visible database filter keeps every database", () => { + assert.deepEqual(filterVisibleDatabaseNames(["app", "analytics"], undefined), ["app", "analytics"]); + assert.equal(visibleDatabaseFilterIsEnabled(undefined), false); +}); + +test("configured visible database filter keeps selected databases in source order", () => { + assert.deepEqual(filterVisibleDatabaseNames(["app", "analytics", "billing"], ["billing", "app"]), [ + "app", + "billing", + ]); + assert.equal(visibleDatabaseFilterIsEnabled(["billing", "app"]), true); +}); + +test("empty configured visible database filter hides every database", () => { + assert.deepEqual(filterVisibleDatabaseNames(["app", "analytics"], []), []); + assert.equal(visibleDatabaseFilterIsEnabled([]), true); +}); + +test("normalizes selected database names against fresh database names", () => { + assert.deepEqual(normalizeVisibleDatabaseSelection(["billing", "missing", "app", "app"], ["app", "billing"]), [ + "billing", + "app", + ]); +});