diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index 045852d..f8ed6d4 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -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'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 81548a7..77ff9f5 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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, -): 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>({}); - const driverStatusCacheRef = useRef<{ - fetchedAt: number; - items: Record; - } | null>(null); - const driverUpdateWarningKeysRef = useRef>(new Set()); const [contextMenu, setContextMenu] = useState(null); const contextMenuPortalRef = useRef(null); const [v2TableContextMenuStats, setV2TableContextMenuStats] = useState>({}); @@ -1277,710 +1212,6 @@ const Sidebar: React.FC<{ return null; }; - const fetchDriverStatusMap = async (): Promise> => { - const cached = driverStatusCacheRef.current; - if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) { - return cached.items; - } - const result: Record = {}; - 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: , - 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: , - 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: , - 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: , - 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 ? : , - 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: , - type: 'queries-folder', - isLeaf: dbQueries.length === 0, - children: dbQueries.map(q => ({ - title: resolveSavedQueryDisplayName(q.name), - key: q.id, - icon: , - 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(); - if (tableStatsResult?.success && Array.isArray(tableStatsResult.data)) { - tableStatsResult.data.forEach((row: Record) => { - 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>((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(); - 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: , - 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: , - 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: , - 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: , - 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: , - 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: , - 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 = {} - ): 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(); - 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'), , bucket.tables, { schemaName: bucket.schemaName }), - buildObjectGroup(schemaNodeKey, 'views', t('sidebar.object_group.views'), , bucket.views, { schemaName: bucket.schemaName }), - ...(includeMaterializedViews ? [buildObjectGroup(schemaNodeKey, 'materializedViews', t('sidebar.object_group.materialized_views'), , bucket.materializedViews, { schemaName: bucket.schemaName })] : []), - buildObjectGroup(schemaNodeKey, 'routines', t('sidebar.object_group.routines'), , bucket.routines, { schemaName: bucket.schemaName }), - buildObjectGroup(schemaNodeKey, 'triggers', t('sidebar.object_group.triggers'), , bucket.triggers, { schemaName: bucket.schemaName }), - ...(includeEvents ? [buildObjectGroup(schemaNodeKey, 'events', t('sidebar.object_group.events'), , bucket.events, { schemaName: bucket.schemaName })] : []), - ]; - - return { - title: schemaTitle, - key: schemaNodeKey, - icon: , - 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'), , sortedTableEntries.map(buildTableNode)), - buildObjectGroup(key as string, 'views', t('sidebar.object_group.views'), , viewEntries.map(buildViewNode)), - ...(includeMaterializedViews ? [buildObjectGroup(key as string, 'materializedViews', t('sidebar.object_group.materialized_views'), , materializedViewEntries.map(buildMaterializedViewNode))] : []), - buildObjectGroup(key as string, 'routines', t('sidebar.object_group.routines'), , routineEntries.map(buildRoutineNode)), - buildObjectGroup(key as string, 'triggers', t('sidebar.object_group.triggers'), , triggerEntries.map(buildTriggerNode)), - ...(includeEvents ? [buildObjectGroup(key as string, 'events', t('sidebar.object_group.events'), , 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>(async () => {}); const waitForSidebarLoadKey = async (loadKey: string): Promise => { @@ -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 { diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx new file mode 100644 index 0000000..dfcf128 --- /dev/null +++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx @@ -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, +): 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; + tableAccessCount: Record; + pinnedSidebarTables: any[]; + isV2Ui: boolean; + loadingNodesRef: React.MutableRefObject>; + setConnectionStates: React.Dispatch>>; + setLoadedKeys: React.Dispatch>; + 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; + } | null>(null); + const driverUpdateWarningKeysRef = useRef>(new Set()); + + const fetchDriverStatusMap = async (): Promise> => { + const cached = driverStatusCacheRef.current; + if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) { + return cached.items; + } + const result: Record = {}; + 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: , + 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: , + 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: , + 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: , + 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 ? : , + 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: , + type: 'queries-folder', + isLeaf: dbQueries.length === 0, + children: dbQueries.map(q => ({ + title: resolveSavedQueryDisplayName(q.name), + key: q.id, + icon: , + 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(); + if (tableStatsResult?.success && Array.isArray(tableStatsResult.data)) { + tableStatsResult.data.forEach((row: Record) => { + 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>((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(); + 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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 = {} + ): 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(); + 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'), , bucket.tables, { schemaName: bucket.schemaName }), + buildObjectGroup(schemaNodeKey, 'views', t('sidebar.object_group.views'), , bucket.views, { schemaName: bucket.schemaName }), + ...(includeMaterializedViews ? [buildObjectGroup(schemaNodeKey, 'materializedViews', t('sidebar.object_group.materialized_views'), , bucket.materializedViews, { schemaName: bucket.schemaName })] : []), + buildObjectGroup(schemaNodeKey, 'routines', t('sidebar.object_group.routines'), , bucket.routines, { schemaName: bucket.schemaName }), + buildObjectGroup(schemaNodeKey, 'triggers', t('sidebar.object_group.triggers'), , bucket.triggers, { schemaName: bucket.schemaName }), + ...(includeEvents ? [buildObjectGroup(schemaNodeKey, 'events', t('sidebar.object_group.events'), , bucket.events, { schemaName: bucket.schemaName })] : []), + ]; + + return { + title: schemaTitle, + key: schemaNodeKey, + icon: , + 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'), , sortedTableEntries.map(buildTableNode)), + buildObjectGroup(key as string, 'views', t('sidebar.object_group.views'), , viewEntries.map(buildViewNode)), + ...(includeMaterializedViews ? [buildObjectGroup(key as string, 'materializedViews', t('sidebar.object_group.materialized_views'), , materializedViewEntries.map(buildMaterializedViewNode))] : []), + buildObjectGroup(key as string, 'routines', t('sidebar.object_group.routines'), , routineEntries.map(buildRoutineNode)), + buildObjectGroup(key as string, 'triggers', t('sidebar.object_group.triggers'), , triggerEntries.map(buildTriggerNode)), + ...(includeEvents ? [buildObjectGroup(key as string, 'events', t('sidebar.object_group.events'), , 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, + }; +};