feat(sidebar): add visible database filters

This commit is contained in:
t8y2 2026-05-15 17:36:51 +08:00
parent b15e22033e
commit 08de206644
13 changed files with 395 additions and 14 deletions

View File

@ -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(),

View File

@ -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(),

View File

@ -17,6 +17,8 @@ pub struct ConnectionConfig {
pub username: String,
pub password: String,
pub database: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_databases: Option<Vec<String>>,
#[serde(default)]
pub color: Option<String>,
#[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);

View File

@ -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(),

View File

@ -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
<ContextMenuItem @click="refresh">
<RefreshCw class="w-4 h-4" /> {{ t("contextMenu.refreshChildren") }}
</ContextMenuItem>
<ContextMenuItem v-if="canConfigureVisibleDatabases" @click="openVisibleDatabasesDialog">
<ListFilter class="w-4 h-4" /> {{ t("contextMenu.selectVisibleDatabases") }}
</ContextMenuItem>
<ContextMenuItem @click="editConnection">
<Pencil class="w-4 h-4" /> {{ t("contextMenu.editConnection") }}
</ContextMenuItem>
@ -1787,6 +1801,13 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
</ContextMenuContent>
</ContextMenu>
<VisibleDatabasesDialog
v-if="node.type === 'connection' && node.connectionId"
v-model:open="showVisibleDatabasesDialog"
:connection-id="node.connectionId"
:connection-name="node.label"
/>
<Dialog v-model:open="showDeleteConfirm">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>

View File

@ -0,0 +1,177 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { CheckSquare, Loader2, Search, Square } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { useConnectionStore } from "@/stores/connectionStore";
import { normalizeVisibleDatabaseSelection } from "@/lib/visibleDatabases";
import * as api from "@/lib/api";
const props = defineProps<{
open: boolean;
connectionId: string;
connectionName: string;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
const { t } = useI18n();
const connectionStore = useConnectionStore();
const { getDatabaseOptions } = useDatabaseOptions();
const databaseNames = ref<string[]>([]);
const selectedNames = ref<Set<string>>(new Set());
const searchText = ref("");
const isLoading = ref(false);
const errorMessage = ref("");
const connection = computed(() => connectionStore.getConfig(props.connectionId));
const filteredDatabaseNames = computed(() => {
const query = searchText.value.trim().toLowerCase();
if (!query) return databaseNames.value;
return databaseNames.value.filter((name) => name.toLowerCase().includes(query));
});
const selectedCount = computed(() => selectedNames.value.size);
const totalCount = computed(() => databaseNames.value.length);
watch(
() => props.open,
(open) => {
if (!open) return;
loadDatabases().catch(() => {});
},
);
async function loadDatabases() {
isLoading.value = true;
errorMessage.value = "";
searchText.value = "";
try {
const names = await loadDatabaseNames();
databaseNames.value = names;
const configured = connection.value?.visible_databases;
const initialSelection = Array.isArray(configured) ? normalizeVisibleDatabaseSelection(configured, names) : names;
selectedNames.value = new Set(initialSelection);
} catch (e: any) {
databaseNames.value = [];
selectedNames.value = new Set();
errorMessage.value = String(e?.message || e);
} finally {
isLoading.value = false;
}
}
async function loadDatabaseNames(): Promise<string[]> {
const config = connection.value;
if (config?.db_type === "oracle" || config?.db_type === "dameng") {
await connectionStore.ensureConnected(props.connectionId);
return api.listSchemas(props.connectionId, config.database || "");
}
return getDatabaseOptions(props.connectionId);
}
function toggleDatabase(database: string) {
const next = new Set(selectedNames.value);
if (next.has(database)) next.delete(database);
else next.add(database);
selectedNames.value = next;
}
function selectAll() {
selectedNames.value = new Set(databaseNames.value);
}
function clearSelection() {
selectedNames.value = new Set();
}
async function showAllDatabases() {
await connectionStore.clearVisibleDatabases(props.connectionId);
emit("update:open", false);
}
async function saveSelection() {
await connectionStore.setVisibleDatabases(props.connectionId, [...selectedNames.value]);
emit("update:open", false);
}
</script>
<template>
<Dialog :open="open" @update:open="(value: boolean) => emit('update:open', value)">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("visibleDatabases.title") }}</DialogTitle>
<p class="text-sm text-muted-foreground">
{{ t("visibleDatabases.description", { connection: connectionName }) }}
</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input
v-model="searchText"
:placeholder="t('visibleDatabases.searchPlaceholder')"
class="h-8 border-0 px-0 shadow-none focus-visible:ring-0"
:disabled="isLoading || !!errorMessage"
/>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("visibleDatabases.selectedCount", { selected: selectedCount, total: totalCount }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="selectAll">
{{ t("visibleDatabases.selectAll") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoading" @click="clearSelection">
{{ t("visibleDatabases.clear") }}
</button>
<button
class="hover:text-foreground disabled:opacity-50"
:disabled="isLoading || !Array.isArray(connection?.visible_databases)"
@click="showAllDatabases"
>
{{ t("visibleDatabases.showAll") }}
</button>
</div>
</div>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoading" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="errorMessage" class="p-3 text-sm text-destructive">
{{ t("visibleDatabases.loadFailed", { message: errorMessage }) }}
</div>
<div v-else-if="!filteredDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
<template v-else>
<button
v-for="database in filteredDatabaseNames"
:key="database"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleDatabase(database)"
>
<CheckSquare v-if="selectedNames.has(database)" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate">{{ database }}</span>
</button>
</template>
</div>
<DialogFooter>
<Button variant="outline" @click="emit('update:open', false)">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoading || !!errorMessage" @click="saveSelection">
{{ t("visibleDatabases.save") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -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",

View File

@ -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",

View File

@ -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 插件",

View File

@ -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<string>();
return selectedNames.filter((name) => {
if (!available.has(name) || seen.has(name)) return false;
seen.add(name);
return true;
});
}

View File

@ -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,

View File

@ -43,6 +43,7 @@ export interface ConnectionConfig {
username: string;
password: string;
database?: string;
visible_databases?: string[];
color?: string;
ssh_enabled?: boolean;
ssh_host?: string;

View File

@ -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",
]);
});