♻️ refactor(sidebar): 抽出树节点加载器

This commit is contained in:
Syngnat
2026-06-19 17:32:45 +08:00
parent 39e52469f2
commit 6e422aea33
3 changed files with 900 additions and 776 deletions

View File

@@ -68,6 +68,7 @@ const readSidebarSource = () => [
readSourceFile('./sidebar/sidebarMetadataLoaders.ts'),
readSourceFile('./sidebar/useSidebarBatchExport.ts'),
readSourceFile('./sidebar/SidebarExternalSqlWorkflow.tsx'),
readSourceFile('./sidebar/useSidebarTreeLoaders.tsx'),
readSourceFile('./sidebarV2Utils.ts'),
].join('\n');
const readLegacyNodeMenuSource = () => readSourceFile('./sidebar/sidebarLegacyNodeMenu.tsx');

View File

@@ -4,32 +4,25 @@ import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/Side
import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu';
import {
buildDuckDBMacroDDL,
buildQualifiedName,
buildSidebarObjectKeyName,
buildSidebarTableStatusSQL,
escapeSQLLiteral,
extractSqlServerDefinitionRows,
getCaseInsensitiveRawValue,
getCaseInsensitiveValue,
getMetadataDialect,
getMySQLShowTablesName,
getSidebarTableDisplayName,
isSphinxConnection,
loadDatabaseEvents,
loadDatabaseTriggers,
loadFunctions,
loadSchemas,
loadStarRocksMaterializedViews,
loadViews,
parseMetadataRowCount,
shouldHideSchemaPrefix,
splitQualifiedName,
supportsDatabaseEvents,
} from './sidebar/sidebarMetadataLoaders';
import {
useSidebarBatchExport,
} from './sidebar/useSidebarBatchExport';
import { SidebarBatchExportModals } from './sidebar/SidebarBatchExportModals';
import {
normalizeDriverType,
useSidebarTreeLoaders,
} from './sidebar/useSidebarTreeLoaders';
export { formatSidebarDriverAgentUpdateWarning } from './sidebar/useSidebarTreeLoaders';
import {
ExternalSQLFileModal,
SQLFileExecutionModal,
@@ -125,9 +118,9 @@ import {
useStore,
} from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, ListSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
import { DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, ListSQLDirectory } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
@@ -290,31 +283,6 @@ type SidebarMessagePublishTarget = {
destination: string;
};
type DriverStatusSnapshot = {
type: string;
name: string;
connectable: boolean;
expectedRevision?: string;
needsUpdate?: boolean;
updateReason?: string;
message?: string;
};
export const formatSidebarDriverAgentUpdateWarning = (
driverName: string,
status: Pick<DriverStatusSnapshot, 'message' | 'updateReason'>,
): string => {
const rawMessage = String(status.message || '').trim();
if (rawMessage) {
return rawMessage;
}
const rawUpdateReason = String(status.updateReason || '').trim();
if (rawUpdateReason) {
return rawUpdateReason;
}
return t('connection.modal.driver.updateFallback', { name: driverName });
};
const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => {
if (!conn) return '';
return JSON.stringify({
@@ -329,34 +297,6 @@ const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => {
return text === connectionId || text.startsWith(`${connectionId}-`);
};
const DRIVER_STATUS_CACHE_TTL_MS = 30_000;
const normalizeDriverType = (value: string): string => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres';
if (normalized === 'doris') return 'diros';
if (
normalized === 'open_gauss' ||
normalized === 'open-gauss' ||
normalized === 'opengauss'
) return 'opengauss';
if (
normalized === 'intersystems' ||
normalized === 'intersystemsiris' ||
normalized === 'inter-systems' ||
normalized === 'inter-systems-iris'
) return 'iris';
return normalized;
};
const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): string => {
const type = normalizeDriverType(conn?.config?.type || '');
if (type !== 'custom') {
return type;
}
return normalizeDriverType(conn?.config?.driver || '');
};
const isPostgresSchemaDialect = (dialect: string): boolean => (
['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(normalizeDriverType(dialect))
);
@@ -653,11 +593,6 @@ const Sidebar: React.FC<{
activeContext: null,
});
const connectionReloadSignaturesRef = useRef<Record<string, string>>({});
const driverStatusCacheRef = useRef<{
fetchedAt: number;
items: Record<string, DriverStatusSnapshot>;
} | null>(null);
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
const [contextMenu, setContextMenu] = useState<SidebarContextMenuState | null>(null);
const contextMenuPortalRef = useRef<HTMLDivElement | null>(null);
const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState<Record<string, V2TableContextMenuStats>>({});
@@ -1277,710 +1212,6 @@ const Sidebar: React.FC<{
return null;
};
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
const cached = driverStatusCacheRef.current;
if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) {
return cached.items;
}
const result: Record<string, DriverStatusSnapshot> = {};
const res = await GetDriverStatusList('', '');
if (!res?.success) {
return result;
}
const data = (res.data || {}) as any;
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
drivers.forEach((item: any) => {
const type = normalizeDriverType(String(item.type || '').trim());
if (!type) return;
result[type] = {
type,
name: String(item.name || item.type || type).trim(),
connectable: !!item.connectable,
expectedRevision: String(item.expectedRevision || '').trim() || undefined,
needsUpdate: !!item.needsUpdate,
updateReason: String(item.updateReason || '').trim() || undefined,
message: String(item.message || '').trim() || undefined,
};
});
driverStatusCacheRef.current = { fetchedAt: Date.now(), items: result };
return result;
};
const warnIfConnectionDriverAgentNeedsUpdate = async (conn: SavedConnection) => {
try {
const driverType = resolveSavedConnectionDriverType(conn);
if (!driverType || driverType === 'custom') {
return;
}
const statusMap = await fetchDriverStatusMap();
const status = statusMap[driverType];
if (!status?.connectable || !status.needsUpdate) {
return;
}
const revisionKey = status.expectedRevision || status.updateReason || status.message || 'unknown';
const warningKey = `${conn.id}:${driverType}:${revisionKey}`;
if (driverUpdateWarningKeysRef.current.has(warningKey)) {
return;
}
driverUpdateWarningKeysRef.current.add(warningKey);
const driverName = status.name || driverType;
message.warning({
content: formatSidebarDriverAgentUpdateWarning(driverName, status),
key: `driver-agent-update-${conn.id}`,
duration: 10,
});
} catch (error) {
console.warn('检查驱动代理更新状态失败', error);
}
};
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
const loadKey = `dbs-${conn.id}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
if (conn.config.type === 'jvm') {
try {
const res = await JVMProbeCapabilities(buildRuntimeConfig(conn) as any);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const capabilities: JVMCapability[] = Array.isArray(res.data) ? res.data as JVMCapability[] : [];
const modeNodes: TreeNode[] = capabilities.map((capability) => ({
title: capability.displayLabel || capability.mode,
key: `${conn.id}-jvm-mode-${capability.mode}`,
icon: <HddOutlined />,
type: 'jvm-mode',
dataRef: {
...conn,
providerMode: capability.mode,
canBrowse: capability.canBrowse,
canWrite: capability.canWrite,
reason: capability.reason,
displayLabel: capability.displayLabel,
},
isLeaf: capability.canBrowse !== true,
}));
const monitoringNodes: TreeNode[] = buildJVMMonitoringActionDescriptors(conn.id, capabilities).map((item) => ({
title: item.title,
key: item.key,
icon: <DashboardOutlined />,
type: 'jvm-monitoring',
dataRef: {
...conn,
providerMode: item.providerMode,
},
isLeaf: true,
}));
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
replaceTreeNodeChildren(node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]);
} else {
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
if (diagnosticNode.length > 0) {
replaceTreeNodeChildren(node.key, diagnosticNode);
message.warning({
content: t('sidebar.message.jvm_provider_probe_failed_with_diagnostic', {
error: res.message || t('sidebar.error.unknown'),
}),
key: `conn-${conn.id}-jvm-caps`,
});
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `conn-${conn.id}-jvm-caps` });
}
}
} catch (e: any) {
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
if (diagnosticNode.length > 0) {
replaceTreeNodeChildren(node.key, diagnosticNode);
message.warning({
content: t('sidebar.message.jvm_provider_probe_exception_with_diagnostic', {
error: e?.message || String(e),
}),
key: `conn-${conn.id}-jvm-caps`,
});
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({
content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }),
key: `conn-${conn.id}-jvm-caps`,
});
}
} finally {
loadingNodesRef.current.delete(loadKey);
}
return;
}
// Handle Redis connections differently
if (conn.config.type === 'redis') {
try {
const res = await (window as any).go.app.App.RedisGetDatabases(buildRpcConnectionConfig(config));
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const redisRows: any[] = Array.isArray(res.data) ? res.data : [];
let dbs = redisRows.map((db: any) => ({
title: `db${db.index}${db.keys > 0 ? ` (${db.keys})` : ''}`,
key: `${conn.id}-db${db.index}`,
icon: <DatabaseOutlined style={{ color: '#DC382D' }} />,
type: 'redis-db' as const,
dataRef: { ...conn, redisDB: db.index },
isLeaf: true,
dbIndex: db.index,
}));
// Filter Redis databases if configured
if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex));
}
replaceTreeNodeChildren(node.key, dbs);
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error({
content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }),
key: `conn-${conn.id}-dbs`,
});
} finally {
loadingNodesRef.current.delete(loadKey);
}
return;
}
try {
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
let dbs = dbRows.map((row: any) => ({
title: row.Database || row.database,
key: `${conn.id}-${row.Database || row.database}`,
icon: <DatabaseOutlined />,
type: 'database' as const,
dataRef: { ...conn, dbName: row.Database || row.database },
isLeaf: false,
}));
// Filter databases if configured
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
}
if (dbs.length > 0) {
replaceTreeNodeChildren(node.key, dbs);
} else {
// 空列表:清理 loadedKeys 以允许重新加载,不设置 children = []
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.warning({ content: t('sidebar.message.no_visible_databases'), key: `conn-${conn.id}-dbs` });
}
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({
content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }),
key: `conn-${conn.id}-dbs`,
});
} finally {
loadingNodesRef.current.delete(loadKey);
}
};
const loadJVMResources = async (node: any) => {
const conn = node.dataRef as SavedConnection & { providerMode?: string; resourcePath?: string };
const providerMode = String(conn.providerMode || '').trim().toLowerCase();
const parentPath = String(conn.resourcePath || '').trim();
const loadKey = `jvm-resources-${conn.id}-${providerMode}-${parentPath}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
try {
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMListResources !== 'function') {
throw new Error(t('sidebar.message.jvm_resources_backend_unavailable'));
}
const res = await backendApp.JVMListResources(buildJVMRuntimeConfig(conn, providerMode), parentPath);
if (res.success) {
const resourceRows: JVMResourceSummary[] = Array.isArray(res.data) ? res.data as JVMResourceSummary[] : [];
const resourceNodes: TreeNode[] = resourceRows.map((item) => ({
title: item.name || item.path || item.id,
key: `${conn.id}-jvm-resource-${providerMode}-${item.path}`,
icon: item.hasChildren ? <FolderOpenOutlined /> : <HddOutlined />,
type: 'jvm-resource',
dataRef: {
...conn,
providerMode: item.providerMode || providerMode,
resourcePath: item.path,
resourceKind: item.kind,
canRead: item.canRead,
canWrite: item.canWrite,
hasChildren: item.hasChildren,
sensitive: item.sensitive,
},
isLeaf: item.hasChildren !== true,
}));
replaceTreeNodeChildren(node.key, resourceNodes);
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `jvm-resource-${node.key}` });
}
} catch (e: any) {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({
content: t('sidebar.message.load_jvm_resources_failed', { error: e?.message || String(e) }),
key: `jvm-resource-${node.key}`,
});
} finally {
loadingNodesRef.current.delete(loadKey);
}
};
const loadTables = async (node: any) => {
const conn = node.dataRef; // has dbName
const dbName = conn.dbName;
const key = node.key;
const loadKey = `tables-${conn.id}-${dbName}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
const queriesNode: TreeNode = {
title: t('sidebar.tree.saved_queries'),
key: `${key}-queries`,
icon: <FolderOpenOutlined />,
type: 'queries-folder',
isLeaf: dbQueries.length === 0,
children: dbQueries.map(q => ({
title: resolveSavedQueryDisplayName(q.name),
key: q.id,
icon: <FileTextOutlined />,
type: 'saved-query',
dataRef: q,
isLeaf: true
}))
};
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
try {
const res = await DBGetTables(buildRpcConnectionConfig(config) as any, conn.dbName);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
const tableRows: any[] = Array.isArray(res.data) ? res.data : [];
const tableStatusSql = buildSidebarTableStatusSQL(conn as SavedConnection, conn.dbName);
const tableStatsResult = tableStatusSql
? await DBQuery(buildRpcConnectionConfig(config) as any, conn.dbName, tableStatusSql).catch(() => ({ success: false, data: [] as any[] }))
: { success: false, data: [] as any[] };
const tableRowCountMap = new Map<string, number>();
if (tableStatsResult?.success && Array.isArray(tableStatsResult.data)) {
tableStatsResult.data.forEach((row: Record<string, any>) => {
const rawTableName = String(
getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'Name', 'name'])
|| getMySQLShowTablesName(row)
|| ''
).trim();
if (!rawTableName) return;
const rowCount = parseMetadataRowCount(row);
if (rowCount === undefined) return;
tableRowCountMap.set(rawTableName.toLowerCase(), rowCount);
});
}
const tableEntries = tableRows.map((row: any) => {
const tableName = Object.values(row)[0] as string;
const parsed = splitQualifiedName(tableName);
return {
tableName,
schemaName: parsed.schemaName,
displayName: getSidebarTableDisplayName(conn, tableName),
rowCount: tableRowCountMap.get(String(tableName || '').trim().toLowerCase()),
};
});
const [schemasResult, viewsResult, materializedViewsResult, triggersResult, routinesResult, eventsResult] = await Promise.all([
loadSchemas(conn, conn.dbName),
loadViews(conn, conn.dbName),
loadStarRocksMaterializedViews(conn, conn.dbName),
loadDatabaseTriggers(conn, conn.dbName),
loadFunctions(conn, conn.dbName),
loadDatabaseEvents(conn, conn.dbName),
]);
const externalSQLDirectoryResults = await Promise.all(
externalSQLDirectories.map(async (directory: ExternalSQLDirectory) => {
const directoryRes = await ListSQLDirectory(directory.path);
if (!directoryRes.success) {
message.warning({
key: `external-sql-${directory.id}`,
content: t('sidebar.message.external_sql_directory_read_failed', {
name: directory.name,
error: directoryRes.message,
}),
});
return { id: directory.id, entries: [] as ExternalSQLTreeEntry[] };
}
return {
id: directory.id,
entries: Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : [],
};
}),
);
const externalSQLTrees = externalSQLDirectoryResults.reduce<Record<string, ExternalSQLTreeEntry[]>>((accumulator, item) => {
accumulator[item.id] = item.entries;
return accumulator;
}, {});
const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({
dbNodeKey: String(key),
connectionId: String(conn.id),
dbName: String(conn.dbName),
directories: externalSQLDirectories,
directoryTrees: externalSQLTrees,
labels: {
root: t('sidebar.external_sql.root'),
directoryFallback: t('sidebar.external_sql.directory_fallback'),
},
}));
const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewsResult.views) ? viewsResult.views : [];
const materializedViewRows: SidebarViewMetadataEntry[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : [];
const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : [];
const routineRows: any[] = Array.isArray(routinesResult.routines) ? routinesResult.routines : [];
const eventRows: any[] = Array.isArray(eventsResult.events) ? eventsResult.events : [];
const schemaRows: string[] = Array.isArray(schemasResult.schemas) ? schemasResult.schemas : [];
const viewEntries = viewRows.map((entry: SidebarViewMetadataEntry) => {
const parsed = splitQualifiedName(entry.viewName);
return {
viewName: entry.viewName,
schemaName: entry.schemaName || parsed.schemaName,
displayName: getSidebarTableDisplayName(conn, entry.viewName),
};
});
const materializedViewEntries = materializedViewRows.map((entry: SidebarViewMetadataEntry) => {
const parsed = splitQualifiedName(entry.viewName);
return {
viewName: entry.viewName,
schemaName: entry.schemaName || parsed.schemaName,
displayName: getSidebarTableDisplayName(conn, entry.viewName),
};
});
const triggerEntries = (() => {
const deduped: Array<{ displayName: string; triggerName: string; tableName: string; schemaName: string }> = [];
const triggerSeen = new Set<string>();
const metadataDialect = getMetadataDialect(conn as SavedConnection);
triggerRows.forEach((trigger: any) => {
const triggerParsed = splitQualifiedName(trigger.triggerName);
const tableParsed = splitQualifiedName(trigger.tableName);
const schemaName = tableParsed.schemaName || triggerParsed.schemaName || String(conn.dbName || '').trim();
const triggerObjectName = (triggerParsed.objectName || trigger.triggerName).trim();
const tableObjectName = (tableParsed.objectName || trigger.tableName).trim();
const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName;
const dedupeKey = metadataDialect === 'mysql'
? `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}`
: `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}@@${tableObjectName.toLowerCase()}`;
if (triggerSeen.has(dedupeKey)) return;
triggerSeen.add(dedupeKey);
deduped.push({
...trigger,
schemaName,
triggerName: triggerObjectName,
tableName: buildQualifiedName(schemaName, tableObjectName) || tableObjectName,
displayName,
});
});
return deduped;
})();
const routineEntries = routineRows.map((routine: any) => {
const parsed = splitQualifiedName(routine.routineName);
const typeLabel = routine.routineType === 'PROCEDURE' ? 'P' : 'F';
return {
...routine,
schemaName: parsed.schemaName,
displayName: `${parsed.objectName || routine.routineName} [${typeLabel}]`,
};
});
const eventEntries = eventRows.map((event: any) => ({
...event,
schemaName: String(event.schemaName || conn.dbName || '').trim(),
displayName: String(event.displayName || event.eventName || '').trim(),
})).filter((event: any) => event.eventName && event.displayName);
if (isSphinxConnection(conn as SavedConnection)) {
const unsupportedObjects: string[] = [];
if (!viewsResult.supported) unsupportedObjects.push(t('sidebar.object_group.views'));
if (!routinesResult.supported) unsupportedObjects.push(t('sidebar.object_group.routines'));
if (!triggersResult.supported) unsupportedObjects.push(t('sidebar.object_group.triggers'));
if (unsupportedObjects.length > 0) {
message.info({
key: `sphinx-capability-${conn.id}-${conn.dbName}`,
content: t('sidebar.message.sphinx_unsupported_objects', {
objects: unsupportedObjects.join(t('sidebar.punctuation.list_separator')),
}),
});
}
}
const currentStoreState = useStore.getState();
const currentTableSortPreference = currentStoreState.tableSortPreference || tableSortPreference;
const currentTableAccessCount = currentStoreState.tableAccessCount || tableAccessCount;
const currentPinnedSidebarTables = currentStoreState.pinnedSidebarTables || pinnedSidebarTables;
// 获取当前数据库的排序偏好
const sortPreferenceKey = `${conn.id}-${conn.dbName}`;
const sortBy = currentTableSortPreference[sortPreferenceKey] || 'name';
const sortedTableEntries = sortSidebarTableEntries(tableEntries, {
connectionId: conn.id,
dbName: conn.dbName,
sortBy,
tableAccessCount: currentTableAccessCount,
pinnedSidebarTables: isV2Ui ? currentPinnedSidebarTables : [],
});
// Sort views by name (case-insensitive)
viewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
materializedViewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
// Sort triggers by display name (case-insensitive)
triggerEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
// Sort routines by display name (case-insensitive)
routineEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
eventEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string; rowCount?: number }): TreeNode => {
const isPinned = isV2Ui && isSidebarTablePinned(
currentPinnedSidebarTables,
conn.id,
conn.dbName,
entry.tableName,
entry.schemaName,
);
return {
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-${entry.tableName}`,
icon: <TableOutlined />,
type: 'table',
dataRef: {
...conn,
tableName: entry.tableName,
schemaName: entry.schemaName,
rowCount: entry.rowCount,
...(isPinned ? { pinnedSidebarTable: true } : {}),
},
isLeaf: false,
};
};
const buildViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => {
const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.viewName);
return {
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-view-${keyName}`,
icon: <EyeOutlined />,
type: 'view',
dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName },
isLeaf: true,
};
};
const buildMaterializedViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => {
const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.viewName);
return {
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-materialized-view-${keyName}`,
icon: <ThunderboltOutlined />,
type: 'materialized-view',
dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName, objectKind: 'materialized-view' },
isLeaf: true,
};
};
const buildTriggerNode = (entry: { triggerName: string; tableName: string; schemaName: string; displayName: string }): TreeNode => ({
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-trigger-${entry.triggerName}-${entry.tableName}`,
icon: <FunctionOutlined />,
type: 'db-trigger',
dataRef: { ...conn, triggerName: entry.triggerName, triggerTableName: entry.tableName, tableName: entry.tableName, schemaName: entry.schemaName },
isLeaf: true,
});
const buildRoutineNode = (entry: { routineName: string; routineType: string; schemaName: string; displayName: string }): TreeNode => ({
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-routine-${entry.routineName}`,
icon: <CodeOutlined />,
type: 'routine',
dataRef: { ...conn, routineName: entry.routineName, routineType: entry.routineType, schemaName: entry.schemaName },
isLeaf: true,
});
const buildEventNode = (entry: { eventName: string; schemaName: string; displayName: string; eventType?: string; status?: string }): TreeNode => ({
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-event-${entry.schemaName}-${entry.eventName}`,
icon: <ClockCircleOutlined />,
type: 'db-event',
dataRef: { ...conn, eventName: entry.eventName, schemaName: entry.schemaName, eventType: entry.eventType, eventStatus: entry.status },
isLeaf: true,
});
const buildObjectGroup = (
parentKey: string,
groupKey: string,
groupTitle: string,
groupIcon: React.ReactNode,
children: TreeNode[],
extraData: Record<string, any> = {}
): TreeNode => {
const groupNodeKey = `${parentKey}-${groupKey}`;
const groupedChildren = groupKey === 'tables'
? buildSidebarTableChildrenForUi(groupNodeKey, children, isV2Ui)
: children;
return {
title: groupTitle,
key: groupNodeKey,
icon: groupIcon,
type: 'object-group',
isLeaf: children.length === 0,
children: groupedChildren.length > 0 ? groupedChildren : undefined,
dataRef: { ...conn, dbName: conn.dbName, groupKey, ...extraData }
};
};
const shouldGroupBySchema = shouldHideSchemaPrefix(conn as SavedConnection);
if (shouldGroupBySchema) {
type SchemaBucket = {
schemaName: string;
tables: TreeNode[];
views: TreeNode[];
materializedViews: TreeNode[];
routines: TreeNode[];
triggers: TreeNode[];
events: TreeNode[];
};
const schemaMap = new Map<string, SchemaBucket>();
const getSchemaBucket = (rawSchemaName: string): SchemaBucket => {
const schemaName = String(rawSchemaName || '').trim();
const schemaKey = schemaName || '__default__';
let bucket = schemaMap.get(schemaKey);
if (!bucket) {
bucket = {
schemaName,
tables: [],
views: [],
materializedViews: [],
routines: [],
triggers: [],
events: [],
};
schemaMap.set(schemaKey, bucket);
}
return bucket;
};
schemaRows.forEach((schemaName) => getSchemaBucket(schemaName));
sortedTableEntries.forEach((entry) => getSchemaBucket(entry.schemaName).tables.push(buildTableNode(entry)));
viewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).views.push(buildViewNode(entry)));
materializedViewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).materializedViews.push(buildMaterializedViewNode(entry)));
routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry)));
triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry)));
eventEntries.forEach((entry) => getSchemaBucket(entry.schemaName).events.push(buildEventNode(entry)));
const dialect = getMetadataDialect(conn as SavedConnection);
const isOracleLike = (dialect === 'oracle' || dialect === 'dm');
const includeMaterializedViews = dialect === 'starrocks';
const includeEvents = supportsDatabaseEvents(conn as SavedConnection);
const schemaNodes: TreeNode[] = Array.from(schemaMap.values())
.filter((bucket) => !(isOracleLike && !bucket.schemaName))
.sort((a, b) => {
if (!a.schemaName && !b.schemaName) return 0;
if (!a.schemaName) return -1;
if (!b.schemaName) return 1;
return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase());
})
.map((bucket) => {
const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`;
const schemaTitle = bucket.schemaName || t('sidebar.tree.default_schema');
const groupedNodes: TreeNode[] = [
buildObjectGroup(schemaNodeKey, 'tables', t('sidebar.object_group.tables'), <TableOutlined />, bucket.tables, { schemaName: bucket.schemaName }),
buildObjectGroup(schemaNodeKey, 'views', t('sidebar.object_group.views'), <EyeOutlined />, bucket.views, { schemaName: bucket.schemaName }),
...(includeMaterializedViews ? [buildObjectGroup(schemaNodeKey, 'materializedViews', t('sidebar.object_group.materialized_views'), <ThunderboltOutlined />, bucket.materializedViews, { schemaName: bucket.schemaName })] : []),
buildObjectGroup(schemaNodeKey, 'routines', t('sidebar.object_group.routines'), <CodeOutlined />, bucket.routines, { schemaName: bucket.schemaName }),
buildObjectGroup(schemaNodeKey, 'triggers', t('sidebar.object_group.triggers'), <FunctionOutlined />, bucket.triggers, { schemaName: bucket.schemaName }),
...(includeEvents ? [buildObjectGroup(schemaNodeKey, 'events', t('sidebar.object_group.events'), <ClockCircleOutlined />, bucket.events, { schemaName: bucket.schemaName })] : []),
];
return {
title: schemaTitle,
key: schemaNodeKey,
icon: <FolderOpenOutlined />,
type: 'object-group' as const,
isLeaf: groupedNodes.length === 0,
children: groupedNodes,
dataRef: { ...conn, dbName: conn.dbName, groupKey: 'schema', schemaName: bucket.schemaName }
};
});
replaceTreeNodeChildren(key, [queriesNode, ...schemaNodes]);
} else {
const includeMaterializedViews = getMetadataDialect(conn as SavedConnection) === 'starrocks';
const includeEvents = supportsDatabaseEvents(conn as SavedConnection);
const groupedNodes: TreeNode[] = [
buildObjectGroup(key as string, 'tables', t('sidebar.object_group.tables'), <TableOutlined />, sortedTableEntries.map(buildTableNode)),
buildObjectGroup(key as string, 'views', t('sidebar.object_group.views'), <EyeOutlined />, viewEntries.map(buildViewNode)),
...(includeMaterializedViews ? [buildObjectGroup(key as string, 'materializedViews', t('sidebar.object_group.materialized_views'), <ThunderboltOutlined />, materializedViewEntries.map(buildMaterializedViewNode))] : []),
buildObjectGroup(key as string, 'routines', t('sidebar.object_group.routines'), <CodeOutlined />, routineEntries.map(buildRoutineNode)),
buildObjectGroup(key as string, 'triggers', t('sidebar.object_group.triggers'), <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
...(includeEvents ? [buildObjectGroup(key as string, 'events', t('sidebar.object_group.events'), <ClockCircleOutlined />, eventEntries.map(buildEventNode))] : []),
];
replaceTreeNodeChildren(key, [queriesNode, ...groupedNodes]);
}
} else {
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
message.error({ content: res.message, key: `db-${key}-tables` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
message.error({
content: t('sidebar.message.load_table_list_failed', { error: e?.message || String(e) }),
key: `db-${key}-tables`,
});
} finally {
loadingNodesRef.current.delete(loadKey);
}
};
const locateObjectInSidebarRef = useRef<(detail: unknown) => Promise<void>>(async () => {});
const waitForSidebarLoadKey = async (loadKey: string): Promise<boolean> => {
@@ -3284,6 +2515,28 @@ const Sidebar: React.FC<{
return rawName || t('query_editor.save_modal.unnamed');
};
const {
loadDatabases,
loadJVMResources,
loadTables,
} = useSidebarTreeLoaders({
savedQueries,
externalSQLDirectories,
tableSortPreference,
tableAccessCount,
pinnedSidebarTables,
isV2Ui,
loadingNodesRef,
setConnectionStates,
setLoadedKeys,
replaceTreeNodeChildren,
buildRuntimeConfig,
buildJVMRuntimeConfig,
buildJVMDiagnosticTreeNodes,
resolveSavedQueryDisplayName,
decorateExternalSQLTreeNode,
});
const handleRenameSavedQuery = async () => {
if (!renameSavedQueryTarget) return;
try {

View File

@@ -0,0 +1,870 @@
import React, { useRef } from 'react';
import { message } from 'antd';
import {
CodeOutlined,
ClockCircleOutlined,
DashboardOutlined,
DatabaseOutlined,
EyeOutlined,
FileTextOutlined,
FolderOpenOutlined,
FunctionOutlined,
HddOutlined,
TableOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import type { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../../types';
import { useStore } from '../../store';
import { t } from '../../i18n';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { buildJVMMonitoringActionDescriptors } from '../../utils/jvmSidebarActions';
import { type SidebarViewMetadataEntry } from '../../utils/sidebarMetadata';
import { buildExternalSQLRootNode, type ExternalSQLTreeNode } from '../../utils/externalSqlTree';
import {
buildQualifiedName,
buildSidebarObjectKeyName,
buildSidebarTableStatusSQL,
getCaseInsensitiveValue,
getMetadataDialect,
getMySQLShowTablesName,
getSidebarTableDisplayName,
isSphinxConnection,
loadDatabaseEvents,
loadDatabaseTriggers,
loadFunctions,
loadSchemas,
loadStarRocksMaterializedViews,
loadViews,
parseMetadataRowCount,
shouldHideSchemaPrefix,
splitQualifiedName,
supportsDatabaseEvents,
} from './sidebarMetadataLoaders';
import {
buildSidebarTableChildrenForUi,
isSidebarTablePinned,
sortSidebarTableEntries,
type SidebarTreeNode as TreeNode,
} from '../sidebarV2Utils';
import { DBGetDatabases, DBGetTables, DBQuery, GetDriverStatusList, JVMProbeCapabilities, ListSQLDirectory } from '../../../wailsjs/go/app/App';
type DriverStatusSnapshot = {
type: string;
name: string;
connectable: boolean;
expectedRevision?: string;
needsUpdate?: boolean;
updateReason?: string;
message?: string;
};
export const formatSidebarDriverAgentUpdateWarning = (
driverName: string,
status: Pick<DriverStatusSnapshot, 'message' | 'updateReason'>,
): string => {
const rawMessage = String(status.message || '').trim();
if (rawMessage) {
return rawMessage;
}
const rawUpdateReason = String(status.updateReason || '').trim();
if (rawUpdateReason) {
return rawUpdateReason;
}
return t('connection.modal.driver.updateFallback', { name: driverName });
};
const buildConnectionReloadSignature = (conn?: SavedConnection | null): string => {
if (!conn) return '';
return JSON.stringify({
config: conn.config || {},
includeDatabases: conn.includeDatabases || [],
includeRedisDatabases: conn.includeRedisDatabases || [],
});
};
const isConnectionTreeKey = (key: React.Key, connectionId: string): boolean => {
const text = String(key);
return text === connectionId || text.startsWith(`${connectionId}-`);
};
const DRIVER_STATUS_CACHE_TTL_MS = 30_000;
export const normalizeDriverType = (value: string): string => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres';
if (normalized === 'doris') return 'diros';
if (
normalized === 'open_gauss' ||
normalized === 'open-gauss' ||
normalized === 'opengauss'
) return 'opengauss';
if (
normalized === 'intersystems' ||
normalized === 'intersystemsiris' ||
normalized === 'inter-systems' ||
normalized === 'inter-systems-iris'
) return 'iris';
return normalized;
};
const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): string => {
const type = normalizeDriverType(conn?.config?.type || '');
if (type !== 'custom') {
return type;
}
return normalizeDriverType(conn?.config?.driver || '');
};
type UseSidebarTreeLoadersOptions = {
savedQueries: SavedQuery[];
externalSQLDirectories: ExternalSQLDirectory[];
tableSortPreference: Record<string, any>;
tableAccessCount: Record<string, any>;
pinnedSidebarTables: any[];
isV2Ui: boolean;
loadingNodesRef: React.MutableRefObject<Set<string>>;
setConnectionStates: React.Dispatch<React.SetStateAction<Record<string, 'success' | 'error'>>>;
setLoadedKeys: React.Dispatch<React.SetStateAction<React.Key[]>>;
replaceTreeNodeChildren: (key: React.Key, children: TreeNode[] | undefined) => TreeNode[];
buildRuntimeConfig: (conn: any, overrideDatabase?: string, clearDatabase?: boolean) => any;
buildJVMRuntimeConfig: (conn: SavedConnection & { dbName?: string }, providerMode: string) => any;
buildJVMDiagnosticTreeNodes: (conn: SavedConnection) => TreeNode[];
resolveSavedQueryDisplayName: (name: string | null | undefined) => string;
decorateExternalSQLTreeNode: (node: ExternalSQLTreeNode) => TreeNode;
};
export const useSidebarTreeLoaders = ({
savedQueries,
externalSQLDirectories,
tableSortPreference,
tableAccessCount,
pinnedSidebarTables,
isV2Ui,
loadingNodesRef,
setConnectionStates,
setLoadedKeys,
replaceTreeNodeChildren,
buildRuntimeConfig,
buildJVMRuntimeConfig,
buildJVMDiagnosticTreeNodes,
resolveSavedQueryDisplayName,
decorateExternalSQLTreeNode,
}: UseSidebarTreeLoadersOptions) => {
const driverStatusCacheRef = useRef<{
fetchedAt: number;
items: Record<string, DriverStatusSnapshot>;
} | null>(null);
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
const cached = driverStatusCacheRef.current;
if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) {
return cached.items;
}
const result: Record<string, DriverStatusSnapshot> = {};
const res = await GetDriverStatusList('', '');
if (!res?.success) {
return result;
}
const data = (res.data || {}) as any;
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
drivers.forEach((item: any) => {
const type = normalizeDriverType(String(item.type || '').trim());
if (!type) return;
result[type] = {
type,
name: String(item.name || item.type || type).trim(),
connectable: !!item.connectable,
expectedRevision: String(item.expectedRevision || '').trim() || undefined,
needsUpdate: !!item.needsUpdate,
updateReason: String(item.updateReason || '').trim() || undefined,
message: String(item.message || '').trim() || undefined,
};
});
driverStatusCacheRef.current = { fetchedAt: Date.now(), items: result };
return result;
};
const warnIfConnectionDriverAgentNeedsUpdate = async (conn: SavedConnection) => {
try {
const driverType = resolveSavedConnectionDriverType(conn);
if (!driverType || driverType === 'custom') {
return;
}
const statusMap = await fetchDriverStatusMap();
const status = statusMap[driverType];
if (!status?.connectable || !status.needsUpdate) {
return;
}
const revisionKey = status.expectedRevision || status.updateReason || status.message || 'unknown';
const warningKey = `${conn.id}:${driverType}:${revisionKey}`;
if (driverUpdateWarningKeysRef.current.has(warningKey)) {
return;
}
driverUpdateWarningKeysRef.current.add(warningKey);
const driverName = status.name || driverType;
message.warning({
content: formatSidebarDriverAgentUpdateWarning(driverName, status),
key: `driver-agent-update-${conn.id}`,
duration: 10,
});
} catch (error) {
console.warn('检查驱动代理更新状态失败', error);
}
};
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
const loadKey = `dbs-${conn.id}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
if (conn.config.type === 'jvm') {
try {
const res = await JVMProbeCapabilities(buildRuntimeConfig(conn) as any);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const capabilities: JVMCapability[] = Array.isArray(res.data) ? res.data as JVMCapability[] : [];
const modeNodes: TreeNode[] = capabilities.map((capability) => ({
title: capability.displayLabel || capability.mode,
key: `${conn.id}-jvm-mode-${capability.mode}`,
icon: <HddOutlined />,
type: 'jvm-mode',
dataRef: {
...conn,
providerMode: capability.mode,
canBrowse: capability.canBrowse,
canWrite: capability.canWrite,
reason: capability.reason,
displayLabel: capability.displayLabel,
},
isLeaf: capability.canBrowse !== true,
}));
const monitoringNodes: TreeNode[] = buildJVMMonitoringActionDescriptors(conn.id, capabilities).map((item) => ({
title: item.title,
key: item.key,
icon: <DashboardOutlined />,
type: 'jvm-monitoring',
dataRef: {
...conn,
providerMode: item.providerMode,
},
isLeaf: true,
}));
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
replaceTreeNodeChildren(node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]);
} else {
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
if (diagnosticNode.length > 0) {
replaceTreeNodeChildren(node.key, diagnosticNode);
message.warning({
content: t('sidebar.message.jvm_provider_probe_failed_with_diagnostic', {
error: res.message || t('sidebar.error.unknown'),
}),
key: `conn-${conn.id}-jvm-caps`,
});
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `conn-${conn.id}-jvm-caps` });
}
}
} catch (e: any) {
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
if (diagnosticNode.length > 0) {
replaceTreeNodeChildren(node.key, diagnosticNode);
message.warning({
content: t('sidebar.message.jvm_provider_probe_exception_with_diagnostic', {
error: e?.message || String(e),
}),
key: `conn-${conn.id}-jvm-caps`,
});
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({
content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }),
key: `conn-${conn.id}-jvm-caps`,
});
}
} finally {
loadingNodesRef.current.delete(loadKey);
}
return;
}
// Handle Redis connections differently
if (conn.config.type === 'redis') {
try {
const res = await (window as any).go.app.App.RedisGetDatabases(buildRpcConnectionConfig(config));
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const redisRows: any[] = Array.isArray(res.data) ? res.data : [];
let dbs = redisRows.map((db: any) => ({
title: `db${db.index}${db.keys > 0 ? ` (${db.keys})` : ''}`,
key: `${conn.id}-db${db.index}`,
icon: <DatabaseOutlined style={{ color: '#DC382D' }} />,
type: 'redis-db' as const,
dataRef: { ...conn, redisDB: db.index },
isLeaf: true,
dbIndex: db.index,
}));
// Filter Redis databases if configured
if (conn.includeRedisDatabases && conn.includeRedisDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeRedisDatabases!.includes(db.dbIndex));
}
replaceTreeNodeChildren(node.key, dbs);
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error({
content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }),
key: `conn-${conn.id}-dbs`,
});
} finally {
loadingNodesRef.current.delete(loadKey);
}
return;
}
try {
const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const dbRows: any[] = Array.isArray(res.data) ? res.data : [];
let dbs = dbRows.map((row: any) => ({
title: row.Database || row.database,
key: `${conn.id}-${row.Database || row.database}`,
icon: <DatabaseOutlined />,
type: 'database' as const,
dataRef: { ...conn, dbName: row.Database || row.database },
isLeaf: false,
}));
// Filter databases if configured
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
}
if (dbs.length > 0) {
replaceTreeNodeChildren(node.key, dbs);
} else {
// 空列表:清理 loadedKeys 以允许重新加载,不设置 children = []
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.warning({ content: t('sidebar.message.no_visible_databases'), key: `conn-${conn.id}-dbs` });
}
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({
content: t('sidebar.message.connection_failed', { error: e?.message || String(e) }),
key: `conn-${conn.id}-dbs`,
});
} finally {
loadingNodesRef.current.delete(loadKey);
}
};
const loadJVMResources = async (node: any) => {
const conn = node.dataRef as SavedConnection & { providerMode?: string; resourcePath?: string };
const providerMode = String(conn.providerMode || '').trim().toLowerCase();
const parentPath = String(conn.resourcePath || '').trim();
const loadKey = `jvm-resources-${conn.id}-${providerMode}-${parentPath}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
try {
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMListResources !== 'function') {
throw new Error(t('sidebar.message.jvm_resources_backend_unavailable'));
}
const res = await backendApp.JVMListResources(buildJVMRuntimeConfig(conn, providerMode), parentPath);
if (res.success) {
const resourceRows: JVMResourceSummary[] = Array.isArray(res.data) ? res.data as JVMResourceSummary[] : [];
const resourceNodes: TreeNode[] = resourceRows.map((item) => ({
title: item.name || item.path || item.id,
key: `${conn.id}-jvm-resource-${providerMode}-${item.path}`,
icon: item.hasChildren ? <FolderOpenOutlined /> : <HddOutlined />,
type: 'jvm-resource',
dataRef: {
...conn,
providerMode: item.providerMode || providerMode,
resourcePath: item.path,
resourceKind: item.kind,
canRead: item.canRead,
canWrite: item.canWrite,
hasChildren: item.hasChildren,
sensitive: item.sensitive,
},
isLeaf: item.hasChildren !== true,
}));
replaceTreeNodeChildren(node.key, resourceNodes);
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `jvm-resource-${node.key}` });
}
} catch (e: any) {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({
content: t('sidebar.message.load_jvm_resources_failed', { error: e?.message || String(e) }),
key: `jvm-resource-${node.key}`,
});
} finally {
loadingNodesRef.current.delete(loadKey);
}
};
const loadTables = async (node: any) => {
const conn = node.dataRef; // has dbName
const dbName = conn.dbName;
const key = node.key;
const loadKey = `tables-${conn.id}-${dbName}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
const queriesNode: TreeNode = {
title: t('sidebar.tree.saved_queries'),
key: `${key}-queries`,
icon: <FolderOpenOutlined />,
type: 'queries-folder',
isLeaf: dbQueries.length === 0,
children: dbQueries.map(q => ({
title: resolveSavedQueryDisplayName(q.name),
key: q.id,
icon: <FileTextOutlined />,
type: 'saved-query',
dataRef: q,
isLeaf: true
}))
};
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
try {
const res = await DBGetTables(buildRpcConnectionConfig(config) as any, conn.dbName);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
const tableRows: any[] = Array.isArray(res.data) ? res.data : [];
const tableStatusSql = buildSidebarTableStatusSQL(conn as SavedConnection, conn.dbName);
const tableStatsResult = tableStatusSql
? await DBQuery(buildRpcConnectionConfig(config) as any, conn.dbName, tableStatusSql).catch(() => ({ success: false, data: [] as any[] }))
: { success: false, data: [] as any[] };
const tableRowCountMap = new Map<string, number>();
if (tableStatsResult?.success && Array.isArray(tableStatsResult.data)) {
tableStatsResult.data.forEach((row: Record<string, any>) => {
const rawTableName = String(
getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'Name', 'name'])
|| getMySQLShowTablesName(row)
|| ''
).trim();
if (!rawTableName) return;
const rowCount = parseMetadataRowCount(row);
if (rowCount === undefined) return;
tableRowCountMap.set(rawTableName.toLowerCase(), rowCount);
});
}
const tableEntries = tableRows.map((row: any) => {
const tableName = Object.values(row)[0] as string;
const parsed = splitQualifiedName(tableName);
return {
tableName,
schemaName: parsed.schemaName,
displayName: getSidebarTableDisplayName(conn, tableName),
rowCount: tableRowCountMap.get(String(tableName || '').trim().toLowerCase()),
};
});
const [schemasResult, viewsResult, materializedViewsResult, triggersResult, routinesResult, eventsResult] = await Promise.all([
loadSchemas(conn, conn.dbName),
loadViews(conn, conn.dbName),
loadStarRocksMaterializedViews(conn, conn.dbName),
loadDatabaseTriggers(conn, conn.dbName),
loadFunctions(conn, conn.dbName),
loadDatabaseEvents(conn, conn.dbName),
]);
const externalSQLDirectoryResults = await Promise.all(
externalSQLDirectories.map(async (directory: ExternalSQLDirectory) => {
const directoryRes = await ListSQLDirectory(directory.path);
if (!directoryRes.success) {
message.warning({
key: `external-sql-${directory.id}`,
content: t('sidebar.message.external_sql_directory_read_failed', {
name: directory.name,
error: directoryRes.message,
}),
});
return { id: directory.id, entries: [] as ExternalSQLTreeEntry[] };
}
return {
id: directory.id,
entries: Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : [],
};
}),
);
const externalSQLTrees = externalSQLDirectoryResults.reduce<Record<string, ExternalSQLTreeEntry[]>>((accumulator, item) => {
accumulator[item.id] = item.entries;
return accumulator;
}, {});
const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({
dbNodeKey: String(key),
connectionId: String(conn.id),
dbName: String(conn.dbName),
directories: externalSQLDirectories,
directoryTrees: externalSQLTrees,
labels: {
root: t('sidebar.external_sql.root'),
directoryFallback: t('sidebar.external_sql.directory_fallback'),
},
}));
const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewsResult.views) ? viewsResult.views : [];
const materializedViewRows: SidebarViewMetadataEntry[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : [];
const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : [];
const routineRows: any[] = Array.isArray(routinesResult.routines) ? routinesResult.routines : [];
const eventRows: any[] = Array.isArray(eventsResult.events) ? eventsResult.events : [];
const schemaRows: string[] = Array.isArray(schemasResult.schemas) ? schemasResult.schemas : [];
const viewEntries = viewRows.map((entry: SidebarViewMetadataEntry) => {
const parsed = splitQualifiedName(entry.viewName);
return {
viewName: entry.viewName,
schemaName: entry.schemaName || parsed.schemaName,
displayName: getSidebarTableDisplayName(conn, entry.viewName),
};
});
const materializedViewEntries = materializedViewRows.map((entry: SidebarViewMetadataEntry) => {
const parsed = splitQualifiedName(entry.viewName);
return {
viewName: entry.viewName,
schemaName: entry.schemaName || parsed.schemaName,
displayName: getSidebarTableDisplayName(conn, entry.viewName),
};
});
const triggerEntries = (() => {
const deduped: Array<{ displayName: string; triggerName: string; tableName: string; schemaName: string }> = [];
const triggerSeen = new Set<string>();
const metadataDialect = getMetadataDialect(conn as SavedConnection);
triggerRows.forEach((trigger: any) => {
const triggerParsed = splitQualifiedName(trigger.triggerName);
const tableParsed = splitQualifiedName(trigger.tableName);
const schemaName = tableParsed.schemaName || triggerParsed.schemaName || String(conn.dbName || '').trim();
const triggerObjectName = (triggerParsed.objectName || trigger.triggerName).trim();
const tableObjectName = (tableParsed.objectName || trigger.tableName).trim();
const displayName = tableObjectName ? `${triggerObjectName} (${tableObjectName})` : triggerObjectName;
const dedupeKey = metadataDialect === 'mysql'
? `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}`
: `${schemaName.toLowerCase()}@@${triggerObjectName.toLowerCase()}@@${tableObjectName.toLowerCase()}`;
if (triggerSeen.has(dedupeKey)) return;
triggerSeen.add(dedupeKey);
deduped.push({
...trigger,
schemaName,
triggerName: triggerObjectName,
tableName: buildQualifiedName(schemaName, tableObjectName) || tableObjectName,
displayName,
});
});
return deduped;
})();
const routineEntries = routineRows.map((routine: any) => {
const parsed = splitQualifiedName(routine.routineName);
const typeLabel = routine.routineType === 'PROCEDURE' ? 'P' : 'F';
return {
...routine,
schemaName: parsed.schemaName,
displayName: `${parsed.objectName || routine.routineName} [${typeLabel}]`,
};
});
const eventEntries = eventRows.map((event: any) => ({
...event,
schemaName: String(event.schemaName || conn.dbName || '').trim(),
displayName: String(event.displayName || event.eventName || '').trim(),
})).filter((event: any) => event.eventName && event.displayName);
if (isSphinxConnection(conn as SavedConnection)) {
const unsupportedObjects: string[] = [];
if (!viewsResult.supported) unsupportedObjects.push(t('sidebar.object_group.views'));
if (!routinesResult.supported) unsupportedObjects.push(t('sidebar.object_group.routines'));
if (!triggersResult.supported) unsupportedObjects.push(t('sidebar.object_group.triggers'));
if (unsupportedObjects.length > 0) {
message.info({
key: `sphinx-capability-${conn.id}-${conn.dbName}`,
content: t('sidebar.message.sphinx_unsupported_objects', {
objects: unsupportedObjects.join(t('sidebar.punctuation.list_separator')),
}),
});
}
}
const currentStoreState = useStore.getState();
const currentTableSortPreference = currentStoreState.tableSortPreference || tableSortPreference;
const currentTableAccessCount = currentStoreState.tableAccessCount || tableAccessCount;
const currentPinnedSidebarTables = currentStoreState.pinnedSidebarTables || pinnedSidebarTables;
// 获取当前数据库的排序偏好
const sortPreferenceKey = `${conn.id}-${conn.dbName}`;
const sortBy = currentTableSortPreference[sortPreferenceKey] || 'name';
const sortedTableEntries = sortSidebarTableEntries(tableEntries, {
connectionId: conn.id,
dbName: conn.dbName,
sortBy,
tableAccessCount: currentTableAccessCount,
pinnedSidebarTables: isV2Ui ? currentPinnedSidebarTables : [],
});
// Sort views by name (case-insensitive)
viewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
materializedViewEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
// Sort triggers by display name (case-insensitive)
triggerEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
// Sort routines by display name (case-insensitive)
routineEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
eventEntries.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase()));
const buildTableNode = (entry: { tableName: string; schemaName: string; displayName: string; rowCount?: number }): TreeNode => {
const isPinned = isV2Ui && isSidebarTablePinned(
currentPinnedSidebarTables,
conn.id,
conn.dbName,
entry.tableName,
entry.schemaName,
);
return {
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-${entry.tableName}`,
icon: <TableOutlined />,
type: 'table',
dataRef: {
...conn,
tableName: entry.tableName,
schemaName: entry.schemaName,
rowCount: entry.rowCount,
...(isPinned ? { pinnedSidebarTable: true } : {}),
},
isLeaf: false,
};
};
const buildViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => {
const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.viewName);
return {
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-view-${keyName}`,
icon: <EyeOutlined />,
type: 'view',
dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName },
isLeaf: true,
};
};
const buildMaterializedViewNode = (entry: { viewName: string; schemaName: string; displayName: string }): TreeNode => {
const keyName = buildSidebarObjectKeyName(conn.dbName, entry.schemaName, entry.viewName);
return {
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-materialized-view-${keyName}`,
icon: <ThunderboltOutlined />,
type: 'materialized-view',
dataRef: { ...conn, viewName: entry.viewName, tableName: entry.viewName, schemaName: entry.schemaName, objectKind: 'materialized-view' },
isLeaf: true,
};
};
const buildTriggerNode = (entry: { triggerName: string; tableName: string; schemaName: string; displayName: string }): TreeNode => ({
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-trigger-${entry.triggerName}-${entry.tableName}`,
icon: <FunctionOutlined />,
type: 'db-trigger',
dataRef: { ...conn, triggerName: entry.triggerName, triggerTableName: entry.tableName, tableName: entry.tableName, schemaName: entry.schemaName },
isLeaf: true,
});
const buildRoutineNode = (entry: { routineName: string; routineType: string; schemaName: string; displayName: string }): TreeNode => ({
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-routine-${entry.routineName}`,
icon: <CodeOutlined />,
type: 'routine',
dataRef: { ...conn, routineName: entry.routineName, routineType: entry.routineType, schemaName: entry.schemaName },
isLeaf: true,
});
const buildEventNode = (entry: { eventName: string; schemaName: string; displayName: string; eventType?: string; status?: string }): TreeNode => ({
title: entry.displayName,
key: `${conn.id}-${conn.dbName}-event-${entry.schemaName}-${entry.eventName}`,
icon: <ClockCircleOutlined />,
type: 'db-event',
dataRef: { ...conn, eventName: entry.eventName, schemaName: entry.schemaName, eventType: entry.eventType, eventStatus: entry.status },
isLeaf: true,
});
const buildObjectGroup = (
parentKey: string,
groupKey: string,
groupTitle: string,
groupIcon: React.ReactNode,
children: TreeNode[],
extraData: Record<string, any> = {}
): TreeNode => {
const groupNodeKey = `${parentKey}-${groupKey}`;
const groupedChildren = groupKey === 'tables'
? buildSidebarTableChildrenForUi(groupNodeKey, children, isV2Ui)
: children;
return {
title: groupTitle,
key: groupNodeKey,
icon: groupIcon,
type: 'object-group',
isLeaf: children.length === 0,
children: groupedChildren.length > 0 ? groupedChildren : undefined,
dataRef: { ...conn, dbName: conn.dbName, groupKey, ...extraData }
};
};
const shouldGroupBySchema = shouldHideSchemaPrefix(conn as SavedConnection);
if (shouldGroupBySchema) {
type SchemaBucket = {
schemaName: string;
tables: TreeNode[];
views: TreeNode[];
materializedViews: TreeNode[];
routines: TreeNode[];
triggers: TreeNode[];
events: TreeNode[];
};
const schemaMap = new Map<string, SchemaBucket>();
const getSchemaBucket = (rawSchemaName: string): SchemaBucket => {
const schemaName = String(rawSchemaName || '').trim();
const schemaKey = schemaName || '__default__';
let bucket = schemaMap.get(schemaKey);
if (!bucket) {
bucket = {
schemaName,
tables: [],
views: [],
materializedViews: [],
routines: [],
triggers: [],
events: [],
};
schemaMap.set(schemaKey, bucket);
}
return bucket;
};
schemaRows.forEach((schemaName) => getSchemaBucket(schemaName));
sortedTableEntries.forEach((entry) => getSchemaBucket(entry.schemaName).tables.push(buildTableNode(entry)));
viewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).views.push(buildViewNode(entry)));
materializedViewEntries.forEach((entry) => getSchemaBucket(entry.schemaName).materializedViews.push(buildMaterializedViewNode(entry)));
routineEntries.forEach((entry) => getSchemaBucket(entry.schemaName).routines.push(buildRoutineNode(entry)));
triggerEntries.forEach((entry) => getSchemaBucket(entry.schemaName).triggers.push(buildTriggerNode(entry)));
eventEntries.forEach((entry) => getSchemaBucket(entry.schemaName).events.push(buildEventNode(entry)));
const dialect = getMetadataDialect(conn as SavedConnection);
const isOracleLike = (dialect === 'oracle' || dialect === 'dm');
const includeMaterializedViews = dialect === 'starrocks';
const includeEvents = supportsDatabaseEvents(conn as SavedConnection);
const schemaNodes: TreeNode[] = Array.from(schemaMap.values())
.filter((bucket) => !(isOracleLike && !bucket.schemaName))
.sort((a, b) => {
if (!a.schemaName && !b.schemaName) return 0;
if (!a.schemaName) return -1;
if (!b.schemaName) return 1;
return a.schemaName.toLowerCase().localeCompare(b.schemaName.toLowerCase());
})
.map((bucket) => {
const schemaNodeKey = `${key}-schema-${bucket.schemaName || 'default'}`;
const schemaTitle = bucket.schemaName || t('sidebar.tree.default_schema');
const groupedNodes: TreeNode[] = [
buildObjectGroup(schemaNodeKey, 'tables', t('sidebar.object_group.tables'), <TableOutlined />, bucket.tables, { schemaName: bucket.schemaName }),
buildObjectGroup(schemaNodeKey, 'views', t('sidebar.object_group.views'), <EyeOutlined />, bucket.views, { schemaName: bucket.schemaName }),
...(includeMaterializedViews ? [buildObjectGroup(schemaNodeKey, 'materializedViews', t('sidebar.object_group.materialized_views'), <ThunderboltOutlined />, bucket.materializedViews, { schemaName: bucket.schemaName })] : []),
buildObjectGroup(schemaNodeKey, 'routines', t('sidebar.object_group.routines'), <CodeOutlined />, bucket.routines, { schemaName: bucket.schemaName }),
buildObjectGroup(schemaNodeKey, 'triggers', t('sidebar.object_group.triggers'), <FunctionOutlined />, bucket.triggers, { schemaName: bucket.schemaName }),
...(includeEvents ? [buildObjectGroup(schemaNodeKey, 'events', t('sidebar.object_group.events'), <ClockCircleOutlined />, bucket.events, { schemaName: bucket.schemaName })] : []),
];
return {
title: schemaTitle,
key: schemaNodeKey,
icon: <FolderOpenOutlined />,
type: 'object-group' as const,
isLeaf: groupedNodes.length === 0,
children: groupedNodes,
dataRef: { ...conn, dbName: conn.dbName, groupKey: 'schema', schemaName: bucket.schemaName }
};
});
replaceTreeNodeChildren(key, [queriesNode, ...schemaNodes]);
} else {
const includeMaterializedViews = getMetadataDialect(conn as SavedConnection) === 'starrocks';
const includeEvents = supportsDatabaseEvents(conn as SavedConnection);
const groupedNodes: TreeNode[] = [
buildObjectGroup(key as string, 'tables', t('sidebar.object_group.tables'), <TableOutlined />, sortedTableEntries.map(buildTableNode)),
buildObjectGroup(key as string, 'views', t('sidebar.object_group.views'), <EyeOutlined />, viewEntries.map(buildViewNode)),
...(includeMaterializedViews ? [buildObjectGroup(key as string, 'materializedViews', t('sidebar.object_group.materialized_views'), <ThunderboltOutlined />, materializedViewEntries.map(buildMaterializedViewNode))] : []),
buildObjectGroup(key as string, 'routines', t('sidebar.object_group.routines'), <CodeOutlined />, routineEntries.map(buildRoutineNode)),
buildObjectGroup(key as string, 'triggers', t('sidebar.object_group.triggers'), <FunctionOutlined />, triggerEntries.map(buildTriggerNode)),
...(includeEvents ? [buildObjectGroup(key as string, 'events', t('sidebar.object_group.events'), <ClockCircleOutlined />, eventEntries.map(buildEventNode))] : []),
];
replaceTreeNodeChildren(key, [queriesNode, ...groupedNodes]);
}
} else {
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
message.error({ content: res.message, key: `db-${key}-tables` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
message.error({
content: t('sidebar.message.load_table_list_failed', { error: e?.message || String(e) }),
key: `db-${key}-tables`,
});
} finally {
loadingNodesRef.current.delete(loadKey);
}
};
return {
loadDatabases,
loadJVMResources,
loadTables,
};
};