feat(connection): 增加可见数据库选择功能 (#944)

感谢 @eryajf 的贡献!此功能允许用户在创建或编辑连接时选择可见的数据库,支持多种数据库类型。
This commit is contained in:
二丫讲梵 2026-06-09 20:56:39 +08:00 committed by GitHub
parent ae483efb98
commit 41758e6bb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 544 additions and 9 deletions

View File

@ -40,6 +40,7 @@ import {
ArrowLeft,
ArrowDown,
ArrowUp,
CheckSquare,
ChevronRight,
Copy,
ExternalLink,
@ -50,12 +51,27 @@ import {
KeyRound,
Link2,
List,
ListFilter,
Loader2,
Pipette,
Plus,
Search,
ShieldCheck,
Square,
Trash2,
} from "@lucide/vue";
import {
buildDraftVisibleDatabasesConnectionId,
connectionCanChooseVisibleDatabases,
initialVisibleDatabaseSelection,
visibleDatabaseSelectionIsStale,
} from "@/lib/connectionVisibleDatabases";
import {
canSaveVisibleDatabaseSelection,
filterDatabaseNamesForConnection,
isSystemDatabaseName,
normalizeVisibleDatabaseSelection,
} from "@/lib/visibleDatabases";
type DbOption = { value: string; label: string };
type DbCategory = { key: string; title: string; options: DbOption[] };
@ -107,6 +123,13 @@ const isTesting = ref(false);
const isSaving = ref(false);
const testResult = ref<{ ok: boolean; message: string } | null>(null);
const editingId = ref<string | null>(null);
const showVisibleDatabasesDialog = ref(false);
const isLoadingVisibleDatabases = ref(false);
const visibleDatabaseNames = ref<string[]>([]);
const visibleDatabaseSelection = ref<Set<string>>(new Set());
const visibleDatabaseSearchText = ref("");
const visibleDatabaseError = ref("");
const visibleDatabaseShowSystem = ref(false);
let testRunId = 0;
const defaultForm = (): ConnectionForm => ({
@ -142,6 +165,7 @@ const defaultForm = (): ConnectionForm => ({
redis_sentinel_tls: false,
redis_cluster_nodes: "",
etcd_endpoints: "",
visible_databases: undefined,
});
function defaultSshTunnel(): SshTunnelConfig {
@ -573,6 +597,7 @@ watch(
redis_sentinel_tls: config.redis_sentinel_tls || false,
redis_cluster_nodes: config.redis_cluster_nodes || "",
etcd_endpoints: config.etcd_endpoints || "",
visible_databases: config.visible_databases,
};
h2ConnectionMode.value = h2ConnectionModeForConfig(config);
customColorInput.value = config.color || "";
@ -950,6 +975,30 @@ const canUseTransportLayers = computed(
const shouldShowAgentDriverInstallHint = computed(() =>
showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile),
);
const canChooseVisibleDatabases = computed(() => connectionCanChooseVisibleDatabases(form.value));
const hasVisibleDatabaseFilter = computed(() => Array.isArray(form.value.visible_databases));
const visibleDatabaseSummary = computed(() => {
const configured = form.value.visible_databases;
if (!Array.isArray(configured)) return t("visibleDatabases.showAll");
return t("visibleDatabases.selectedCount", { selected: configured.length, total: visibleDatabaseNames.value.length });
});
const listedVisibleDatabaseNames = computed(() => {
const connection = connectionConfigSnapshotForVisibleDatabases();
if (visibleDatabaseShowSystem.value) return visibleDatabaseNames.value;
return filterDatabaseNamesForConnection(visibleDatabaseNames.value, connection);
});
const filteredVisibleDatabaseNames = computed(() => {
const query = visibleDatabaseSearchText.value.trim().toLowerCase();
if (!query) return listedVisibleDatabaseNames.value;
return listedVisibleDatabaseNames.value.filter((name) => name.toLowerCase().includes(query));
});
const visibleDatabaseSelectedCount = computed(() => visibleDatabaseSelection.value.size);
const visibleDatabaseTotalCount = computed(() => listedVisibleDatabaseNames.value.length);
const visibleDatabaseCanSave = computed(() => canSaveVisibleDatabaseSelection([...visibleDatabaseSelection.value]));
const visibleDatabaseHasSystemDatabases = computed(() => {
const connection = connectionConfigSnapshotForVisibleDatabases();
return visibleDatabaseNames.value.some((database) => isSystemDatabaseName(connection.db_type, database));
});
const testResultMessage = computed(() => {
if (!testResult.value) return "";
return testResult.value.ok ? t("connection.testSuccess") : testResult.value.message;
@ -1190,9 +1239,21 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
delete legacy.proxy_port;
delete legacy.proxy_username;
delete legacy.proxy_password;
config.visible_databases =
Array.isArray(config.visible_databases) && config.visible_databases.length > 0
? config.visible_databases
: undefined;
return config as ConnectionConfig;
}
function connectionConfigSnapshotForVisibleDatabases(): ConnectionConfig {
return {
...(form.value as ConnectionConfig),
id: editingId.value || "draft",
visible_databases: form.value.visible_databases,
};
}
function getUrlParam(params: string | undefined, key: string): string {
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
return parsed.get(key) || "";
@ -1401,6 +1462,95 @@ function resetTestState() {
testResult.value = null;
}
function resetVisibleDatabaseDraftState() {
showVisibleDatabasesDialog.value = false;
isLoadingVisibleDatabases.value = false;
visibleDatabaseNames.value = [];
visibleDatabaseSelection.value = new Set();
visibleDatabaseSearchText.value = "";
visibleDatabaseError.value = "";
visibleDatabaseShowSystem.value = false;
}
async function openVisibleDatabasesPicker() {
if (!ensureConnectionHostResolvedFromUrl()) return;
if (!canChooseVisibleDatabases.value || isLoadingVisibleDatabases.value) return;
isLoadingVisibleDatabases.value = true;
visibleDatabaseError.value = "";
visibleDatabaseSearchText.value = "";
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
const draftConfig = {
...connectionConfigForSubmit(draftId),
id: draftId,
one_time: true,
};
try {
await api.connectDb(draftConfig);
const names = await loadVisibleDatabaseNames(draftId, draftConfig);
visibleDatabaseNames.value = names;
const initialSelection = initialVisibleDatabaseSelection(names, form.value.visible_databases, draftConfig);
visibleDatabaseSelection.value = new Set(initialSelection);
visibleDatabaseShowSystem.value = initialSelection.some((database) =>
isSystemDatabaseName(draftConfig.db_type, database),
);
showVisibleDatabasesDialog.value = true;
} catch (e: any) {
visibleDatabaseNames.value = [];
visibleDatabaseSelection.value = new Set();
visibleDatabaseError.value = mongodbAuthFailureHint(String(e?.message || e));
testResult.value = { ok: false, message: visibleDatabaseError.value };
} finally {
await api.disconnectDb(draftId).catch(() => undefined);
isLoadingVisibleDatabases.value = false;
}
}
async function loadVisibleDatabaseNames(connectionId: string, config: ConnectionConfig): Promise<string[]> {
if (config.db_type === "oracle" || config.db_type === "dameng") {
return api.listSchemas(connectionId, config.database || "");
}
if (config.db_type === "redis") {
return (await api.redisListDatabases(connectionId)).map((database) => String(database.db));
}
if (config.db_type === "mongodb") {
return api.mongoListDatabases(connectionId);
}
return (await api.listDatabases(connectionId)).map((database) => database.name);
}
function toggleVisibleDatabase(database: string) {
const next = new Set(visibleDatabaseSelection.value);
if (next.has(database)) next.delete(database);
else next.add(database);
visibleDatabaseSelection.value = next;
}
function selectAllVisibleDatabases() {
visibleDatabaseSelection.value = new Set(listedVisibleDatabaseNames.value);
}
function clearVisibleDatabaseSelection() {
visibleDatabaseSelection.value = new Set();
}
function showAllVisibleDatabases() {
form.value.visible_databases = undefined;
visibleDatabaseSelection.value = new Set();
visibleDatabaseNames.value = [];
showVisibleDatabasesDialog.value = false;
}
function saveVisibleDatabaseSelection() {
if (!visibleDatabaseCanSave.value) return;
form.value.visible_databases = normalizeVisibleDatabaseSelection(
[...visibleDatabaseSelection.value],
visibleDatabaseNames.value,
);
showVisibleDatabasesDialog.value = false;
}
function applyConnectionUrl() {
if (applyConnectionUrlToForm(connectionUrlInput.value)) {
toast(t("connection.parseConnectionUrlApplied"), 2000);
@ -1433,6 +1583,7 @@ function resetForm() {
dbPickerView.value = "icon";
dbSearchQuery.value = "";
configTab.value = "connection";
resetVisibleDatabaseDraftState();
resetTestState();
}
@ -1536,6 +1687,25 @@ watch([() => form.value.db_type, () => form.value.username], () => {
if (isOracleSysUser(form.value)) form.value.sysdba = true;
});
watch(
() => connectionConfigSnapshotForVisibleDatabases(),
(current, previous) => {
if (!previous || !form.value.visible_databases?.length) return;
if (!visibleDatabaseSelectionIsStale(previous, current)) return;
form.value.visible_databases = undefined;
visibleDatabaseNames.value = [];
visibleDatabaseSelection.value = new Set();
},
);
watch(visibleDatabaseShowSystem, (show) => {
if (show) return;
const connection = connectionConfigSnapshotForVisibleDatabases();
visibleDatabaseSelection.value = new Set(
[...visibleDatabaseSelection.value].filter((database) => !isSystemDatabaseName(connection.db_type, database)),
);
});
watch(canUseTransportLayers, (value) => {
if (!value && configTab.value === "transport") {
configTab.value = "connection";
@ -3447,6 +3617,17 @@ function openExternalUrl(url: string) {
</Button>
</template>
</div>
<Button
v-if="canChooseVisibleDatabases"
variant="outline"
class="shrink-0"
:disabled="isTesting || isSaving || isLoadingVisibleDatabases || !hasRequiredConnectionTarget"
@click="openVisibleDatabasesPicker"
>
<Loader2 v-if="isLoadingVisibleDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ hasVisibleDatabaseFilter ? visibleDatabaseSummary : t("contextMenu.selectVisibleDatabases") }}
</Button>
<Button variant="outline" class="shrink-0" :disabled="isTesting || isSaving" @click="testConnection">
{{ isTesting ? t("connection.testing") : t("connection.test") }}
</Button>
@ -3463,4 +3644,117 @@ function openExternalUrl(url: string) {
</template>
</DialogContent>
</Dialog>
<Dialog v-model:open="showVisibleDatabasesDialog">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("visibleDatabases.title") }}</DialogTitle>
<p class="text-sm text-muted-foreground">
{{ t("visibleDatabases.description", { connection: form.name || selectedProfile().label }) }}
</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="visibleDatabaseSearchText"
:placeholder="t('visibleDatabases.searchPlaceholder')"
class="h-8 border-0 px-0 shadow-none focus-visible:ring-0"
:disabled="isLoadingVisibleDatabases || !!visibleDatabaseError"
/>
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>
{{
t("visibleDatabases.selectedCount", {
selected: visibleDatabaseSelectedCount,
total: visibleDatabaseTotalCount,
})
}}
</span>
<div class="flex items-center gap-2">
<button
class="hover:text-foreground disabled:opacity-50"
:disabled="isLoadingVisibleDatabases"
@click="selectAllVisibleDatabases"
>
{{ t("visibleDatabases.selectAll") }}
</button>
<button
class="hover:text-foreground disabled:opacity-50"
:disabled="isLoadingVisibleDatabases"
@click="clearVisibleDatabaseSelection"
>
{{ t("visibleDatabases.clear") }}
</button>
<button
class="hover:text-foreground disabled:opacity-50"
:disabled="isLoadingVisibleDatabases"
@click="showAllVisibleDatabases"
>
{{ t("visibleDatabases.showAll") }}
</button>
</div>
</div>
<p
v-if="!isLoadingVisibleDatabases && !visibleDatabaseError && !visibleDatabaseCanSave"
class="text-xs text-destructive"
>
{{ t("visibleDatabases.emptySelection") }}
</p>
<label
v-if="visibleDatabaseHasSystemDatabases"
class="flex h-8 items-center gap-2 rounded-md px-1 text-xs text-muted-foreground"
>
<input
v-model="visibleDatabaseShowSystem"
type="checkbox"
class="h-3.5 w-3.5 accent-primary"
:disabled="isLoadingVisibleDatabases || !!visibleDatabaseError"
/>
<span>{{ t("visibleDatabases.showSystemDatabases") }}</span>
</label>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div
v-if="isLoadingVisibleDatabases"
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="visibleDatabaseError" class="p-3 text-sm text-destructive">
{{ t("visibleDatabases.loadFailed", { message: visibleDatabaseError }) }}
</div>
<div v-else-if="!filteredVisibleDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
<template v-else>
<button
v-for="database in filteredVisibleDatabaseNames"
: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="toggleVisibleDatabase(database)"
>
<CheckSquare v-if="visibleDatabaseSelection.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="showVisibleDatabasesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button
:disabled="isLoadingVisibleDatabases || !!visibleDatabaseError || !visibleDatabaseCanSave"
@click="saveVisibleDatabaseSelection"
>
{{ t("visibleDatabases.save") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/api";
import { DIAGRAM_SQL_TYPES, isSchemaAware as isSchemaAwareDatabase } from "@/lib/databaseCapabilities";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import {
buildDiagramRelationships,
filterDiagramTables,
@ -372,7 +373,10 @@ async function loadDatabases(id: string) {
try {
await store.ensureConnected(id);
const dbs = await api.listDatabases(id);
databases.value = dbs.map((db) => db.name);
databases.value = databaseOptionsForConnection(
dbs.map((db) => db.name),
store.getConfig(id),
);
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {

View File

@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { copyToClipboard } from "@/lib/clipboard";
import type {
@ -375,7 +376,10 @@ async function loadSchemas(side: "source" | "target", preferredSchema = "") {
async function loadDatabases(connectionId: string, side: "source" | "target") {
if (!connectionId) return;
await store.ensureConnected(connectionId);
const names = (await api.listDatabases(connectionId)).map((database) => database.name);
const names = databaseOptionsForConnection(
(await api.listDatabases(connectionId)).map((database) => database.name),
store.getConfig(connectionId),
);
if (side === "source") {
sourceDatabases.value = names;
sourceDatabase.value = names.length === 1 ? names[0] : "";

View File

@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/api";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { copyToClipboard } from "@/lib/clipboard";
import type { TableDiff, TableSchemaDetail } from "@/lib/schemaDiff";
import type { TableInfo } from "@/types/database";
@ -110,7 +111,10 @@ async function loadDatabases(connectionId: string, side: "source" | "target") {
try {
await store.ensureConnected(connectionId);
const dbs = await api.listDatabases(connectionId);
const names = dbs.map((d) => d.name);
const names = databaseOptionsForConnection(
dbs.map((d) => d.name),
store.getConfig(connectionId),
);
if (side === "source") {
sourceDatabases.value = names;
sourceDatabase.value = names.length === 1 ? names[0] : "";

View File

@ -10,6 +10,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/api";
import type { ExportProgress } from "@/lib/api";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { generateDatabaseExportId } from "@/lib/databaseExport";
import { buildSelectedTablesPayload } from "@/lib/databaseExportSelection";
import { isTauriRuntime } from "@/lib/tauriRuntime";
@ -93,7 +94,10 @@ async function loadDatabases(connId: string) {
try {
await store.ensureConnected(connId);
const dbs = await api.listDatabases(connId);
const names = dbs.map((d) => d.name);
const names = databaseOptionsForConnection(
dbs.map((d) => d.name),
store.getConfig(connId),
);
databases.value = names;
database.value = names.length === 1 ? names[0] : "";
schemas.value = [];

View File

@ -2556,7 +2556,8 @@ const nodeIconClass = computed(() => {
});
const canConfigureVisibleDatabases = computed(() => {
if (props.node.type !== "connection" || !props.node.connectionId) return false;
return connectionStore.getConfig(props.node.connectionId)?.db_type !== "elasticsearch";
const dbType = connectionStore.getConfig(props.node.connectionId)?.db_type;
return dbType !== "elasticsearch" && dbType !== "etcd";
});
const canCopyFinalProxyPort = computed(() => {
if (props.node.type !== "connection" || !props.node.connectionId) return false;

View File

@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import { useConnectionStore } from "@/stores/connectionStore";
import {
canSaveVisibleDatabaseSelection,
filterDatabaseNamesForConnection,
isSystemDatabaseName,
normalizeVisibleDatabaseSelection,
@ -45,6 +46,7 @@ const filteredDatabaseNames = computed(() => {
});
const selectedCount = computed(() => selectedNames.value.size);
const totalCount = computed(() => listedDatabaseNames.value.length);
const canSaveSelection = computed(() => canSaveVisibleDatabaseSelection([...selectedNames.value]));
const hasSystemDatabases = computed(() =>
databaseNames.value.some((database) => isSystemDatabaseName(connection.value?.db_type, database)),
);
@ -126,6 +128,7 @@ async function showAllDatabases() {
}
async function saveSelection() {
if (!canSaveSelection.value) return;
await connectionStore.setVisibleDatabases(props.connectionId, [...selectedNames.value]);
emit("update:open", false);
}
@ -169,6 +172,9 @@ async function saveSelection() {
</button>
</div>
</div>
<p v-if="!isLoading && !errorMessage && !canSaveSelection" class="text-xs text-destructive">
{{ t("visibleDatabases.emptySelection") }}
</p>
<label
v-if="hasSystemDatabases"
@ -211,7 +217,7 @@ async function saveSelection() {
<DialogFooter>
<Button variant="outline" @click="emit('update:open', false)">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoading || !!errorMessage" @click="saveSelection">
<Button :disabled="isLoading || !!errorMessage || !canSaveSelection" @click="saveSelection">
{{ t("visibleDatabases.save") }}
</Button>
</DialogFooter>

View File

@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import {
cancelSqlFileExecution,
executeSqlFile,
@ -199,7 +200,10 @@ async function loadDatabasesForConnection(id: string) {
loadingDatabases.value = true;
try {
await store.ensureConnected(id);
const names = (await listDatabases(id)).map((db) => db.name);
const names = databaseOptionsForConnection(
(await listDatabases(id)).map((db) => db.name),
store.getConfig(id),
);
if (token !== databaseLoadToken) return;
databaseOptions.value = names;
database.value = chooseDatabase(names, id);

View File

@ -13,6 +13,7 @@ import * as api from "@/lib/api";
import type { TransferProgress, TransferMode } from "@/lib/api";
import type { DatabaseType } from "@/types/database";
import { isSchemaAware, supportsTransfer } from "@/lib/databaseCapabilities";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { nextTransferTerminalState } from "@/lib/transferProgressState";
import { ArrowRightLeft, Check, X, Loader2, Square, CheckSquare } from "@lucide/vue";
@ -107,9 +108,10 @@ async function loadDatabases(connectionId: string, target: "source" | "target")
if (!connectionId) return;
try {
await store.ensureConnected(connectionId);
const names = isMongoConnection(connectionId)
const rawNames = isMongoConnection(connectionId)
? await api.mongoListDatabases(connectionId)
: (await api.listDatabases(connectionId)).map((d) => d.name);
const names = databaseOptionsForConnection(rawNames, store.getConfig(connectionId));
if (target === "source") {
sourceDatabases.value = names;
sourceDatabase.value = names.length === 1 ? names[0] : "";

View File

@ -29,7 +29,10 @@ export function useDatabaseOptions() {
await connectionStore.ensureConnected(connectionId);
if (connection.db_type === "redis") {
const dbs = await api.redisListDatabases(connectionId);
databaseOptions.value[connectionId] = dbs.map((db) => String(db.db));
databaseOptions.value[connectionId] = databaseOptionsForConnection(
dbs.map((db) => String(db.db)),
connection,
);
} else if (connection.db_type === "mongodb") {
databaseOptions.value[connectionId] = filterDatabaseNamesForConnection(
await api.mongoListDatabases(connectionId),

View File

@ -1146,6 +1146,7 @@ export default {
showAll: "Show all",
showSystemDatabases: "Show system databases",
save: "Save",
emptySelection: "Select at least one database, or use Show all to clear the filter.",
loadFailed: "Failed to load databases: {message}",
},
tree: {

View File

@ -1004,6 +1004,7 @@ export default {
showAll: "Mostrar todo",
showSystemDatabases: "Mostrar bases de datos del sistema",
save: "Guardar",
emptySelection: "Selecciona al menos una base de datos, o usa Mostrar todo para quitar el filtro.",
loadFailed: "No se pudieron cargar las bases de datos: {message}",
},
tree: {

View File

@ -1136,6 +1136,7 @@ export default {
showAll: "Mostra tutto",
showSystemDatabases: "Mostra database di sistema",
save: "Salva",
emptySelection: "Seleziona almeno un database, oppure usa Mostra tutto per rimuovere il filtro.",
loadFailed: "Impossibile caricare i database: {message}",
},
tree: {

View File

@ -1131,6 +1131,7 @@ export default {
showAll: "Mostrar todos",
showSystemDatabases: "Mostrar bancos de dados do sistema",
save: "Salvar",
emptySelection: "Selecione pelo menos um banco de dados, ou use Mostrar todos para limpar o filtro.",
loadFailed: "Falha ao carregar bancos de dados: {message}",
},
tree: {

View File

@ -1120,6 +1120,7 @@ export default {
showAll: "显示全部",
showSystemDatabases: "显示系统库",
save: "保存",
emptySelection: "至少选择一个数据库,或使用“显示全部”清除过滤。",
loadFailed: "加载数据库失败:{message}",
},
tree: {

View File

@ -1067,6 +1067,7 @@ export default {
showAll: "顯示全部",
showSystemDatabases: "顯示系統庫",
save: "儲存",
emptySelection: "至少選擇一個資料庫,或使用「顯示全部」清除篩選。",
loadFailed: "載入資料庫失敗:{message}",
},
tree: {

View File

@ -0,0 +1,71 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { filterDatabaseNamesForConnection, normalizeVisibleDatabaseSelection } from "@/lib/visibleDatabases";
const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["elasticsearch", "etcd"]);
type VisibleDatabaseConnectionFields = Pick<
ConnectionConfig,
| "db_type"
| "driver_profile"
| "host"
| "port"
| "username"
| "database"
| "connection_string"
| "url_params"
| "redis_connection_mode"
| "redis_sentinel_master"
| "redis_sentinel_nodes"
| "redis_cluster_nodes"
| "etcd_endpoints"
| "jdbc_driver_class"
>;
export function buildDraftVisibleDatabasesConnectionId(seed: string): string {
return `${DRAFT_VISIBLE_DATABASES_PREFIX}${seed}`;
}
export function connectionCanChooseVisibleDatabases(
connection: Pick<ConnectionConfig, "db_type"> | undefined,
): boolean {
return !!connection?.db_type && !UNSUPPORTED_VISIBLE_DATABASE_TYPES.has(connection.db_type);
}
export function initialVisibleDatabaseSelection(
databaseNames: string[],
visibleDatabases: string[] | undefined,
connection?: Pick<ConnectionConfig, "db_type" | "driver_profile" | "visible_databases">,
): string[] {
if (Array.isArray(visibleDatabases)) {
return normalizeVisibleDatabaseSelection(visibleDatabases, databaseNames);
}
return filterDatabaseNamesForConnection(databaseNames, connection);
}
export function visibleDatabaseSelectionIsStale(
previous: VisibleDatabaseConnectionFields,
current: VisibleDatabaseConnectionFields,
): boolean {
return visibleDatabaseFingerprint(previous) !== visibleDatabaseFingerprint(current);
}
function visibleDatabaseFingerprint(connection: VisibleDatabaseConnectionFields): string {
return JSON.stringify({
db_type: connection.db_type,
driver_profile: connection.driver_profile || "",
host: connection.host || "",
port: Number(connection.port) || 0,
username: connection.username || "",
database: connection.database || "",
connection_string: connection.connection_string || "",
url_params: connection.url_params || "",
redis_connection_mode: connection.redis_connection_mode || "",
redis_sentinel_master: connection.redis_sentinel_master || "",
redis_sentinel_nodes: connection.redis_sentinel_nodes || "",
redis_cluster_nodes: connection.redis_cluster_nodes || "",
etcd_endpoints: connection.etcd_endpoints || "",
jdbc_driver_class: connection.jdbc_driver_class || "",
});
}

View File

@ -68,6 +68,10 @@ export function visibleDatabaseFilterIsEnabled(visibleDatabases: string[] | unde
return Array.isArray(visibleDatabases);
}
export function canSaveVisibleDatabaseSelection(selectedNames: string[]): boolean {
return selectedNames.length > 0;
}
export function filterVisibleDatabaseNames(databaseNames: string[], visibleDatabases: string[] | undefined): string[] {
if (!visibleDatabaseFilterIsEnabled(visibleDatabases)) return databaseNames;
const visible = new Set(visibleDatabases);

View File

@ -749,6 +749,7 @@ export const useConnectionStore = defineStore("connection", () => {
};
await persistConnections(nextConnections);
connections.value = nextConnections;
invalidateCompletionCache(connectionId);
rebuildTreeNodes();
}

View File

@ -112,6 +112,9 @@ pub async fn disconnect_db(
drop(connections);
app.reset_connection_transport(&body.connection_id).await;
if body.connection_id.starts_with("__visible_draft_") {
app.configs.write().await.remove(&body.connection_id);
}
Ok(Json(()))
}
@ -289,4 +292,26 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn disconnect_db_removes_visible_database_draft_config() {
let (state, dir) = test_web_state().await;
let conn_path = dir.join("draft.db");
let draft_id = "__visible_draft_test";
std::fs::File::create(&conn_path).unwrap();
{
let mut configs = state.app.configs.write().await;
configs.insert(draft_id.to_string(), sqlite_config(draft_id, &conn_path.to_string_lossy()));
}
let result =
disconnect_db(State(state.clone()), Json(DisconnectRequest { connection_id: draft_id.to_string() })).await;
assert!(result.is_ok());
let configs = state.app.configs.read().await;
assert!(!configs.contains_key(draft_id));
let _ = std::fs::remove_dir_all(dir);
}
}

View File

@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import {
buildDraftVisibleDatabasesConnectionId,
connectionCanChooseVisibleDatabases,
visibleDatabaseSelectionIsStale,
initialVisibleDatabaseSelection,
} from "../../apps/desktop/src/lib/connectionVisibleDatabases.ts";
import type { ConnectionConfig } from "../../apps/desktop/src/types/database.ts";
function config(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "conn",
name: "Local",
db_type: "mysql",
driver_profile: "mysql",
host: "127.0.0.1",
port: 3306,
username: "root",
password: "",
database: undefined,
visible_databases: undefined,
transport_layers: [],
connect_timeout_secs: 5,
query_timeout_secs: 30,
idle_timeout_secs: 60,
ssl: false,
ca_cert_path: "",
client_cert_path: "",
client_key_path: "",
sysdba: false,
jdbc_driver_paths: [],
redis_sentinel_master: "",
redis_sentinel_nodes: "",
redis_sentinel_username: "",
redis_sentinel_password: "",
redis_sentinel_tls: false,
redis_cluster_nodes: "",
etcd_endpoints: "",
...overrides,
};
}
test("draft visible database connection ids are namespaced", () => {
assert.equal(buildDraftVisibleDatabasesConnectionId("abc"), "__visible_draft_abc");
});
test("visible databases can be chosen for catalog-like connections only", () => {
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "mysql" }), true);
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "redis" }), true);
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "mongodb" }), true);
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "oracle" }), true);
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "elasticsearch" }), false);
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "etcd" }), false);
});
test("initial selection uses configured visible databases when available", () => {
assert.deepEqual(initialVisibleDatabaseSelection(["app", "analytics", "billing"], ["billing", "missing"]), [
"billing",
]);
});
test("initial selection uses default visible database names when no filter is configured", () => {
assert.deepEqual(initialVisibleDatabaseSelection(["app", "mysql", "sys"], undefined, config()), ["app"]);
});
test("visible database selection is stale when connection target changes", () => {
const previous = config({ host: "db.internal", visible_databases: ["app"] });
assert.equal(visibleDatabaseSelectionIsStale(previous, config({ host: "db.internal" })), false);
assert.equal(visibleDatabaseSelectionIsStale(previous, config({ host: "db2.internal" })), true);
assert.equal(visibleDatabaseSelectionIsStale(previous, config({ username: "readonly" })), true);
assert.equal(visibleDatabaseSelectionIsStale(previous, config({ database: "admin" })), true);
});

View File

@ -13,3 +13,23 @@ test("non tree-schema connections keep an empty database option list", () => {
test("database options preserve returned catalogs when available", () => {
assert.deepEqual(databaseOptionsForConnection(["app", "analytics"], { db_type: "jdbc" }), ["app", "analytics"]);
});
test("database options respect visible database filters", () => {
assert.deepEqual(
databaseOptionsForConnection(["app", "analytics", "billing"], {
db_type: "mysql",
visible_databases: ["billing", "missing"],
}),
["billing"],
);
});
test("redis database options respect visible database filters", () => {
assert.deepEqual(
databaseOptionsForConnection(["0", "1", "2"], {
db_type: "redis",
visible_databases: ["2"],
}),
["2"],
);
});

View File

@ -4,6 +4,7 @@ import {
filterDatabaseNamesForConnection,
filterVisibleDatabaseNames,
isSystemDatabaseName,
canSaveVisibleDatabaseSelection,
normalizeVisibleDatabaseSelection,
visibleDatabaseFilterIsEnabled,
} from "../../apps/desktop/src/lib/visibleDatabases.ts";
@ -23,6 +24,11 @@ test("empty configured visible database filter hides every database", () => {
assert.equal(visibleDatabaseFilterIsEnabled([]), true);
});
test("empty visible database selection cannot be saved", () => {
assert.equal(canSaveVisibleDatabaseSelection(["app"]), true);
assert.equal(canSaveVisibleDatabaseSelection([]), false);
});
test("normalizes selected database names against fresh database names", () => {
assert.deepEqual(normalizeVisibleDatabaseSelection(["billing", "missing", "app", "app"], ["app", "billing"]), [
"billing",

View File

@ -610,6 +610,9 @@ pub async fn disconnect_db(state: State<'_, Arc<AppState>>, connection_id: Strin
}
drop(conns);
state.reset_connection_transport(&connection_id).await;
if connection_id.starts_with("__visible_draft_") {
state.configs.write().await.remove(&connection_id);
}
Ok(())
}