1736 lines
59 KiB
TypeScript
1736 lines
59 KiB
TypeScript
import { defineStore } from "pinia";
|
|
import { uuid } from "@/lib/utils";
|
|
import { ref, computed, watch } from "vue";
|
|
import type { ColumnInfo, ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
|
|
import { applyPinnedTreeNodeState, orderPinnedFirst } from "@/lib/pinnedItems";
|
|
import {
|
|
reconcileLayout,
|
|
buildTreeNodesFromLayout,
|
|
emptyLayout,
|
|
appendConnectionToLayout,
|
|
removeConnectionFromSidebarLayout,
|
|
createGroup as createGroupOp,
|
|
renameGroup as renameGroupOp,
|
|
deleteGroup as deleteGroupOp,
|
|
toggleGroupCollapsed as toggleGroupCollapsedOp,
|
|
moveConnectionToGroup as moveConnectionToGroupOp,
|
|
reorderEntry as reorderEntryOp,
|
|
type DropPosition,
|
|
} from "@/lib/sidebarLayout";
|
|
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sqlCompletion";
|
|
import * as api from "@/lib/api";
|
|
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
|
import { isSchemaAware, usesTreeSchemaMode } from "@/lib/databaseCapabilities";
|
|
import { buildDatabaseTreeNodes } from "@/lib/databaseTree";
|
|
import { buildSqlServerDatabaseTreeNodes, SQLSERVER_DEFAULT_SCHEMA } from "@/lib/sqlServerTree";
|
|
import { findDatabaseTreeNode } from "@/lib/treeRefreshTarget";
|
|
import { shouldMarkDisconnected } from "@/lib/connectionHealth";
|
|
import { filterVisibleDatabaseNames, normalizeVisibleDatabaseSelection } from "@/lib/visibleDatabases";
|
|
import {
|
|
buildGroupedObjectTreeNodes,
|
|
buildTableTreeNodes,
|
|
expandCachedObjectBrowserNodes,
|
|
objectGroupRefreshParentId,
|
|
} from "@/lib/tableTree";
|
|
import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/schemaTreeCache";
|
|
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
|
|
|
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
|
|
type ImportSource = "dbx" | "navicat" | "dbeaver";
|
|
|
|
interface LoadTreeOptions {
|
|
force?: boolean;
|
|
}
|
|
|
|
interface PersistedTreeChildrenLoadResult {
|
|
hit: boolean;
|
|
isStale: boolean;
|
|
}
|
|
|
|
type BeforeConnectHandler = (config: ConnectionConfig) => Promise<void>;
|
|
|
|
function redisDbLabel(db: number, loadedKeyCount?: number, totalKeyCount?: number): string {
|
|
if (totalKeyCount == null) return `db${db}`;
|
|
return `db${db} (${loadedKeyCount ?? 0}/${totalKeyCount})`;
|
|
}
|
|
|
|
export const useConnectionStore = defineStore("connection", () => {
|
|
const connections = ref<ConnectionConfig[]>([]);
|
|
const isDesktop = isTauriRuntime();
|
|
const activeConnectionId = ref<string | null>(!isDesktop ? localStorage.getItem("dbx-active-connection") : null);
|
|
const selectedTreeNodeId = ref<string | null>(null);
|
|
|
|
watch(activeConnectionId, (id) => {
|
|
if (isDesktop) return;
|
|
if (id) localStorage.setItem("dbx-active-connection", id);
|
|
else localStorage.removeItem("dbx-active-connection");
|
|
});
|
|
const treeNodes = ref<TreeNode[]>([]);
|
|
const pinnedTreeNodeIds = ref<Set<string>>(loadPinnedTreeNodeIds());
|
|
const connectedIds = ref<Set<string>>(new Set());
|
|
const loadedTreeNodeChildrenIds = ref<Set<string>>(new Set());
|
|
const connectionErrors = ref<Record<string, string>>({});
|
|
const editingConnectionId = ref<string | null>(null);
|
|
const newConnectionGroupId = ref<string | null>(null);
|
|
const completionTablesCache = ref<Record<string, SqlCompletionTable[]>>({});
|
|
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
|
|
const transferSource = ref<{ connectionId: string; database: string } | null>(null);
|
|
const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null);
|
|
const dataCompareSource = ref<{
|
|
connectionId: string;
|
|
database: string;
|
|
schema?: string;
|
|
tableName?: string;
|
|
} | null>(null);
|
|
const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null);
|
|
const diagramSource = ref<{
|
|
connectionId: string;
|
|
database: string;
|
|
schema?: string;
|
|
tableName?: string;
|
|
} | null>(null);
|
|
const tableImportSource = ref<{
|
|
connectionId: string;
|
|
database: string;
|
|
schema?: string;
|
|
tableName: string;
|
|
} | null>(null);
|
|
const structureEditorSource = ref<{
|
|
connectionId: string;
|
|
database: string;
|
|
schema?: string;
|
|
tableName: string;
|
|
} | null>(null);
|
|
const fieldLineageSource = ref<{
|
|
connectionId: string;
|
|
database: string;
|
|
schema?: string;
|
|
tableName: string;
|
|
columnName: string;
|
|
} | null>(null);
|
|
const databaseSearchSource = ref<{
|
|
connectionId: string;
|
|
database: string;
|
|
schema?: string;
|
|
} | null>(null);
|
|
const databaseExportSource = ref<{
|
|
connectionId: string;
|
|
database: string;
|
|
schema?: string;
|
|
tableName?: string;
|
|
} | null>(null);
|
|
const sidebarLayout = ref<SidebarLayout>(emptyLayout());
|
|
let layoutPersistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const staleTreeRefreshIds = new Set<string>();
|
|
let beforeConnectHandler: BeforeConnectHandler | null = null;
|
|
|
|
function startEditing(id: string) {
|
|
editingConnectionId.value = id;
|
|
}
|
|
|
|
function stopEditing() {
|
|
editingConnectionId.value = null;
|
|
}
|
|
|
|
function startCreatingConnectionInGroup(groupId: string) {
|
|
stopEditing();
|
|
newConnectionGroupId.value = groupId;
|
|
}
|
|
|
|
function stopCreatingConnectionInGroup() {
|
|
newConnectionGroupId.value = null;
|
|
}
|
|
|
|
const configById = computed(() => new Map(connections.value.map((c) => [c.id, c])));
|
|
|
|
function getConfig(connectionId: string) {
|
|
return configById.value.get(connectionId);
|
|
}
|
|
|
|
function connectionErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return String(error);
|
|
}
|
|
|
|
function setConnectionError(connectionId: string, message: string) {
|
|
connectionErrors.value[connectionId] = message;
|
|
}
|
|
|
|
function clearConnectionError(connectionId: string) {
|
|
if (!connectionErrors.value[connectionId]) return;
|
|
delete connectionErrors.value[connectionId];
|
|
}
|
|
|
|
function recordConnectionError(connectionId: string, error: unknown): string {
|
|
const message = connectionErrorMessage(error);
|
|
setConnectionError(connectionId, message);
|
|
return message;
|
|
}
|
|
|
|
function recordMetadataLoadError(connectionId: string, error: unknown) {
|
|
if (shouldMarkDisconnected(error)) {
|
|
connectedIds.value.delete(connectionId);
|
|
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
|
|
}
|
|
recordConnectionError(connectionId, error);
|
|
}
|
|
|
|
function normalizeConnection(config: ConnectionConfig): ConnectionConfig {
|
|
const labelMap: Record<string, string> = {
|
|
mysql: "MySQL",
|
|
postgres: "PostgreSQL",
|
|
sqlite: "SQLite",
|
|
redis: "Redis",
|
|
duckdb: "DuckDB",
|
|
clickhouse: "ClickHouse",
|
|
sqlserver: "SQL Server",
|
|
mongodb: "MongoDB",
|
|
oracle: "Oracle",
|
|
elasticsearch: "Elasticsearch",
|
|
doris: "Doris",
|
|
starrocks: "StarRocks",
|
|
redshift: "Redshift",
|
|
dameng: "DM (Dameng)",
|
|
gaussdb: "GaussDB",
|
|
kingbase: "KingBase",
|
|
highgo: "瀚高 HighGo",
|
|
vastbase: "Vastbase",
|
|
goldendb: "GoldenDB",
|
|
access: "Microsoft Access",
|
|
h2: "H2",
|
|
snowflake: "Snowflake",
|
|
trino: "Trino",
|
|
hive: "Hive",
|
|
db2: "DB2",
|
|
informix: "Informix",
|
|
neo4j: "Neo4j",
|
|
cassandra: "Cassandra",
|
|
bigquery: "BigQuery",
|
|
kylin: "Kylin",
|
|
sundb: "SunDB",
|
|
};
|
|
|
|
const profile = config.driver_profile || config.db_type;
|
|
let dbType = config.db_type;
|
|
if ((profile === "gaussdb" || profile === "opengauss") && dbType === "postgres") {
|
|
dbType = "gaussdb" as ConnectionConfig["db_type"];
|
|
} else if (profile === "redshift" && dbType === "postgres") {
|
|
dbType = "redshift" as ConnectionConfig["db_type"];
|
|
} else if (profile === "kingbase" && dbType === "postgres") {
|
|
dbType = "kingbase" as ConnectionConfig["db_type"];
|
|
} else if (profile === "highgo" && dbType === "postgres") {
|
|
dbType = "highgo" as ConnectionConfig["db_type"];
|
|
} else if (profile === "vastbase" && dbType === "postgres") {
|
|
dbType = "vastbase" as ConnectionConfig["db_type"];
|
|
} else if (profile === "goldendb" && dbType === "mysql") {
|
|
dbType = "goldendb" as ConnectionConfig["db_type"];
|
|
}
|
|
|
|
return {
|
|
...config,
|
|
db_type: dbType,
|
|
driver_profile: profile,
|
|
driver_label: config.driver_label || labelMap[profile] || config.db_type,
|
|
url_params: config.url_params || "",
|
|
ssh_connect_timeout_secs: config.ssh_connect_timeout_secs || 5,
|
|
proxy_type: config.proxy_type || "socks5",
|
|
proxy_port: config.proxy_port || 1080,
|
|
};
|
|
}
|
|
|
|
function loadPinnedTreeNodeIds(): Set<string> {
|
|
try {
|
|
if (typeof localStorage === "undefined") return new Set();
|
|
const saved = localStorage.getItem(PINNED_TREE_NODES_STORAGE_KEY);
|
|
const ids = saved ? JSON.parse(saved) : [];
|
|
return new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : []);
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function persistPinnedTreeNodeIds() {
|
|
if (typeof localStorage === "undefined") return;
|
|
localStorage.setItem(PINNED_TREE_NODES_STORAGE_KEY, JSON.stringify([...pinnedTreeNodeIds.value]));
|
|
}
|
|
|
|
function isTreeNodePinned(id: string): boolean {
|
|
return pinnedTreeNodeIds.value.has(id);
|
|
}
|
|
|
|
function setChildren(parent: TreeNode, children: TreeNode[]) {
|
|
parent.children = applyPinnedTreeNodeState(children, pinnedTreeNodeIds.value);
|
|
loadedTreeNodeChildrenIds.value.add(parent.id);
|
|
}
|
|
|
|
function removeTreeNode(nodeId: string) {
|
|
const parent = findParentNode(treeNodes.value, nodeId);
|
|
if (parent?.children) {
|
|
parent.children = parent.children.filter((c) => c.id !== nodeId);
|
|
}
|
|
}
|
|
|
|
function buildSavedSqlRootNode(connectionId: string, existingRoot?: TreeNode): TreeNode {
|
|
const savedSqlStore = useSavedSqlStore();
|
|
const existingById = new Map<string, TreeNode>();
|
|
const collectExisting = (node?: TreeNode) => {
|
|
if (!node) return;
|
|
existingById.set(node.id, node);
|
|
node.children?.forEach(collectExisting);
|
|
};
|
|
collectExisting(existingRoot);
|
|
|
|
const fileNode = (file: ReturnType<typeof savedSqlStore.listFiles>[number]): TreeNode => ({
|
|
id: `${connectionId}:__saved_sql:file:${file.id}`,
|
|
label: file.name,
|
|
type: "saved-sql-file",
|
|
connectionId,
|
|
database: file.database,
|
|
schema: file.schema,
|
|
savedSqlId: file.id,
|
|
});
|
|
|
|
const folderNodes = savedSqlStore.listFolders(connectionId).map((folder) => {
|
|
const id = `${connectionId}:__saved_sql:folder:${folder.id}`;
|
|
const existing = existingById.get(id);
|
|
return {
|
|
id,
|
|
label: folder.name,
|
|
type: "saved-sql-folder" as const,
|
|
connectionId,
|
|
savedSqlFolderId: folder.id,
|
|
isExpanded: existing?.isExpanded ?? true,
|
|
children: savedSqlStore.listFiles(connectionId, folder.id).map(fileNode),
|
|
};
|
|
});
|
|
|
|
const rootId = `${connectionId}:__saved_sql`;
|
|
return {
|
|
id: rootId,
|
|
label: "tree.savedSql",
|
|
type: "saved-sql-root",
|
|
connectionId,
|
|
isExpanded: existingRoot?.isExpanded ?? true,
|
|
children: [...folderNodes, ...savedSqlStore.listFiles(connectionId).map(fileNode)],
|
|
};
|
|
}
|
|
|
|
function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
|
|
const existingRoot = existingConnectionNode?.children?.find((child) => child.type === "saved-sql-root");
|
|
const nonSavedChildren = children.filter((child) => child.type !== "saved-sql-root");
|
|
return [buildSavedSqlRootNode(connectionId, existingRoot), ...nonSavedChildren];
|
|
}
|
|
|
|
function refreshSavedSqlTree(connectionId?: string) {
|
|
const refresh = (nodes: TreeNode[]) => {
|
|
for (const node of nodes) {
|
|
if (node.type === "connection" && node.connectionId && (!connectionId || node.connectionId === connectionId)) {
|
|
node.children = withSavedSqlRoot(
|
|
node.connectionId,
|
|
(node.children || []).filter((child) => child.type !== "saved-sql-root"),
|
|
node,
|
|
);
|
|
}
|
|
if (node.children) refresh(node.children);
|
|
}
|
|
};
|
|
refresh(treeNodes.value);
|
|
}
|
|
|
|
function schemaCacheKey(...parts: string[]): string {
|
|
return parts.map((part) => encodeURIComponent(part)).join(":");
|
|
}
|
|
|
|
function refreshStaleTreeNode(node: TreeNode) {
|
|
if (staleTreeRefreshIds.has(node.id)) return;
|
|
staleTreeRefreshIds.add(node.id);
|
|
void loadTreeNodeChildren(node, { force: true }).finally(() => staleTreeRefreshIds.delete(node.id));
|
|
}
|
|
|
|
async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string): Promise<PersistedTreeChildrenLoadResult> {
|
|
const payload = await api.loadSchemaCache<unknown>(cacheKey).catch(() => null);
|
|
const decoded = decodeSchemaTreeCache<TreeNode[]>(payload);
|
|
if (!decoded) return { hit: false, isStale: false };
|
|
const normalizedChildren = expandCachedObjectBrowserNodes(decoded.children);
|
|
setChildren(
|
|
node,
|
|
node.type === "connection" && node.connectionId
|
|
? withSavedSqlRoot(node.connectionId, normalizedChildren, node)
|
|
: normalizedChildren,
|
|
);
|
|
node.isExpanded = true;
|
|
return { hit: true, isStale: decoded.isStale };
|
|
}
|
|
|
|
async function savePersistedTreeChildren(cacheKey: string, children: TreeNode[]) {
|
|
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(children)).catch(() => undefined);
|
|
}
|
|
|
|
function useCachedChildren(node: TreeNode, options?: LoadTreeOptions): boolean {
|
|
if (options?.force || !loadedTreeNodeChildrenIds.value.has(node.id)) return false;
|
|
node.isExpanded = true;
|
|
return true;
|
|
}
|
|
|
|
function clearLoadedChildrenCache(prefix: string) {
|
|
for (const id of loadedTreeNodeChildrenIds.value) {
|
|
if (id === prefix || id.startsWith(`${prefix}:`)) {
|
|
loadedTreeNodeChildrenIds.value.delete(id);
|
|
}
|
|
}
|
|
const rawPrefix = `${prefix}:`;
|
|
const encodedPrefix = `${schemaCacheKey(prefix)}:`;
|
|
if (rawPrefix === encodedPrefix) {
|
|
api.deleteSchemaCachePrefix(rawPrefix).catch(() => undefined);
|
|
} else {
|
|
Promise.all([api.deleteSchemaCachePrefix(rawPrefix), api.deleteSchemaCachePrefix(encodedPrefix)]).catch(
|
|
() => undefined,
|
|
);
|
|
}
|
|
}
|
|
|
|
function schemaCachePrefixForNode(node: TreeNode): string | null {
|
|
if (node.type === "connection" && node.connectionId) {
|
|
return `${schemaCacheKey(node.connectionId)}:`;
|
|
}
|
|
if (node.type === "database" && node.connectionId && node.database) {
|
|
return `${schemaCacheKey(node.connectionId, node.database)}:`;
|
|
}
|
|
if (node.type === "schema" && node.connectionId && node.database && node.schema) {
|
|
return `${schemaCacheKey(node.connectionId, node.database, node.schema)}:`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function clearPersistedTreeCacheForNode(node: TreeNode) {
|
|
const prefix = schemaCachePrefixForNode(node);
|
|
if (!prefix) return;
|
|
await api.deleteSchemaCachePrefix(prefix).catch(() => undefined);
|
|
}
|
|
|
|
function findParentNode(nodes: TreeNode[], id: string, parent: TreeNode | null = null): TreeNode | null {
|
|
for (const node of nodes) {
|
|
if (node.id === id) return parent;
|
|
if (node.children) {
|
|
const found = findParentNode(node.children, id, node);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function toggleTreeNodePin(id: string) {
|
|
const next = new Set(pinnedTreeNodeIds.value);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
pinnedTreeNodeIds.value = next;
|
|
persistPinnedTreeNodeIds();
|
|
|
|
const node = findNode(treeNodes.value, id);
|
|
if (node) node.pinned = next.has(id);
|
|
|
|
const isConnectionOrGroup =
|
|
treeNodes.value.some((n) => n.id === id) ||
|
|
treeNodes.value.some((n) => n.type === "connection-group" && n.children?.some((c) => c.id === id));
|
|
if (isConnectionOrGroup) {
|
|
rebuildTreeNodes();
|
|
} else {
|
|
const parent = findParentNode(treeNodes.value, id);
|
|
if (parent?.children) {
|
|
parent.children = orderPinnedFirst(parent.children, (child) => !!child.pinned);
|
|
const sqlRootIdx = parent.children.findIndex((c) => c.type === "saved-sql-root");
|
|
if (sqlRootIdx > 0) {
|
|
parent.children.unshift(...parent.children.splice(sqlRootIdx, 1));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function addConnection(config: ConnectionConfig) {
|
|
const normalized = normalizeConnection(config);
|
|
const existing = connections.value.findIndex((c) => c.id === normalized.id);
|
|
const nextConnections = [...connections.value];
|
|
if (existing >= 0) {
|
|
nextConnections[existing] = normalized;
|
|
} else {
|
|
nextConnections.push(normalized);
|
|
sidebarLayout.value = appendConnectionToLayout(sidebarLayout.value, normalized.id, newConnectionGroupId.value);
|
|
}
|
|
await persistConnections(nextConnections);
|
|
connections.value = nextConnections;
|
|
rebuildTreeNodes();
|
|
persistSidebarLayoutDebounced();
|
|
stopCreatingConnectionInGroup();
|
|
}
|
|
|
|
function invalidateCompletionCache(connectionId: string) {
|
|
const cachePrefix = `${connectionId}:`;
|
|
for (const key of Object.keys(completionTablesCache.value)) {
|
|
if (key.startsWith(cachePrefix)) delete completionTablesCache.value[key];
|
|
}
|
|
for (const key of Object.keys(completionColumnsCache.value)) {
|
|
if (key.startsWith(cachePrefix)) delete completionColumnsCache.value[key];
|
|
}
|
|
}
|
|
|
|
async function removeConnection(id: string) {
|
|
const nextConnections = connections.value.filter((c) => c.id !== id);
|
|
await persistConnections(nextConnections);
|
|
connections.value = nextConnections;
|
|
clearConnectionError(id);
|
|
sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id);
|
|
rebuildTreeNodes();
|
|
persistSidebarLayoutDebounced();
|
|
if (activeConnectionId.value === id) {
|
|
activeConnectionId.value = null;
|
|
}
|
|
invalidateCompletionCache(id);
|
|
clearLoadedChildrenCache(id);
|
|
}
|
|
|
|
async function updateConnection(config: ConnectionConfig) {
|
|
config = normalizeConnection(config);
|
|
const idx = connections.value.findIndex((c) => c.id === config.id);
|
|
if (idx < 0) return;
|
|
const nextConnections = [...connections.value];
|
|
nextConnections[idx] = config;
|
|
await persistConnections(nextConnections);
|
|
connections.value = nextConnections;
|
|
rebuildTreeNodes();
|
|
connectedIds.value.delete(config.id);
|
|
invalidateCompletionCache(config.id);
|
|
clearLoadedChildrenCache(config.id);
|
|
}
|
|
|
|
async function setDefaultDatabase(connectionId: string, database: string) {
|
|
const config = getConfig(connectionId);
|
|
if (!config || config.database === database) return;
|
|
await updateConnection({
|
|
...config,
|
|
database,
|
|
});
|
|
}
|
|
|
|
async function clearDefaultDatabase(connectionId: string) {
|
|
const config = getConfig(connectionId);
|
|
if (!config || !config.database) return;
|
|
await updateConnection({
|
|
...config,
|
|
database: undefined,
|
|
});
|
|
}
|
|
|
|
function isDefaultDatabase(connectionId: string, database: string): boolean {
|
|
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);
|
|
if (pendingNode) pendingNode.isLoading = true;
|
|
try {
|
|
await beforeConnectHandler?.(config);
|
|
const id = await api.connectDb(config);
|
|
activeConnectionId.value = id;
|
|
connectedIds.value.add(id);
|
|
clearConnectionError(config.id);
|
|
if (id !== config.id) clearConnectionError(id);
|
|
|
|
const node: TreeNode = {
|
|
id,
|
|
label: config.name,
|
|
type: "connection",
|
|
connectionId: id,
|
|
isExpanded: false,
|
|
children: [],
|
|
};
|
|
const existing = treeNodes.value.findIndex((n) => n.id === id);
|
|
if (existing >= 0) {
|
|
treeNodes.value[existing] = node;
|
|
} else {
|
|
treeNodes.value.push(node);
|
|
}
|
|
return id;
|
|
} catch (e) {
|
|
recordConnectionError(config.id, e);
|
|
throw e;
|
|
} finally {
|
|
const node = findNode(treeNodes.value, config.id);
|
|
if (node) node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function disconnect(connectionId: string) {
|
|
await api.disconnectDb(connectionId);
|
|
connectedIds.value.delete(connectionId);
|
|
const node = treeNodes.value.find((n) => n.connectionId === connectionId);
|
|
if (node) {
|
|
node.isExpanded = false;
|
|
node.children = [];
|
|
}
|
|
clearLoadedChildrenCache(connectionId);
|
|
if (activeConnectionId.value === connectionId) {
|
|
activeConnectionId.value = null;
|
|
}
|
|
invalidateCompletionCache(connectionId);
|
|
}
|
|
|
|
async function ensureConnected(connectionId: string) {
|
|
if (connectedIds.value.has(connectionId)) return;
|
|
const config = getConfig(connectionId);
|
|
if (!config) {
|
|
const error = new Error("Connection config not found");
|
|
recordConnectionError(connectionId, error);
|
|
throw error;
|
|
}
|
|
try {
|
|
await beforeConnectHandler?.(config);
|
|
await api.connectDb(config);
|
|
connectedIds.value.add(connectionId);
|
|
activeConnectionId.value = connectionId;
|
|
clearConnectionError(connectionId);
|
|
} catch (e) {
|
|
recordConnectionError(connectionId, e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
function setBeforeConnectHandler(handler: BeforeConnectHandler | null) {
|
|
beforeConnectHandler = handler;
|
|
}
|
|
|
|
async function loadDatabases(connectionId: string, options?: LoadTreeOptions) {
|
|
const node = findNode(treeNodes.value, connectionId);
|
|
if (!node) return;
|
|
node.isLoading = true;
|
|
try {
|
|
await ensureConnected(connectionId);
|
|
if (useCachedChildren(node, options)) return;
|
|
|
|
const config = getConfig(connectionId);
|
|
if (config?.db_type === "dameng" || config?.db_type === "oracle") {
|
|
const effectiveDb = config.database || "";
|
|
const cacheKey = schemaCacheKey(connectionId, effectiveDb, "schemas");
|
|
if (!options?.force) {
|
|
const cached = await loadPersistedTreeChildren(node, cacheKey);
|
|
if (cached.hit) {
|
|
if (cached.isStale) refreshStaleTreeNode(node);
|
|
return;
|
|
}
|
|
}
|
|
const schemas = await api.listSchemas(connectionId, effectiveDb);
|
|
const visibleSchemas = filterVisibleDatabaseNames(schemas, config?.visible_databases);
|
|
const schemaNodes: TreeNode[] = visibleSchemas.map((s) => ({
|
|
id: `${connectionId}:${s}:${s}`,
|
|
label: s,
|
|
type: "schema" as const,
|
|
connectionId,
|
|
database: s,
|
|
schema: s,
|
|
isExpanded: false,
|
|
children: [],
|
|
}));
|
|
setChildren(node, withSavedSqlRoot(connectionId, schemaNodes, node));
|
|
await savePersistedTreeChildren(cacheKey, schemaNodes);
|
|
} else {
|
|
const cacheKey = schemaCacheKey(connectionId, "databases");
|
|
if (!options?.force) {
|
|
const cached = await loadPersistedTreeChildren(node, cacheKey);
|
|
if (cached.hit) {
|
|
if (cached.isStale) refreshStaleTreeNode(node);
|
|
return;
|
|
}
|
|
}
|
|
const databases = await api.listDatabases(connectionId);
|
|
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);
|
|
}
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadRedisDatabases(connectionId: string) {
|
|
const node = findNode(treeNodes.value, connectionId);
|
|
if (!node) return;
|
|
|
|
node.isLoading = true;
|
|
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
|
|
.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,
|
|
),
|
|
);
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
function updateRedisDbKeyStats(
|
|
connectionId: string,
|
|
db: number,
|
|
stats: { loaded?: number; total?: number; totalDelta?: number },
|
|
) {
|
|
const node = findNode(treeNodes.value, `${connectionId}:db${db}`);
|
|
if (!node || node.type !== "redis-db") return;
|
|
if (stats.loaded != null) node.loadedKeyCount = stats.loaded;
|
|
if (stats.total != null) node.totalKeyCount = stats.total;
|
|
if (stats.totalDelta != null && node.totalKeyCount != null) {
|
|
node.totalKeyCount = Math.max(0, node.totalKeyCount + stats.totalDelta);
|
|
}
|
|
node.label = redisDbLabel(db, node.loadedKeyCount, node.totalKeyCount);
|
|
}
|
|
|
|
async function loadMongoDatabases(connectionId: string) {
|
|
const node = findNode(treeNodes.value, connectionId);
|
|
if (!node) return;
|
|
|
|
node.isLoading = true;
|
|
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,
|
|
visibleDbs.map((db) => ({
|
|
id: `${connectionId}:${db}`,
|
|
label: db,
|
|
type: "mongo-db" as const,
|
|
connectionId,
|
|
database: db,
|
|
isExpanded: false,
|
|
children: [],
|
|
})),
|
|
node,
|
|
),
|
|
);
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadMongoCollections(connectionId: string, database: string) {
|
|
const nodeId = `${connectionId}:${database}`;
|
|
const node = findNode(treeNodes.value, nodeId);
|
|
if (!node) return;
|
|
|
|
node.isLoading = true;
|
|
try {
|
|
const collections = await api.mongoListCollections(connectionId, database);
|
|
setChildren(
|
|
node,
|
|
collections.map((col) => ({
|
|
id: `${nodeId}:${col}`,
|
|
label: col,
|
|
type: "mongo-collection" as const,
|
|
connectionId,
|
|
database,
|
|
isExpanded: false,
|
|
})),
|
|
);
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadSchemas(connectionId: string, database: string, options?: LoadTreeOptions) {
|
|
const nodeId = `${connectionId}:${database}`;
|
|
const node = findNode(treeNodes.value, nodeId);
|
|
if (!node) return;
|
|
node.isLoading = true;
|
|
try {
|
|
await ensureConnected(connectionId);
|
|
if (useCachedChildren(node, options)) return;
|
|
const cacheKey = schemaCacheKey(connectionId, database, "schemas");
|
|
if (!options?.force) {
|
|
const cached = await loadPersistedTreeChildren(node, cacheKey);
|
|
if (cached.hit) {
|
|
if (cached.isStale) refreshStaleTreeNode(node);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const schemas = await api.listSchemas(connectionId, database);
|
|
const children = schemas.map((s) => ({
|
|
id: `${connectionId}:${database}:${s}`,
|
|
label: s,
|
|
type: "schema" as const,
|
|
connectionId,
|
|
database,
|
|
schema: s,
|
|
isExpanded: false,
|
|
children: [],
|
|
}));
|
|
setChildren(node, children);
|
|
await savePersistedTreeChildren(cacheKey, children);
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadSqlServerDatabaseObjects(connectionId: string, database: string, options?: LoadTreeOptions) {
|
|
const nodeId = `${connectionId}:${database}`;
|
|
const node = findNode(treeNodes.value, nodeId);
|
|
if (!node) return;
|
|
node.isLoading = true;
|
|
try {
|
|
await ensureConnected(connectionId);
|
|
if (useCachedChildren(node, options)) return;
|
|
const cacheKey = schemaCacheKey(connectionId, database, "sqlserver-objects");
|
|
if (!options?.force) {
|
|
const cached = await loadPersistedTreeChildren(node, cacheKey);
|
|
if (cached.hit) {
|
|
if (cached.isStale) refreshStaleTreeNode(node);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const [schemas, defaultSchemaObjects] = await Promise.all([
|
|
api.listSchemas(connectionId, database),
|
|
api.listObjects(connectionId, database, SQLSERVER_DEFAULT_SCHEMA),
|
|
]);
|
|
const children = buildSqlServerDatabaseTreeNodes(connectionId, database, schemas, defaultSchemaObjects);
|
|
setChildren(node, children);
|
|
await savePersistedTreeChildren(cacheKey, children);
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadTables(connectionId: string, database: string, schema?: string, options?: LoadTreeOptions) {
|
|
const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
|
|
const node = findNode(treeNodes.value, nodeId);
|
|
if (!node) return;
|
|
node.isLoading = true;
|
|
try {
|
|
await ensureConnected(connectionId);
|
|
if (useCachedChildren(node, options)) return;
|
|
const cacheKey = schemaCacheKey(connectionId, database, schema || "", "objects");
|
|
if (!options?.force) {
|
|
const cached = await loadPersistedTreeChildren(node, cacheKey);
|
|
if (cached.hit) {
|
|
if (cached.isStale) refreshStaleTreeNode(node);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const querySchema = schema || database;
|
|
const config = getConfig(connectionId);
|
|
const effectiveSchema = schema || (config?.db_type && isSchemaAware(config.db_type) ? database : undefined);
|
|
let children: TreeNode[];
|
|
try {
|
|
const objects = await api.listObjects(connectionId, database, querySchema);
|
|
children = buildGroupedObjectTreeNodes({ nodeId, connectionId, database, schema: effectiveSchema, objects });
|
|
} catch {
|
|
const tables = await api.listTables(connectionId, database, querySchema);
|
|
children = buildTableTreeNodes({ nodeId, connectionId, database, schema: effectiveSchema, tables });
|
|
}
|
|
setChildren(node, children);
|
|
await savePersistedTreeChildren(cacheKey, children);
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadTableGroups(
|
|
connectionId: string,
|
|
database: string,
|
|
table: string,
|
|
schema?: string,
|
|
nodeId?: string,
|
|
) {
|
|
const parentId =
|
|
nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}` : `${connectionId}:${database}:${table}`);
|
|
const node = findNode(treeNodes.value, parentId);
|
|
if (!node) return;
|
|
|
|
setChildren(node, [
|
|
{
|
|
id: `${parentId}:__columns`,
|
|
label: "tree.columns",
|
|
type: "group-columns",
|
|
connectionId,
|
|
database,
|
|
schema,
|
|
tableName: table,
|
|
isExpanded: false,
|
|
children: [],
|
|
},
|
|
{
|
|
id: `${parentId}:__indexes`,
|
|
label: "tree.indexes",
|
|
type: "group-indexes",
|
|
connectionId,
|
|
database,
|
|
schema,
|
|
tableName: table,
|
|
isExpanded: false,
|
|
children: [],
|
|
},
|
|
{
|
|
id: `${parentId}:__fkeys`,
|
|
label: "tree.foreignKeys",
|
|
type: "group-fkeys",
|
|
connectionId,
|
|
database,
|
|
schema,
|
|
tableName: table,
|
|
isExpanded: false,
|
|
children: [],
|
|
},
|
|
{
|
|
id: `${parentId}:__triggers`,
|
|
label: "tree.triggers",
|
|
type: "group-triggers",
|
|
connectionId,
|
|
database,
|
|
schema,
|
|
tableName: table,
|
|
isExpanded: false,
|
|
children: [],
|
|
},
|
|
]);
|
|
node.isExpanded = true;
|
|
}
|
|
|
|
async function loadColumns(connectionId: string, database: string, table: string, schema?: string) {
|
|
const parentId = schema
|
|
? `${connectionId}:${database}:${schema}:${table}:__columns`
|
|
: `${connectionId}:${database}:${table}:__columns`;
|
|
const node = findNode(treeNodes.value, parentId);
|
|
if (!node) return;
|
|
|
|
node.isLoading = true;
|
|
try {
|
|
const querySchema = schema || database;
|
|
const columns = await api.getColumns(connectionId, database, querySchema, table);
|
|
setChildren(
|
|
node,
|
|
columns.map((col) => ({
|
|
id: `${parentId}:${col.name}`,
|
|
label: `${col.name} (${col.data_type})`,
|
|
type: "column" as const,
|
|
connectionId,
|
|
database,
|
|
schema,
|
|
tableName: table,
|
|
meta: col,
|
|
})),
|
|
);
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadIndexes(connectionId: string, database: string, table: string, schema?: string) {
|
|
const parentId = schema
|
|
? `${connectionId}:${database}:${schema}:${table}:__indexes`
|
|
: `${connectionId}:${database}:${table}:__indexes`;
|
|
const node = findNode(treeNodes.value, parentId);
|
|
if (!node) return;
|
|
|
|
node.isLoading = true;
|
|
try {
|
|
const querySchema = schema || database;
|
|
const indexes = await api.listIndexes(connectionId, database, querySchema, table);
|
|
setChildren(
|
|
node,
|
|
indexes.map((idx) => ({
|
|
id: `${parentId}:${idx.name}`,
|
|
label: `${idx.name} (${idx.columns.join(", ")})`,
|
|
type: "index" as const,
|
|
connectionId,
|
|
database,
|
|
schema,
|
|
meta: idx,
|
|
})),
|
|
);
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadForeignKeys(connectionId: string, database: string, table: string, schema?: string) {
|
|
const parentId = schema
|
|
? `${connectionId}:${database}:${schema}:${table}:__fkeys`
|
|
: `${connectionId}:${database}:${table}:__fkeys`;
|
|
const node = findNode(treeNodes.value, parentId);
|
|
if (!node) return;
|
|
|
|
node.isLoading = true;
|
|
try {
|
|
const querySchema = schema || database;
|
|
const fkeys = await api.listForeignKeys(connectionId, database, querySchema, table);
|
|
setChildren(
|
|
node,
|
|
fkeys.map((fk) => ({
|
|
id: `${parentId}:${fk.name}`,
|
|
label: `${fk.column} → ${fk.ref_table}.${fk.ref_column}`,
|
|
type: "fkey" as const,
|
|
connectionId,
|
|
database,
|
|
schema,
|
|
meta: fk,
|
|
})),
|
|
);
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadTriggers(connectionId: string, database: string, table: string, schema?: string) {
|
|
const parentId = schema
|
|
? `${connectionId}:${database}:${schema}:${table}:__triggers`
|
|
: `${connectionId}:${database}:${table}:__triggers`;
|
|
const node = findNode(treeNodes.value, parentId);
|
|
if (!node) return;
|
|
|
|
node.isLoading = true;
|
|
try {
|
|
const querySchema = schema || database;
|
|
const triggers = await api.listTriggers(connectionId, database, querySchema, table);
|
|
setChildren(
|
|
node,
|
|
triggers.map((tr) => ({
|
|
id: `${parentId}:${tr.name}`,
|
|
label: `${tr.name} (${tr.timing} ${tr.event})`,
|
|
type: "trigger" as const,
|
|
connectionId,
|
|
database,
|
|
schema,
|
|
meta: tr,
|
|
})),
|
|
);
|
|
node.isExpanded = true;
|
|
} catch (e) {
|
|
recordMetadataLoadError(connectionId, e);
|
|
throw e;
|
|
} finally {
|
|
node.isLoading = false;
|
|
}
|
|
}
|
|
|
|
function collectExpandedNodeIds(nodes: TreeNode[], ids = new Set<string>()): Set<string> {
|
|
for (const node of nodes) {
|
|
if (node.isExpanded) ids.add(node.id);
|
|
if (node.children) collectExpandedNodeIds(node.children, ids);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
async function loadTreeNodeChildren(node: TreeNode, options?: LoadTreeOptions) {
|
|
if (node.type === "connection" && node.connectionId) {
|
|
const config = getConfig(node.connectionId);
|
|
if (config?.db_type === "redis") {
|
|
await loadRedisDatabases(node.connectionId);
|
|
} else if (config?.db_type === "mongodb" || config?.db_type === "elasticsearch") {
|
|
await loadMongoDatabases(node.connectionId);
|
|
} else {
|
|
await loadDatabases(node.connectionId, options);
|
|
}
|
|
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
|
|
await loadMongoCollections(node.connectionId, node.database);
|
|
} else if (node.type === "database" && node.connectionId && node.database) {
|
|
const config = getConfig(node.connectionId);
|
|
if (config?.db_type === "sqlserver") {
|
|
await loadSqlServerDatabaseObjects(node.connectionId, node.database, options);
|
|
} else if (usesTreeSchemaMode(config?.db_type)) {
|
|
await loadSchemas(node.connectionId, node.database, options);
|
|
} else {
|
|
await loadTables(node.connectionId, node.database, undefined, options);
|
|
}
|
|
} else if (node.type === "schema" && node.connectionId && node.database && node.schema) {
|
|
await loadTables(node.connectionId, node.database, node.schema, options);
|
|
} else if ((node.type === "table" || node.type === "view") && node.connectionId && node.database) {
|
|
await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id);
|
|
} else if (node.type === "group-columns" && node.connectionId && node.database && node.tableName) {
|
|
await loadColumns(node.connectionId, node.database, node.tableName, node.schema);
|
|
} else if (node.type === "group-indexes" && node.connectionId && node.database && node.tableName) {
|
|
await loadIndexes(node.connectionId, node.database, node.tableName, node.schema);
|
|
} else if (node.type === "group-fkeys" && node.connectionId && node.database && node.tableName) {
|
|
await loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema);
|
|
} else if (node.type === "group-triggers" && node.connectionId && node.database && node.tableName) {
|
|
await loadTriggers(node.connectionId, node.database, node.tableName, node.schema);
|
|
} else if (
|
|
node.type === "group-tables" ||
|
|
node.type === "group-views" ||
|
|
node.type === "group-procedures" ||
|
|
node.type === "group-functions"
|
|
) {
|
|
node.isExpanded = true;
|
|
}
|
|
}
|
|
|
|
async function restoreExpandedChildren(node: TreeNode, expandedIds: Set<string>, options?: LoadTreeOptions) {
|
|
if (!node.children) return;
|
|
for (const child of node.children) {
|
|
if (!expandedIds.has(child.id)) continue;
|
|
await loadTreeNodeChildren(child, options);
|
|
await restoreExpandedChildren(child, expandedIds, options);
|
|
}
|
|
}
|
|
|
|
async function refreshTreeNode(node: TreeNode) {
|
|
const parentId = objectGroupRefreshParentId(node);
|
|
const parentNode = parentId ? findNode(treeNodes.value, parentId) : null;
|
|
if (parentNode) {
|
|
await refreshTreeNode(parentNode);
|
|
return;
|
|
}
|
|
|
|
const expandedIds = collectExpandedNodeIds([node]);
|
|
expandedIds.add(node.id);
|
|
await clearPersistedTreeCacheForNode(node);
|
|
clearLoadedChildrenCache(node.id);
|
|
if (node.type !== "connection-group") {
|
|
node.children = [];
|
|
}
|
|
await loadTreeNodeChildren(node, { force: true });
|
|
await restoreExpandedChildren(node, expandedIds, { force: true });
|
|
}
|
|
|
|
async function refreshDatabaseTreeNode(connectionId: string, database: string) {
|
|
const node = findDatabaseTreeNode(treeNodes.value, connectionId, database);
|
|
if (node) {
|
|
await refreshTreeNode(node);
|
|
return;
|
|
}
|
|
await loadDatabases(connectionId, { force: true });
|
|
}
|
|
|
|
async function refreshObjectListTreeNode(connectionId: string, database: string, schema?: string) {
|
|
const config = getConfig(connectionId);
|
|
const shouldRefreshSchemaNode = schema && !(config?.db_type === "sqlserver" && schema.toLowerCase() === "dbo");
|
|
const node = shouldRefreshSchemaNode ? findNode(treeNodes.value, `${connectionId}:${database}:${schema}`) : null;
|
|
if (node) {
|
|
await refreshTreeNode(node);
|
|
return;
|
|
}
|
|
await refreshDatabaseTreeNode(connectionId, database);
|
|
}
|
|
|
|
function isSchemaAwareDatabase(connectionId: string): boolean {
|
|
return isSchemaAware(getConfig(connectionId)?.db_type);
|
|
}
|
|
|
|
const COMPLETION_CACHE_MAX = 50;
|
|
|
|
function evictOldestCacheEntries(cache: Record<string, unknown>, max: number) {
|
|
const keys = Object.keys(cache);
|
|
if (keys.length <= max) return;
|
|
const toRemove = keys.slice(0, keys.length - max);
|
|
for (const key of toRemove) {
|
|
delete cache[key];
|
|
}
|
|
}
|
|
|
|
async function listCompletionTables(
|
|
connectionId: string,
|
|
database: string,
|
|
filter = "",
|
|
limit?: number,
|
|
schema?: string,
|
|
): Promise<SqlCompletionTable[]> {
|
|
const normalizedFilter = filter.trim().toLowerCase();
|
|
const cacheKey = `${connectionId}:${database}:${normalizedFilter}:${limit ?? ""}:${schema ?? ""}`;
|
|
if (completionTablesCache.value[cacheKey]) {
|
|
return completionTablesCache.value[cacheKey];
|
|
}
|
|
|
|
await ensureConnected(connectionId);
|
|
|
|
if (isSchemaAwareDatabase(connectionId)) {
|
|
const schemas = schema ? [schema] : await api.listSchemas(connectionId, database);
|
|
if (normalizedFilter || limit) {
|
|
const limitedTables: SqlCompletionTable[] = [];
|
|
for (const schema of schemas) {
|
|
try {
|
|
const remaining = limit ? Math.max(limit - limitedTables.length, 0) : undefined;
|
|
if (remaining === 0) break;
|
|
const tables = await api.listTables(connectionId, database, schema, normalizedFilter, remaining);
|
|
limitedTables.push(
|
|
...tables.map((table) => ({
|
|
name: table.name,
|
|
schema,
|
|
type: table.table_type === "VIEW" ? ("view" as const) : ("table" as const),
|
|
})),
|
|
);
|
|
} catch {
|
|
/* ignore schema failures */
|
|
}
|
|
}
|
|
completionTablesCache.value[cacheKey] = limitedTables;
|
|
evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX);
|
|
return completionTablesCache.value[cacheKey];
|
|
}
|
|
|
|
const tableGroups = await Promise.all(
|
|
schemas.map(async (schema) => {
|
|
try {
|
|
const tables = await api.listTables(connectionId, database, schema);
|
|
return tables.map((table) => ({
|
|
name: table.name,
|
|
schema,
|
|
type: table.table_type === "VIEW" ? ("view" as const) : ("table" as const),
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}),
|
|
);
|
|
completionTablesCache.value[cacheKey] = tableGroups.flat();
|
|
evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX);
|
|
return completionTablesCache.value[cacheKey];
|
|
}
|
|
|
|
const tables = await api.listTables(connectionId, database, database, normalizedFilter, limit);
|
|
completionTablesCache.value[cacheKey] = tables.map((table) => ({
|
|
name: table.name,
|
|
type: table.table_type === "VIEW" ? ("view" as const) : ("table" as const),
|
|
}));
|
|
evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX);
|
|
return completionTablesCache.value[cacheKey];
|
|
}
|
|
|
|
async function listCompletionColumns(
|
|
connectionId: string,
|
|
database: string,
|
|
table: string,
|
|
schema?: string,
|
|
): Promise<SqlCompletionColumn[]> {
|
|
const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`;
|
|
if (!completionColumnsCache.value[cacheKey]) {
|
|
await ensureConnected(connectionId);
|
|
const querySchema = schema || database;
|
|
completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, table);
|
|
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
|
|
}
|
|
|
|
return completionColumnsCache.value[cacheKey].map((column) => ({
|
|
name: column.name,
|
|
table,
|
|
schema,
|
|
dataType: column.data_type,
|
|
}));
|
|
}
|
|
|
|
function findNode(nodes: TreeNode[], id: string): TreeNode | null {
|
|
for (const node of nodes) {
|
|
if (node.id === id) return node;
|
|
if (node.children) {
|
|
const found = findNode(node.children, id);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function persistConnections(nextConnections: ConnectionConfig[] = connections.value) {
|
|
await api.saveConnections(nextConnections);
|
|
}
|
|
|
|
function persistSidebarLayoutDebounced() {
|
|
if (layoutPersistTimer) clearTimeout(layoutPersistTimer);
|
|
layoutPersistTimer = setTimeout(() => {
|
|
api.saveSidebarLayout(sidebarLayout.value).catch(() => {});
|
|
layoutPersistTimer = null;
|
|
}, 300);
|
|
}
|
|
|
|
function rebuildTreeNodes() {
|
|
const existingNodesMap = new Map<string, TreeNode>();
|
|
const collectExisting = (nodes: TreeNode[]) => {
|
|
for (const node of nodes) {
|
|
existingNodesMap.set(node.id, node);
|
|
if (node.children) collectExisting(node.children);
|
|
}
|
|
};
|
|
collectExisting(treeNodes.value);
|
|
|
|
const freshNodes = buildTreeNodesFromLayout(sidebarLayout.value, connections.value, pinnedTreeNodeIds.value);
|
|
const mergeState = (nodes: TreeNode[]): TreeNode[] =>
|
|
nodes.map((node) => {
|
|
const existing = existingNodesMap.get(node.id);
|
|
if (node.type === "connection-group") {
|
|
return { ...node, children: mergeState(node.children || []) };
|
|
}
|
|
if (existing && node.type === "connection") {
|
|
return {
|
|
...existing,
|
|
label: node.label,
|
|
pinned: node.pinned,
|
|
children: withSavedSqlRoot(node.connectionId!, existing.children || [], existing),
|
|
};
|
|
}
|
|
if (node.type === "connection" && node.connectionId) {
|
|
return { ...node, children: withSavedSqlRoot(node.connectionId, node.children || []) };
|
|
}
|
|
return node;
|
|
});
|
|
treeNodes.value = mergeState(freshNodes);
|
|
}
|
|
|
|
function updateLayoutAndRebuild(nextLayout: SidebarLayout) {
|
|
sidebarLayout.value = nextLayout;
|
|
rebuildTreeNodes();
|
|
persistSidebarLayoutDebounced();
|
|
}
|
|
|
|
async function refreshAllTree() {
|
|
const expandedIds = collectExpandedNodeIds(treeNodes.value);
|
|
const refreshExpandedNodes = async (nodes: TreeNode[]) => {
|
|
for (const node of nodes) {
|
|
if (node.type === "connection-group") {
|
|
if (node.children) await refreshExpandedNodes(node.children);
|
|
continue;
|
|
}
|
|
if (!expandedIds.has(node.id)) continue;
|
|
clearLoadedChildrenCache(node.id);
|
|
node.children = [];
|
|
await loadTreeNodeChildren(node, { force: true });
|
|
await restoreExpandedChildren(node, expandedIds, { force: true });
|
|
}
|
|
};
|
|
await refreshExpandedNodes(treeNodes.value);
|
|
}
|
|
|
|
async function exportConnectionsToFile(passphrase: string) {
|
|
const { encryptConfig } = await import("@/lib/configCrypto");
|
|
const exportData = { connections: connections.value, layout: sidebarLayout.value };
|
|
const json = JSON.stringify(exportData);
|
|
const payload = await encryptConfig(json, passphrase);
|
|
const content = JSON.stringify(payload, null, 2);
|
|
|
|
if (isTauriRuntime()) {
|
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
const path = await save({
|
|
filters: [{ name: "JSON", extensions: ["json"] }],
|
|
defaultPath: "dbx-connections.json",
|
|
});
|
|
if (!path) return;
|
|
await writeTextFile(path, content);
|
|
} else {
|
|
const blob = new Blob([content], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "dbx-connections.json";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|
|
|
|
function bytesToBase64(bytes: Uint8Array) {
|
|
let binary = "";
|
|
const chunkSize = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
binary += String.fromCharCode(...bytes.slice(i, i + chunkSize));
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function siblingCredentialsPath(path: string) {
|
|
const fileName = path.split(/[\\/]/).pop() || "";
|
|
const credentialsFile = fileName.startsWith("data-sources-")
|
|
? fileName.replace(/^data-sources/, "credentials-config")
|
|
: "credentials-config.json";
|
|
return path.replace(/[^\\/]+$/, credentialsFile);
|
|
}
|
|
|
|
async function readDbeaverImportFile(): Promise<{ content: string; encrypted: boolean } | null> {
|
|
let dataSources: string;
|
|
let credentialsBase64 = "";
|
|
|
|
if (isTauriRuntime()) {
|
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
const { readTextFile, readFile } = await import("@tauri-apps/plugin-fs");
|
|
const path = await open({
|
|
filters: [{ name: "DBeaver Data Sources", extensions: ["json"] }],
|
|
multiple: false,
|
|
});
|
|
if (!path) return null;
|
|
const dataSourcesPath = path as string;
|
|
dataSources = await readTextFile(dataSourcesPath);
|
|
try {
|
|
credentialsBase64 = bytesToBase64(await readFile(siblingCredentialsPath(dataSourcesPath)));
|
|
} catch {
|
|
credentialsBase64 = "";
|
|
}
|
|
} else {
|
|
const files = await new Promise<FileList>((resolve, reject) => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = ".json";
|
|
input.multiple = true;
|
|
input.onchange = () => {
|
|
if (!input.files?.length) {
|
|
reject(new Error("No file selected"));
|
|
return;
|
|
}
|
|
resolve(input.files);
|
|
};
|
|
input.click();
|
|
});
|
|
const fileList = Array.from(files);
|
|
const dataSourcesFile =
|
|
fileList.find((file) => /^data-sources.*\.json$/i.test(file.name)) ||
|
|
fileList.find((file) => !/^credentials-config.*\.json$/i.test(file.name));
|
|
const credentialsFile = fileList.find((file) => /^credentials-config.*\.json$/i.test(file.name));
|
|
if (!dataSourcesFile) throw new Error("Select DBeaver data-sources.json");
|
|
dataSources = await dataSourcesFile.text();
|
|
if (credentialsFile) {
|
|
credentialsBase64 = bytesToBase64(new Uint8Array(await credentialsFile.arrayBuffer()));
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: JSON.stringify({ format: "dbeaver-import", dataSources, credentialsBase64 }),
|
|
encrypted: false,
|
|
};
|
|
}
|
|
|
|
async function readImportFile(source: ImportSource = "dbx"): Promise<{ content: string; encrypted: boolean } | null> {
|
|
if (source === "dbeaver") return readDbeaverImportFile();
|
|
|
|
let content: string;
|
|
|
|
if (isTauriRuntime()) {
|
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
|
const path = await open({
|
|
filters:
|
|
source === "navicat"
|
|
? [{ name: "Navicat Connection Export", extensions: ["ncx", "xml"] }]
|
|
: [{ name: "DBX JSON", extensions: ["json"] }],
|
|
multiple: false,
|
|
});
|
|
if (!path) return null;
|
|
content = await readTextFile(path as string);
|
|
} else {
|
|
content = await new Promise<string>((resolve, reject) => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = source === "navicat" ? ".ncx,.xml" : ".json";
|
|
input.onchange = () => {
|
|
const file = input.files?.[0];
|
|
if (!file) {
|
|
reject(new Error("No file selected"));
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.readAsText(file);
|
|
};
|
|
input.click();
|
|
});
|
|
}
|
|
|
|
if (content.trimStart().startsWith("<")) {
|
|
return { content, encrypted: false };
|
|
}
|
|
|
|
const { isEncryptedConfig } = await import("@/lib/configCrypto");
|
|
const parsed = JSON.parse(content);
|
|
return { content, encrypted: isEncryptedConfig(parsed) };
|
|
}
|
|
|
|
async function importConnectionsFromFile(
|
|
content: string,
|
|
passphrase: string | null,
|
|
): Promise<{ count: number; layout?: SidebarLayout }> {
|
|
let imported: ConnectionConfig[] = [];
|
|
let importedLayout: SidebarLayout | undefined;
|
|
|
|
if (!passphrase && content.trimStart().startsWith("<")) {
|
|
const { parseNavicatConnections } = await import("@/lib/navicatImport");
|
|
imported = await parseNavicatConnections(content);
|
|
} else if (!passphrase) {
|
|
const { isDbeaverImportPayload, parseDbeaverConnections } = await import("@/lib/dbeaverImport");
|
|
if (isDbeaverImportPayload(content)) {
|
|
imported = await parseDbeaverConnections(content);
|
|
} else {
|
|
const parsed = JSON.parse(content);
|
|
|
|
if (Array.isArray(parsed)) {
|
|
imported = parsed;
|
|
} else if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) {
|
|
imported = parsed.connections;
|
|
} else if (parsed.connections && Array.isArray(parsed.connections)) {
|
|
imported = parsed.connections;
|
|
if (parsed.layout?.groups && parsed.layout?.order) {
|
|
importedLayout = parsed.layout;
|
|
}
|
|
} else {
|
|
imported = [];
|
|
}
|
|
}
|
|
} else {
|
|
const parsed = JSON.parse(content);
|
|
|
|
if (passphrase) {
|
|
const { decryptConfig } = await import("@/lib/configCrypto");
|
|
const json = await decryptConfig(parsed, passphrase);
|
|
const decrypted = JSON.parse(json);
|
|
if (Array.isArray(decrypted)) {
|
|
imported = decrypted;
|
|
} else if (decrypted.connections) {
|
|
imported = decrypted.connections;
|
|
if (decrypted.layout?.groups && decrypted.layout?.order) {
|
|
importedLayout = decrypted.layout;
|
|
}
|
|
} else {
|
|
imported = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
let count = 0;
|
|
for (const config of imported) {
|
|
const duplicate = connections.value.find(
|
|
(c) => c.name === config.name && c.host === config.host && c.port === config.port,
|
|
);
|
|
if (!duplicate) {
|
|
config.id = uuid();
|
|
const normalized = normalizeConnection(config);
|
|
await addConnection(normalized);
|
|
count++;
|
|
}
|
|
}
|
|
return { count, layout: importedLayout };
|
|
}
|
|
|
|
function applySidebarLayout(layout: SidebarLayout) {
|
|
const reconciledLayout = reconcileLayout(
|
|
connections.value.map((c) => c.id),
|
|
layout,
|
|
);
|
|
updateLayoutAndRebuild(reconciledLayout);
|
|
}
|
|
|
|
async function initFromDisk() {
|
|
const saved = await api.loadConnections();
|
|
connections.value = saved.map(normalizeConnection);
|
|
const savedLayout = await api.loadSidebarLayout();
|
|
sidebarLayout.value = reconcileLayout(
|
|
connections.value.map((c) => c.id),
|
|
savedLayout,
|
|
);
|
|
rebuildTreeNodes();
|
|
}
|
|
|
|
function addEphemeralConnection(config: ConnectionConfig) {
|
|
const normalized = normalizeConnection(config);
|
|
if (!connections.value.find((c) => c.id === normalized.id)) {
|
|
connections.value.push(normalized);
|
|
}
|
|
connectedIds.value.add(normalized.id);
|
|
clearConnectionError(normalized.id);
|
|
}
|
|
|
|
return {
|
|
connections,
|
|
activeConnectionId,
|
|
selectedTreeNodeId,
|
|
treeNodes,
|
|
removeTreeNode,
|
|
refreshAllTree,
|
|
refreshSavedSqlTree,
|
|
refreshTreeNode,
|
|
refreshDatabaseTreeNode,
|
|
refreshObjectListTreeNode,
|
|
connectedIds,
|
|
connectionErrors,
|
|
setConnectionError,
|
|
clearConnectionError,
|
|
recordConnectionError,
|
|
sidebarLayout,
|
|
getConfig,
|
|
isTreeNodePinned,
|
|
toggleTreeNodePin,
|
|
addConnection,
|
|
addEphemeralConnection,
|
|
updateConnection,
|
|
setDefaultDatabase,
|
|
clearDefaultDatabase,
|
|
isDefaultDatabase,
|
|
setVisibleDatabases,
|
|
clearVisibleDatabases,
|
|
removeConnection,
|
|
editingConnectionId,
|
|
newConnectionGroupId,
|
|
startEditing,
|
|
stopEditing,
|
|
startCreatingConnectionInGroup,
|
|
stopCreatingConnectionInGroup,
|
|
connect,
|
|
disconnect,
|
|
ensureConnected,
|
|
setBeforeConnectHandler,
|
|
initFromDisk,
|
|
loadDatabases,
|
|
loadRedisDatabases,
|
|
updateRedisDbKeyStats,
|
|
loadMongoDatabases,
|
|
loadMongoCollections,
|
|
loadSchemas,
|
|
loadSqlServerDatabaseObjects,
|
|
loadTables,
|
|
loadTableGroups,
|
|
loadColumns,
|
|
loadIndexes,
|
|
loadForeignKeys,
|
|
loadTriggers,
|
|
listCompletionTables,
|
|
listCompletionColumns,
|
|
exportConnectionsToFile,
|
|
readImportFile,
|
|
importConnectionsFromFile,
|
|
applySidebarLayout,
|
|
transferSource,
|
|
schemaDiffSource,
|
|
dataCompareSource,
|
|
sqlFileSource,
|
|
diagramSource,
|
|
tableImportSource,
|
|
structureEditorSource,
|
|
fieldLineageSource,
|
|
databaseSearchSource,
|
|
databaseExportSource,
|
|
createConnectionGroup(name: string) {
|
|
const result = createGroupOp(sidebarLayout.value, name);
|
|
updateLayoutAndRebuild(result.layout);
|
|
return result.groupId;
|
|
},
|
|
renameConnectionGroup(groupId: string, name: string) {
|
|
updateLayoutAndRebuild(renameGroupOp(sidebarLayout.value, groupId, name));
|
|
},
|
|
deleteConnectionGroup(groupId: string) {
|
|
updateLayoutAndRebuild(deleteGroupOp(sidebarLayout.value, groupId));
|
|
},
|
|
toggleConnectionGroupCollapsed(groupId: string) {
|
|
updateLayoutAndRebuild(toggleGroupCollapsedOp(sidebarLayout.value, groupId));
|
|
},
|
|
moveConnectionToGroup(connectionId: string, groupId: string | null) {
|
|
updateLayoutAndRebuild(moveConnectionToGroupOp(sidebarLayout.value, connectionId, groupId));
|
|
},
|
|
reorderSidebarEntry(draggedId: string, targetId: string, position: DropPosition) {
|
|
updateLayoutAndRebuild(reorderEntryOp(sidebarLayout.value, draggedId, targetId, position));
|
|
},
|
|
};
|
|
});
|