import Modal from './common/ResizableDraggableModal'; import SidebarConnectionRail from './sidebar/SidebarConnectionRail'; import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel'; 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 { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, hasSidebarLazyChildren, shouldClearSidebarActiveContextOnEmptySelect, shouldLoadSidebarNodeOnExpand, getV2RailConnectionGroupBadgeText, isV2SidebarObjectNode, resolveV2ObjectGroupTitle, resolveSidebarTableNameForCopy, parseV2CommandSearchQuery, type V2ExplorerFilter, type V2CommandSearchMode, type V2CommandSearchQuery, } from './sidebar/sidebarHelpers'; // 重新导出,保持外部测试文件的 `from './Sidebar'` 兼容 export { V2_RAIL_UNGROUPED_CONNECTION_GROUP_ID, formatSidebarRowCount, hasSidebarLazyChildren, shouldClearSidebarActiveContextOnEmptySelect, shouldLoadSidebarNodeOnExpand, getV2RailConnectionGroupBadgeText, isV2SidebarObjectNode, resolveV2ObjectGroupTitle, resolveSidebarTableNameForCopy, parseV2CommandSearchQuery, } from './sidebar/sidebarHelpers'; import React, { useEffect, useState, useMemo, useRef, useCallback, useDeferredValue } from 'react'; import { createPortal } from 'react-dom'; import { Tree, message, Dropdown, MenuProps, Input, Button, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress, Switch } from 'antd'; import { DatabaseOutlined, TableOutlined, EyeOutlined, ConsoleSqlOutlined, HddOutlined, FolderOutlined, FolderOpenOutlined, FileTextOutlined, CopyOutlined, ExportOutlined, FolderAddOutlined, SaveOutlined, EditOutlined, SearchOutlined, KeyOutlined, ThunderboltOutlined, UnorderedListOutlined, FunctionOutlined, LinkOutlined, FileAddOutlined, PlusOutlined, ReloadOutlined, SendOutlined, DeleteOutlined, DisconnectOutlined, CloudOutlined, CheckSquareOutlined, CodeOutlined, TagOutlined, CheckOutlined, FilterOutlined, DashboardOutlined, WarningOutlined, ClockCircleOutlined, RobotOutlined, AimOutlined, MoreOutlined, ToolOutlined, SettingOutlined, BarsOutlined, StarFilled, StarOutlined } from '@ant-design/icons'; import { buildSidebarRootConnectionToken, buildSidebarRootTagToken, resolveSidebarRootOrderTokens, useStore, } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; import { getDbIcon } from './DatabaseIcons'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTableWithOptions, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } 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'; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import FindInDatabaseModal from './FindInDatabaseModal'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { resolveSidebarRuntimeDatabase, type SidebarViewMetadataEntry, } from '../utils/sidebarMetadata'; import { buildStarRocksMaterializedViewPreviewSql } from './tableDesignerSchemaSql'; import { resolveConnectionHostSummary, resolveConnectionHostTokens } from '../utils/tabDisplay'; import { findSidebarNodePathByKey, findSidebarNodePathForLocate, normalizeSidebarLocateObjectRequest, normalizeSidebarLocateObjectRequestFromTab, resolveSidebarLocateTarget, type SidebarLocateTreeNodeLike, } from '../utils/sidebarLocate'; import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual'; import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation'; import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions'; import { buildTableSelectQuery } from '../utils/objectQueryTemplates'; import { buildBatchDatabaseExportWorkbenchTab, buildBatchTableExportWorkbenchTab, buildTableExportTab, } from '../utils/tableExportTab'; import { useExportProgressDialog } from './ExportProgressModal'; import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; import { getCurrentLanguage, t } from '../i18n'; import { SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag'; import { buildSqlServerObjectDefinitionQueries } from '../utils/sqlServerObjectDefinition'; import JVMModeBadge from './jvm/JVMModeBadge'; import MessagePublishModal from './MessagePublishModal'; import { SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, isExternalSQLDirectoryModalMode, normalizeMySQLViewDDLForEditing, resolveSidebarContextMenuPosition, resolveSidebarObjectDragText, type ExternalSQLFileModalMode, type SearchScope, } from './sidebarCoreUtils'; export { resolveSidebarContextMenuPosition } from './sidebarCoreUtils'; export type { ExternalSQLFileModalMode, SearchScope } from './sidebarCoreUtils'; import { V2DatabaseContextMenuView, V2ConnectionGroupContextMenuView, V2ConnectionContextMenuView, V2SchemaContextMenuView, V2TableContextMenuView, V2TableGroupContextMenuView, type V2DatabaseContextMenuActionKey, type V2ConnectionGroupContextMenuActionKey, type V2ConnectionContextMenuActionKey, type V2SchemaContextMenuActionKey, type V2TableContextMenuActionKey, type V2TableContextMenuStats, type V2TableGroupContextMenuActionKey, } from './V2TableContextMenu'; import { V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE, buildSidebarTableChildrenForUi, buildV2RailConnectionGroups, buildV2SidebarTableSectionedChildren, estimateV2TreeHorizontalScrollWidth, filterV2CommandSearchTreeItems, filterV2ExplorerTreeByKind, isSidebarTablePinned, normalizeSidebarTreeRelativeDropPosition, resolveSidebarConnectionIdFromKey, resolveSidebarDropInsertBefore, resolveSidebarDropNodeFromDomEvent, resolveSidebarDropTargetMetricsFromDomEvent, resolveSidebarNodeConnectionId, resolveSidebarTagDropInsertBefore, resolveV2ActiveConnectionId, resolveV2CommandSearchPersistentFilter, shouldSkipSidebarLoadOnExpandWhileDragging, shouldSkipSidebarSelectWhileDragging, shouldCloseV2CommandSearchOnGlobalKey, shouldRunV2CommandSearchEnter, sortSidebarTableEntries, type SidebarTreeNode as TreeNode, type V2CommandSearchItem, type V2RailConnectionGroup, } from './sidebarV2Utils'; export { buildSidebarTableChildrenForUi, buildV2RailConnectionGroups, buildV2SidebarTableSectionedChildren, estimateV2TreeHorizontalScrollWidth, filterV2CommandSearchTreeItems, filterV2ExplorerTreeByKind, isSidebarTablePinned, normalizeSidebarTreeRelativeDropPosition, resolveSidebarConnectionIdFromKey, resolveSidebarDropInsertBefore, resolveSidebarDropNodeFromDomEvent, resolveSidebarDropTargetMetricsFromDomEvent, resolveSidebarNodeConnectionId, resolveSidebarTagDropInsertBefore, resolveV2ActiveConnectionId, resolveV2CommandSearchPersistentFilter, shouldSkipSidebarLoadOnExpandWhileDragging, shouldSkipSidebarSelectWhileDragging, shouldCloseV2CommandSearchOnGlobalKey, shouldRunV2CommandSearchEnter, sortSidebarTableEntries, }; export type { V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Utils'; const { Search } = Input; type SidebarContextMenuState = { x: number; y: number; sourceX?: number; sourceY?: number; items: MenuProps['items']; kind?: 'v2-table' | 'v2-database' | 'v2-schema' | 'v2-table-group' | 'v2-connection' | 'v2-connection-group'; node?: any; rootClassName?: string; overlayStyle?: React.CSSProperties; maxHeight?: number; }; const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50; const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160; // resolveV2ObjectGroupTitle 已迁移到 ./sidebar/sidebarHelpers export type SQLFileExecutionStatus = 'running' | 'done' | 'cancelled' | 'error'; export type SQLFileExecutionProgressState = { fileSizeMB: string; status: SQLFileExecutionStatus; executed: number; failed: number; percent: number; currentSQL: string; resultMessage: string; }; const resolveSQLFileExecutionStatusLabel = (status: SQLFileExecutionStatus): string => { switch (status) { case 'done': return `✅ ${t('sidebar.sql_file_exec.status.done')}`; case 'cancelled': return `⚠️ ${t('sidebar.sql_file_exec.status.cancelled')}`; case 'error': return `❌ ${t('sidebar.sql_file_exec.status.error')}`; case 'running': default: return t('sidebar.sql_file_exec.status.running'); } }; export const buildSQLFileExecutionFooter = ({ status, onCancelExecution, onClose, }: { status: SQLFileExecutionStatus; onCancelExecution: () => void; onClose: () => void; }): React.ReactNode[] => { if (status === 'running') { return [ , ]; } return [ , ]; }; export const SQLFileExecutionProgressContent: React.FC = ({ fileSizeMB, status, executed, failed, percent, currentSQL, resultMessage, }) => ( <>
{t('sidebar.sql_file_exec.file_size')}{fileSizeMB} MB
{t('sidebar.sql_file_exec.status_label')}{resolveSQLFileExecutionStatusLabel(status)}
{t('sidebar.sql_file_exec.executed_label')} {executed} {t('sidebar.sql_file_exec.rows_separator')} 0 ? '#ff4d4f' : undefined }}>{failed} {t('sidebar.sql_file_exec.rows_suffix')}
{currentSQL && status === 'running' && (
{currentSQL}
)} {resultMessage && status !== 'running' && (
{resultMessage}
)} ); // shouldLoadSidebarNodeOnExpand 已迁移到 ./sidebar/sidebarHelpers // resolveSidebarTableNameForCopy 已迁移到 ./sidebar/sidebarHelpers const buildConnectionRootQueryTabTitle = () => t('query.new'); const buildConnectionRootRedisCommandTabTitle = (redisDbLabel = 'db0') => t('sidebar.tab.redis_command', { database: redisDbLabel }); const buildConnectionRootRedisMonitorTabTitle = (redisDbLabel = 'db0') => t('sidebar.tab.redis_monitor', { database: redisDbLabel }); type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; type BatchObjectType = 'table' | 'view'; type BatchObjectFilterType = 'all' | BatchObjectType; type BatchSelectionScope = 'filtered' | 'all'; const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; labelKey: string }> = [ { key: 'all', labelKey: 'sidebar.command_search.object_kind.all' }, { key: 'tables', labelKey: 'sidebar.command_search.object_kind.tables' }, { key: 'views', labelKey: 'sidebar.command_search.object_kind.views' }, { key: 'routines', labelKey: 'sidebar.command_search.object_kind.routines' }, { key: 'events', labelKey: 'sidebar.command_search.object_kind.events' }, ]; type SidebarMessagePublishTarget = { connection: SavedConnection; executionDbName: string; destination: string; }; interface BatchObjectItem { title: string; key: string; objectName: string; objectType: BatchObjectType; dataRef: any; } 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; 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)) ); const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; labelKey: string }> = [ { value: 'smart', labelKey: 'sidebar.command_search.scope.smart' }, { value: 'object', labelKey: 'sidebar.command_search.scope.object' }, { value: 'database', labelKey: 'sidebar.command_search.scope.database' }, { value: 'host', labelKey: 'sidebar.command_search.scope.host' }, { value: 'tag', labelKey: 'sidebar.command_search.scope.tag' }, ]; const SEARCH_SCOPE_LABEL_KEY_MAP: Record = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => { acc[option.value] = option.labelKey; return acc; }, {} as Record); const SEARCH_SCOPE_ICON_MAP: Record = { smart: , object: , database: , host: , tag: , }; const isSavedQueryUnmatchedForConnectionIds = (query: SavedQuery, connectionIds: Set): boolean => ( query.bindingStatus === 'orphan' || !connectionIds.has(query.connectionId) ); export const buildAllSavedQueriesTreeNode = ( savedQueries: SavedQuery[], connections: SavedConnection[], ): TreeNode | null => { if (savedQueries.length === 0) { return null; } const connectionIds = new Set(connections.map((conn) => conn.id)); const unmatchedSavedQueries = savedQueries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIds)); const unmatchedIds = new Set(unmatchedSavedQueries.map((query) => query.id)); const createQueryNode = (query: SavedQuery): TreeNode => ({ title: query.name || t('sidebar.tree.untitled_query'), key: `all-saved-query-${query.id}`, icon: , type: 'saved-query', dataRef: query, isLeaf: true, }); const buildDatabaseGroups = (queries: SavedQuery[], keyPrefix: string): TreeNode[] => { const groupedByDatabase = new Map(); queries.forEach((query) => { const dbName = String(query.dbName || '').trim() || t('sidebar.tree.default_database'); groupedByDatabase.set(dbName, [...(groupedByDatabase.get(dbName) || []), query]); }); return Array.from(groupedByDatabase.entries()).map(([dbName, items]) => ({ title: dbName, key: `${keyPrefix}-db-${encodeURIComponent(dbName)}`, icon: , type: 'saved-query-group', selectable: false, isLeaf: false, children: items.map(createQueryNode), })); }; const groupedByConnection = new Map(); savedQueries.forEach((query) => { if (unmatchedIds.has(query.id)) { return; } groupedByConnection.set(query.connectionId, [ ...(groupedByConnection.get(query.connectionId) || []), query, ]); }); const children: TreeNode[] = []; connections.forEach((conn) => { const connectionQueries = groupedByConnection.get(conn.id); if (!connectionQueries || connectionQueries.length === 0) { return; } const iconType = resolveConnectionIconType(conn); const iconColor = resolveConnectionAccentColor(conn); children.push({ title: conn.name || conn.id, key: `all-saved-queries-connection-${conn.id}`, icon: getDbIcon(iconType, iconColor, 22), type: 'saved-query-group', selectable: false, isLeaf: false, children: buildDatabaseGroups(connectionQueries, `all-saved-queries-connection-${conn.id}`), }); }); if (unmatchedSavedQueries.length > 0) { const groupedByOriginalConnection = new Map(); unmatchedSavedQueries.forEach((query) => { const originalConnectionId = String(query.originalConnectionId || query.connectionId || t('sidebar.tree.unknown_connection')).trim() || t('sidebar.tree.unknown_connection'); groupedByOriginalConnection.set(originalConnectionId, [ ...(groupedByOriginalConnection.get(originalConnectionId) || []), query, ]); }); children.push({ title: t('sidebar.tree.unmatched_saved_queries'), key: 'all-saved-queries-unmatched', icon: , type: 'saved-query-group', selectable: false, isLeaf: false, children: Array.from(groupedByOriginalConnection.entries()).map(([connectionLabel, items]) => ({ title: connectionLabel, key: `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`, icon: , type: 'saved-query-group', selectable: false, isLeaf: false, children: buildDatabaseGroups(items, `all-saved-queries-unmatched-${encodeURIComponent(connectionLabel)}`), })), }); } return { title: t('sidebar.tree.all_saved_queries'), key: 'all-saved-queries', icon: , type: 'all-saved-queries', isLeaf: false, selectable: false, children, }; }; const Sidebar: React.FC<{ onCreateConnection?: () => void; onEditConnection?: (conn: SavedConnection) => void; onOpenTools?: () => void; onOpenSettings?: () => void; onToggleAI?: () => void; onToggleLogPanel?: () => void; sqlLogCount?: number; uiVersion?: 'legacy' | 'v2'; onFocusCommandSearch?: () => void; }> = React.memo(({ onCreateConnection, onEditConnection, onOpenTools, onOpenSettings, onToggleAI, onToggleLogPanel, sqlLogCount = 0, uiVersion, onFocusCommandSearch, }) => { const connections = useStore(state => state.connections); const savedQueries = useStore(state => state.savedQueries); const externalSQLDirectories = useStore(state => state.externalSQLDirectories); const saveQuery = useStore(state => state.saveQuery); const deleteQuery = useStore(state => state.deleteQuery); const saveExternalSQLDirectory = useStore(state => state.saveExternalSQLDirectory); const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory); const addConnection = useStore(state => state.addConnection); const addTab = useStore(state => state.addTab); const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft); const tabs = useStore(state => state.tabs); const activeTabId = useStore(state => state.activeTabId); const setActiveContext = useStore(state => state.setActiveContext); const removeConnection = useStore(state => state.removeConnection); const connectionTags = useStore(state => state.connectionTags); const sidebarRootOrder = useStore(state => state.sidebarRootOrder); const addConnectionTag = useStore(state => state.addConnectionTag); const updateConnectionTag = useStore(state => state.updateConnectionTag); const removeConnectionTag = useStore(state => state.removeConnectionTag); const moveConnectionToTag = useStore(state => state.moveConnectionToTag); const reorderConnections = useStore(state => state.reorderConnections); const reorderTags = useStore(state => state.reorderTags); const reorderSidebarRoot = useStore(state => state.reorderSidebarRoot); const closeTabsByConnection = useStore(state => state.closeTabsByConnection); const closeTabsByDatabase = useStore(state => state.closeTabsByDatabase); const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); const activeContext = useStore(state => state.activeContext); const tableAccessCount = useStore(state => state.tableAccessCount); const tableSortPreference = useStore(state => state.tableSortPreference); const pinnedSidebarTables = useStore(state => state.pinnedSidebarTables); const recordTableAccess = useStore(state => state.recordTableAccess); const setTableSortPreference = useStore(state => state.setTableSortPreference); const setSidebarTablePinned = useStore(state => state.setSidebarTablePinned); const addSqlLog = useStore(state => state.addSqlLog); const sqlLogs = useStore(state => state.sqlLogs) || []; const shortcutOptions = useStore(state => state.shortcutOptions); const languagePreference = useStore(state => state.languagePreference); const setAppearance = useStore(state => state.setAppearance); const setAIPanelVisible = useStore(state => state.setAIPanelVisible); const addAIContext = useStore(state => state.addAIContext); void languagePreference; const darkMode = theme === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); const { exportProgressModal, runExportWithProgress } = useExportProgressDialog(); const disableLocalBackdropFilter = isMacLikePlatform(); const autoFetchVisible = useAutoFetchVisibility(); const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform()); const focusSidebarSearchShortcut = resolveShortcutDisplay(shortcutOptions, 'focusSidebarSearch', activeShortcutPlatform); const focusSidebarSearchShortcutTokens = focusSidebarSearchShortcut === '-' ? [] : focusSidebarSearchShortcut.match(/Ctrl|Alt|Shift|Esc|Space|[⌘⌃⌥⇧↵↑↓←→]|[^+]/g) ?? []; const isV2Ui = (uiVersion ?? appearance.uiVersion) === 'v2'; const [treeData, setTreeData] = useState([]); const activeTab = useMemo(() => tabs.find(tab => tab.id === activeTabId) || null, [tabs, activeTabId]); const activeTabLocateRequest = useMemo(() => normalizeSidebarLocateObjectRequestFromTab(activeTab), [activeTab]); const canLocateActiveTab = !!activeTabLocateRequest; // Background Helper (Duplicate logic for now, ideally shared) const getBg = (darkHex: string) => { if (!darkMode) return `rgba(255, 255, 255, ${opacity})`; const hex = darkHex.replace('#', ''); const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${opacity})`; }; const bgMain = getBg('#141414'); const overlayTheme = useMemo( () => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }), [darkMode, disableLocalBackdropFilter, appearance.uiVersion], ); const modalPanelStyle = useMemo(() => ({ background: overlayTheme.shellBg, border: overlayTheme.shellBorder, boxShadow: overlayTheme.shellShadow, backdropFilter: overlayTheme.shellBackdropFilter, }), [overlayTheme]); const modalSectionStyle = useMemo(() => ({ padding: 14, borderRadius: 14, border: overlayTheme.sectionBorder, background: overlayTheme.sectionBg, }), [overlayTheme]); const modalScrollSectionStyle = useMemo(() => ({ maxHeight: 400, overflow: 'auto' as const, border: overlayTheme.sectionBorder, borderRadius: 14, padding: 12, background: overlayTheme.sectionBg, }), [overlayTheme]); const modalHintTextStyle = useMemo(() => ({ color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6, }), [overlayTheme]); const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => (
{icon}
{title}
{description}
); const v2SidebarSearchMode = appearance.v2SidebarSearchMode ?? 'command'; const v2UseLegacySidebarFilter = isV2Ui && v2SidebarSearchMode === 'filter'; const v2CommandSearchPersistentFilterEnabled = appearance.v2CommandSearchPersistentFilterEnabled === true; const v2PersistedSidebarFilter = appearance.v2SidebarPersistedFilter ?? ''; const [searchValue, setSearchValue] = useState(v2PersistedSidebarFilter); const deferredSearchValue = useDeferredValue(searchValue); const [searchScopes, setSearchScopes] = useState(['smart']); const [v2ExplorerFilter, setV2ExplorerFilter] = useState('all'); const [isSearchScopePopoverOpen, setIsSearchScopePopoverOpen] = useState(false); const searchInputRef = useRef(null); const commandSearchInputRef = useRef(null); const [isV2CommandSearchOpen, setIsV2CommandSearchOpen] = useState(false); const [v2CommandSearchValue, setV2CommandSearchValue] = useState(''); const deferredV2CommandSearchValue = useDeferredValue(v2CommandSearchValue); const [v2CommandActiveIndex, setV2CommandActiveIndex] = useState(0); const [expandedKeys, setExpandedKeys] = useState([]); const [autoExpandParent, setAutoExpandParent] = useState(true); const [loadedKeys, setLoadedKeys] = useState([]); const [selectedKeys, setSelectedKeys] = useState([]); const selectedNodesRef = useRef([]); const loadingNodesRef = useRef>(new Set()); const clickTimerRef = useRef | null>(null); const treeDragSelectSuppressUntilRef = useRef(0); const treeDragSelectionSnapshotRef = useRef<{ selectedKeys: React.Key[]; selectedNodes: any[]; activeContext: { connectionId: string; dbName: string } | null; }>({ selectedKeys: [], selectedNodes: [], 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>({}); const connectionIds = useMemo(() => connections.map((conn) => conn.id), [connections]); const connectionIdSet = useMemo(() => new Set(connectionIds), [connectionIds]); const unmatchedSavedQueries = useMemo( () => savedQueries.filter((query) => isSavedQueryUnmatchedForConnectionIds(query, connectionIdSet)), [connectionIdSet, savedQueries], ); const allSavedQueriesNode = useMemo(() => { return buildAllSavedQueriesTreeNode(savedQueries, connections); }, [connections, savedQueries]); const snapshotTreeSelectionBeforeDrag = useCallback(() => { treeDragSelectionSnapshotRef.current = { selectedKeys: [...selectedKeys], selectedNodes: [...selectedNodesRef.current], activeContext: activeContext ? { ...activeContext } : null, }; }, [activeContext, selectedKeys]); const restoreTreeSelectionAfterDrag = useCallback(() => { const snapshot = treeDragSelectionSnapshotRef.current; treeDragSelectSuppressUntilRef.current = Date.now() + 1000; setSelectedKeys(snapshot.selectedKeys); selectedNodesRef.current = snapshot.selectedNodes; setActiveContext(snapshot.activeContext); }, [setActiveContext]); const openV2CommandSearch = useCallback(() => { setIsV2CommandSearchOpen(true); setV2CommandActiveIndex(0); }, []); const commitV2CommandSearchPersistentFilter = useCallback((value = v2CommandSearchValue) => { if (!v2CommandSearchPersistentFilterEnabled) { return; } const nextFilter = value.trim(); setSearchValue(nextFilter); if (nextFilter !== v2PersistedSidebarFilter) { setAppearance({ v2SidebarPersistedFilter: nextFilter }); } }, [setAppearance, v2CommandSearchPersistentFilterEnabled, v2CommandSearchValue, v2PersistedSidebarFilter]); const closeV2CommandSearch = useCallback(() => { commitV2CommandSearchPersistentFilter(); setIsV2CommandSearchOpen(false); setV2CommandSearchValue(''); setV2CommandActiveIndex(0); }, [commitV2CommandSearchPersistentFilter]); useEffect(() => { setSearchValue(v2PersistedSidebarFilter); }, [v2PersistedSidebarFilter]); useEffect(() => { if (!v2UseLegacySidebarFilter) { return; } const nextFilter = searchValue.trim(); if (nextFilter !== v2PersistedSidebarFilter) { setAppearance({ v2SidebarPersistedFilter: nextFilter }); } }, [searchValue, setAppearance, v2PersistedSidebarFilter, v2UseLegacySidebarFilter]); const handleV2CommandSearchValueChange = useCallback((value: string) => { setV2CommandSearchValue(value); }, []); useEffect(() => { if (!v2CommandSearchPersistentFilterEnabled) { return; } if (!isV2CommandSearchOpen) { return; } const nextFilter = resolveV2CommandSearchPersistentFilter({ commandSearchValue: deferredV2CommandSearchValue, persistedFilter: v2PersistedSidebarFilter, enabled: v2CommandSearchPersistentFilterEnabled, isOpen: isV2CommandSearchOpen, }); setSearchValue(nextFilter); const timer = window.setTimeout(() => { setAppearance({ v2SidebarPersistedFilter: nextFilter }); }, 160); return () => window.clearTimeout(timer); }, [deferredV2CommandSearchValue, isV2CommandSearchOpen, setAppearance, v2CommandSearchPersistentFilterEnabled, v2PersistedSidebarFilter]); const toggleV2CommandSearchPersistentFilter = useCallback((enabled: boolean) => { const nextFilter = enabled ? v2CommandSearchValue.trim() : ''; setSearchValue(nextFilter); setAppearance({ v2CommandSearchPersistentFilterEnabled: enabled, v2SidebarPersistedFilter: nextFilter, }); message.success( enabled ? t('sidebar.message.sidebar_filter_sync_enabled') : t('sidebar.message.sidebar_filter_sync_disabled'), ); }, [setAppearance, v2CommandSearchValue]); const resetV2SidebarFilter = useCallback(() => { setSearchValue(''); setAppearance({ v2CommandSearchPersistentFilterEnabled: false, v2SidebarPersistedFilter: '', }); message.success(t('sidebar.message.sidebar_filter_reset')); }, [setAppearance]); // Virtual Scroll State const [treeHeight, setTreeHeight] = useState(500); const [treeViewportWidth, setTreeViewportWidth] = useState(0); const treeContainerRef = useRef(null); const treeRef = useRef(null); const treeDataRef = useRef([]); const externalSQLDirectoryTreesRef = useRef>({}); const findTreeNodeByKeyRef = useRef<(nodes: TreeNode[], targetKey: React.Key) => TreeNode | null>(() => null); const expandConnectionFromRailRef = useRef<(connectionId: string) => void>(() => {}); useEffect(() => { treeDataRef.current = treeData; }, [treeData]); useEffect(() => { if (!treeContainerRef.current) return; const resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { setTreeHeight(entry.contentRect.height); setTreeViewportWidth(entry.contentRect.width); } }); resizeObserver.observe(treeContainerRef.current); return () => resizeObserver.disconnect(); }, []); useEffect(() => { const handleFocusSidebarSearch = () => { if (isV2Ui && !v2UseLegacySidebarFilter) { openV2CommandSearch(); return; } const inputEl = searchInputRef.current?.input as HTMLInputElement | undefined; if (!inputEl) { return; } inputEl.focus(); inputEl.select(); }; window.addEventListener('gonavi:focus-sidebar-search', handleFocusSidebarSearch as EventListener); return () => { window.removeEventListener('gonavi:focus-sidebar-search', handleFocusSidebarSearch as EventListener); }; }, [isV2Ui, openV2CommandSearch, v2UseLegacySidebarFilter]); useEffect(() => { if (!isV2CommandSearchOpen) return; const timer = window.setTimeout(() => { const inputEl = commandSearchInputRef.current?.input as HTMLInputElement | undefined; inputEl?.focus(); inputEl?.select(); }, 0); return () => window.clearTimeout(timer); }, [isV2CommandSearchOpen]); useEffect(() => { if (!isV2CommandSearchOpen) return; const handleV2CommandSearchGlobalKeyDown = (event: KeyboardEvent) => { if (!shouldCloseV2CommandSearchOnGlobalKey({ key: event.key, isOpen: isV2CommandSearchOpen })) { return; } event.preventDefault(); event.stopPropagation(); closeV2CommandSearch(); }; window.addEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true); return () => window.removeEventListener('keydown', handleV2CommandSearchGlobalKeyDown, true); }, [closeV2CommandSearch, isV2CommandSearchOpen]); // Connection Status State: key -> 'success' | 'error' const [connectionStates, setConnectionStates] = useState>({}); const [isTreeDragging, setIsTreeDragging] = useState(false); // Create Database Modal const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false); const [createDbForm] = Form.useForm(); const [targetConnection, setTargetConnection] = useState(null); const [isCreateSchemaModalOpen, setIsCreateSchemaModalOpen] = useState(false); const [createSchemaForm] = Form.useForm(); const [createSchemaTarget, setCreateSchemaTarget] = useState(null); const [isRenameSchemaModalOpen, setIsRenameSchemaModalOpen] = useState(false); const [renameSchemaForm] = Form.useForm(); const [renameSchemaTarget, setRenameSchemaTarget] = useState(null); const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false); const [renameDbForm] = Form.useForm(); const [renameDbTarget, setRenameDbTarget] = useState(null); const [isRenameTableModalOpen, setIsRenameTableModalOpen] = useState(false); const [renameTableForm] = Form.useForm(); const [renameTableTarget, setRenameTableTarget] = useState(null); const [messagePublishTarget, setMessagePublishTarget] = useState(null); const [isRenameViewModalOpen, setIsRenameViewModalOpen] = useState(false); const [renameViewForm] = Form.useForm(); const [renameViewTarget, setRenameViewTarget] = useState(null); const [isRenameSavedQueryModalOpen, setIsRenameSavedQueryModalOpen] = useState(false); const [renameSavedQueryForm] = Form.useForm(); const [renameSavedQueryTarget, setRenameSavedQueryTarget] = useState(null); const [isExternalSQLFileModalOpen, setIsExternalSQLFileModalOpen] = useState(false); const [externalSQLFileForm] = Form.useForm(); const [externalSQLFileModalMode, setExternalSQLFileModalMode] = useState('create'); const [externalSQLFileTarget, setExternalSQLFileTarget] = useState(null); // Connection Tag Modals const [isCreateTagModalOpen, setIsCreateTagModalOpen] = useState(false); const [createTagForm] = Form.useForm(); // Batch Operations Modal const [isBatchModalOpen, setIsBatchModalOpen] = useState(false); const [batchTables, setBatchTables] = useState([]); const [checkedTableKeys, setCheckedTableKeys] = useState([]); const [batchDbContext, setBatchDbContext] = useState(null); const [selectedConnection, setSelectedConnection] = useState(''); const [selectedDatabase, setSelectedDatabase] = useState(''); const [availableDatabases, setAvailableDatabases] = useState([]); const [batchFilterKeyword, setBatchFilterKeyword] = useState(''); const [batchFilterType, setBatchFilterType] = useState('all'); const [batchSelectionScope, setBatchSelectionScope] = useState('filtered'); const filteredBatchObjects = useMemo(() => { const keyword = batchFilterKeyword.trim().toLowerCase(); return batchTables.filter((item) => { if (batchFilterType !== 'all' && item.objectType !== batchFilterType) { return false; } if (!keyword) { return true; } return item.title.toLowerCase().includes(keyword) || item.objectName.toLowerCase().includes(keyword); }); }, [batchFilterKeyword, batchFilterType, batchTables]); const groupedBatchObjects = useMemo(() => { const tables = filteredBatchObjects.filter(item => item.objectType === 'table'); const views = filteredBatchObjects.filter(item => item.objectType === 'view'); return { tables, views }; }, [filteredBatchObjects]); const allBatchObjectKeys = useMemo(() => batchTables.map(item => item.key), [batchTables]); const allBatchObjectKeysByType = useMemo(() => { if (batchFilterType === 'all') { return allBatchObjectKeys; } return batchTables .filter((item) => item.objectType === batchFilterType) .map((item) => item.key); }, [allBatchObjectKeys, batchFilterType, batchTables]); const filteredBatchObjectKeys = useMemo(() => filteredBatchObjects.map(item => item.key), [filteredBatchObjects]); const selectionScopeTargetKeys = useMemo( () => (batchSelectionScope === 'filtered' ? filteredBatchObjectKeys : allBatchObjectKeysByType), [allBatchObjectKeysByType, batchSelectionScope, filteredBatchObjectKeys] ); useEffect(() => { if (batchFilterType === 'all') { return; } const allowed = new Set(allBatchObjectKeysByType); setCheckedTableKeys((prev) => prev.filter((key) => allowed.has(key))); }, [allBatchObjectKeysByType, batchFilterType]); // Batch Database Operations Modal const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false); const [batchDatabases, setBatchDatabases] = useState([]); const [checkedDbKeys, setCheckedDbKeys] = useState([]); const [batchConnContext, setBatchConnContext] = useState(null); const [selectedDbConnection, setSelectedDbConnection] = useState(''); // Find in Database Modal const [findInDbContext, setFindInDbContext] = useState<{ open: boolean; connectionId: string; dbName: string }>({ open: false, connectionId: '', dbName: '' }); useEffect(() => { if (!autoFetchVisible) { return; } expandedKeys.forEach(key => { const node = findTreeNodeByKey(treeData, key); if (node && node.type === 'database') { loadTables(node); } }); }, [autoFetchVisible, savedQueries]); useEffect(() => { const previousSignatures = connectionReloadSignaturesRef.current; const nextSignatures: Record = {}; const staleConnectionIds = new Set(); connections.forEach((conn) => { const signature = buildConnectionReloadSignature(conn); nextSignatures[conn.id] = signature; if (previousSignatures[conn.id] && previousSignatures[conn.id] !== signature) { staleConnectionIds.add(conn.id); } }); connectionReloadSignaturesRef.current = nextSignatures; if (staleConnectionIds.size > 0) { const staleIds = Array.from(staleConnectionIds); setLoadedKeys((prev) => prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))), ); setExpandedKeys((prev) => prev.filter((key) => !staleIds.some((id) => isConnectionTreeKey(key, id))), ); setConnectionStates((prev) => { const next = { ...prev }; staleIds.forEach((id) => { Object.keys(next).forEach((key) => { if (isConnectionTreeKey(key, id)) { delete next[key]; } }); }); return next; }); staleIds.forEach((id) => { Array.from(loadingNodesRef.current).forEach((key) => { if (key === `dbs-${id}` || key.startsWith(`tables-${id}-`)) { loadingNodesRef.current.delete(key); } }); }); } setTreeData((prev) => { const prevMap = new Map(); // We need to recursively extract connections from old tag structures // so if a user expands a connection that was tagged, the state remains const recurseCollect = (nodes: TreeNode[]) => { nodes.forEach((node) => { if (node.type === 'tag') { if (node.children) recurseCollect(node.children); } else if (node.type === 'connection') { prevMap.set(String(node.key), node); } }); }; recurseCollect(prev); const buildConnectionNode = (conn: SavedConnection): TreeNode => { const existing = prevMap.get(conn.id); const iconType = resolveConnectionIconType(conn); const iconColor = resolveConnectionAccentColor(conn); const preserveChildren = existing && !staleConnectionIds.has(conn.id); return { title: conn.name, key: conn.id, icon: getDbIcon(iconType, iconColor, 22), type: 'connection', dataRef: conn, isLeaf: false, children: preserveChildren ? existing.children : undefined, } as TreeNode; }; const taggedConnIds = new Set(); const tagNodesById = new Map(); connectionTags.forEach((tag) => { tag.connectionIds.forEach(id => taggedConnIds.add(id)); tagNodesById.set(tag.id, { title: tag.name, key: `tag-${tag.id}`, icon: ( ), type: 'tag', dataRef: tag, isLeaf: false, children: tag.connectionIds .map(cid => connections.find(c => c.id === cid)) .filter(Boolean) .map(conn => buildConnectionNode(conn!)), } as TreeNode); }); const ungroupedNodesById = new Map(); connections .filter(c => !taggedConnIds.has(c.id)) .forEach((conn) => { ungroupedNodesById.set(conn.id, buildConnectionNode(conn)); }); const orderedRootTokens = resolveSidebarRootOrderTokens( sidebarRootOrder, connectionTags, connections, ); const orderedNodes: TreeNode[] = []; orderedRootTokens.forEach((token) => { if (token.startsWith('tag:')) { const tagNode = tagNodesById.get(token.slice('tag:'.length)); if (!tagNode) return; orderedNodes.push(tagNode); tagNodesById.delete(token.slice('tag:'.length)); return; } if (token.startsWith('connection:')) { const connectionNode = ungroupedNodesById.get(token.slice('connection:'.length)); if (!connectionNode) return; orderedNodes.push(connectionNode); ungroupedNodesById.delete(token.slice('connection:'.length)); } }); orderedNodes.push(...Array.from(tagNodesById.values())); orderedNodes.push(...Array.from(ungroupedNodesById.values())); if (allSavedQueriesNode) { orderedNodes.push(allSavedQueriesNode); } const externalSQLRootNode = prev.find((node) => node.type === 'external-sql-root'); return externalSQLRootNode ? [...orderedNodes, externalSQLRootNode] : orderedNodes; }); }, [connections, connectionTags, sidebarRootOrder, allSavedQueriesNode]); const handleDuplicateConnection = async (conn: SavedConnection) => { if (!conn?.id) return; const backendApp = (window as any).go?.app?.App; if (typeof backendApp?.DuplicateConnection !== 'function') { message.error(t('connection.sidebar.duplicate.backendUnavailable')); return; } try { const duplicatedConnection = await backendApp.DuplicateConnection(conn.id); if (!duplicatedConnection) { throw new Error(t('connection.sidebar.duplicate.noResult')); } addConnection(duplicatedConnection); message.success(t('connection.sidebar.duplicate.success', { name: duplicatedConnection.name, })); } catch (error: any) { message.error(error?.message || t('connection.sidebar.duplicate.failureFallback')); } }; const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => { return list.map(node => { if (node.key === key) { return { ...node, children }; } if (node.children) { return { ...node, children: updateTreeData(node.children, key, children) }; } return node; }); }; const findTreeNodeByKey = (nodes: TreeNode[], targetKey: React.Key): TreeNode | null => { for (const node of nodes) { if (node.key === targetKey) { return node; } if (node.children) { const child = findTreeNodeByKey(node.children, targetKey); if (child) { return child; } } } return null; }; findTreeNodeByKeyRef.current = findTreeNodeByKey; const replaceTreeNodeChildren = (key: React.Key, children: TreeNode[] | undefined): TreeNode[] => { const nextTreeData = updateTreeData(treeDataRef.current, key, children); treeDataRef.current = nextTreeData; setTreeData(nextTreeData); return nextTreeData; }; const mergeExpandedTreeKeys = (requiredKeys: React.Key[]) => { setExpandedKeys(prev => { const merged = [...prev]; requiredKeys.forEach(key => { if (!merged.includes(key)) merged.push(key); }); return merged; }); setAutoExpandParent(true); }; const scrollSidebarTreeToKey = (key: React.Key) => { const runAfterFrame = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function' ? window.requestAnimationFrame.bind(window) : (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0); runAfterFrame(() => { treeRef.current?.scrollTo?.({ key, align: 'auto' }); runAfterFrame(() => { const selectedNode = treeContainerRef.current?.querySelector('.ant-tree-treenode-selected') as HTMLElement | null; selectedNode?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); }); }); }; const decorateExternalSQLTreeNode = (node: ExternalSQLTreeNode): TreeNode => { const icon = (() => { switch (node.type) { case 'external-sql-root': return ; case 'external-sql-directory': return ; case 'external-sql-folder': return ; default: return ; } })(); return { ...node, icon, children: node.children?.map((child) => decorateExternalSQLTreeNode(child)), }; }; const buildExternalSQLRootTreeNode = useCallback(( directories: ExternalSQLDirectory[] = externalSQLDirectories, directoryTrees: Record = externalSQLDirectoryTreesRef.current, ): TreeNode => decorateExternalSQLTreeNode(buildExternalSQLRootNode({ directories, directoryTrees, })), [externalSQLDirectories]); const refreshGlobalExternalSQLRootNode = useCallback(async ( showSuccess = false, directoriesOverride?: ExternalSQLDirectory[], ) => { const targetDirectories = directoriesOverride || externalSQLDirectories; const directoryTrees: Record = {}; await Promise.all(targetDirectories.map(async (directory) => { 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, }), }); directoryTrees[directory.id] = []; return; } directoryTrees[directory.id] = Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : []; })); externalSQLDirectoryTreesRef.current = directoryTrees; const rootNode = buildExternalSQLRootTreeNode(targetDirectories, directoryTrees); setTreeData((prev) => { const withoutExternalRoot = prev.filter((node) => node.type !== 'external-sql-root'); const nextTreeData = [...withoutExternalRoot, rootNode]; treeDataRef.current = nextTreeData; return nextTreeData; }); if (showSuccess) { message.success(t('sidebar.message.external_sql_directory_refreshed')); } }, [buildExternalSQLRootTreeNode, externalSQLDirectories]); useEffect(() => { void refreshGlobalExternalSQLRootNode(false); }, [refreshGlobalExternalSQLRootNode]); const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => { if (!node) return null; if (node.type === 'database') { return { connectionId: String(node?.dataRef?.id || '').trim(), dbName: String(node?.dataRef?.dbName || '').trim(), dbNodeKey: String(node.key || '').trim(), }; } if ( node.type === 'external-sql-root' || node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file' ) { return { connectionId: String(node?.dataRef?.connectionId || '').trim(), dbName: String(node?.dataRef?.dbName || '').trim(), dbNodeKey: String(node?.dataRef?.dbNodeKey || '').trim(), }; } 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 => { for (let attempt = 0; attempt < SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS && loadingNodesRef.current.has(loadKey); attempt += 1) { await new Promise(resolve => window.setTimeout(resolve, SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS)); } return !loadingNodesRef.current.has(loadKey); }; const locateObjectInSidebar = async (detail: unknown) => { const request = normalizeSidebarLocateObjectRequest(detail); if (!request) { message.warning(t('sidebar.message.locate_current_table_unavailable')); return; } if (request.objectGroup === 'externalSqlFiles') { await refreshGlobalExternalSQLRootNode(false); const target = resolveSidebarLocateTarget(request, { groupBySchema: false }); const path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target); if (!path) { message.warning(t('sidebar.message.locate_external_sql_file_not_found', { path: request.filePath })); return; } const targetKey = path[path.length - 1]; const targetNode = findTreeNodeByKey(treeDataRef.current, targetKey); setSearchValue(''); mergeExpandedTreeKeys(path.slice(0, -1)); setSelectedKeys([targetKey]); selectedNodesRef.current = targetNode ? [targetNode] : []; const connectionId = String(request.connectionId || activeContext?.connectionId || activeTab?.connectionId || '').trim(); const dbName = String(request.dbName || activeContext?.dbName || activeTab?.dbName || '').trim(); if (connectionId) { setActiveContext({ connectionId, dbName }); } scrollSidebarTreeToKey(targetKey); return; } const conn = connections.find(item => item.id === request.connectionId); if (!conn) { message.warning(t('sidebar.message.locate_connection_not_found_for_object')); return; } const target = resolveSidebarLocateTarget(request, { groupBySchema: shouldHideSchemaPrefix(conn), }); const objectLabel = request.objectGroup === 'materializedViews' ? t('sidebar.locate.object.materialized_view') : request.objectGroup === 'views' ? t('sidebar.locate.object.view') : request.objectGroup === 'triggers' ? t('sidebar.locate.object.trigger') : request.objectGroup === 'routines' ? t('sidebar.locate.object.routine') : t('sidebar.locate.object.table'); let path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target); const dbLoadKey = `dbs-${request.connectionId}`; const tableLoadKey = `tables-${request.connectionId}-${request.dbName}`; if (!path && !findSidebarNodePathByKey(treeDataRef.current as SidebarLocateTreeNodeLike[], target.databaseKey)) { const connectionNode = findTreeNodeByKey(treeDataRef.current, target.connectionKey); if (!connectionNode) { message.warning(t('sidebar.message.locate_connection_not_in_tree')); return; } if (loadingNodesRef.current.has(dbLoadKey)) { const loaded = await waitForSidebarLoadKey(dbLoadKey); if (!loaded) { message.info(t('sidebar.message.locate_database_loading', { database: request.dbName })); return; } } else { await loadDatabases(connectionNode); } } const dbNode = findTreeNodeByKey(treeDataRef.current, target.databaseKey); if (!dbNode) { message.warning(t('sidebar.message.locate_database_not_found', { database: request.dbName })); return; } path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target); if (!path) { if (loadingNodesRef.current.has(tableLoadKey)) { const loaded = await waitForSidebarLoadKey(tableLoadKey); if (!loaded) { message.info(t('sidebar.message.locate_object_loading', { object: objectLabel, database: request.dbName, })); return; } } else { await loadTables(dbNode); } path = findSidebarNodePathForLocate(treeDataRef.current as SidebarLocateTreeNodeLike[], target); } if (!path) { message.warning(t('sidebar.message.locate_object_not_found', { object: objectLabel, name: request.tableName, })); return; } const targetKey = path[path.length - 1]; const targetNode = findTreeNodeByKey(treeDataRef.current, targetKey); setSearchValue(''); mergeExpandedTreeKeys(path.slice(0, -1)); setSelectedKeys([targetKey]); selectedNodesRef.current = targetNode ? [targetNode] : []; setActiveContext({ connectionId: request.connectionId, dbName: request.dbName }); scrollSidebarTreeToKey(targetKey); }; const handleLocateActiveTabInSidebar = () => { if (!activeTabLocateRequest) { message.warning(t('sidebar.message.locate_current_table_unavailable')); return; } void locateObjectInSidebar(activeTabLocateRequest); }; useEffect(() => { locateObjectInSidebarRef.current = locateObjectInSidebar; }); useEffect(() => { const handleLocateSidebarObject = (event: Event) => { void locateObjectInSidebarRef.current((event as CustomEvent).detail); }; window.addEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener); return () => { window.removeEventListener('gonavi:locate-sidebar-object', handleLocateSidebarObject as EventListener); }; }, []); useEffect(() => { const handleSidebarTablePinChanged = (event: Event) => { const detail = (event as CustomEvent).detail || {}; const connectionId = String(detail.connectionId || '').trim(); const dbName = String(detail.dbName || '').trim(); if (!connectionId || !dbName) return; const dbNode = findTreeNodeByKeyRef.current(treeDataRef.current, `${connectionId}-${dbName}`); if (dbNode) { void loadTables(dbNode); } }; window.addEventListener('gonavi:sidebar-table-pin-changed', handleSidebarTablePinChanged as EventListener); return () => { window.removeEventListener('gonavi:sidebar-table-pin-changed', handleSidebarTablePinChanged as EventListener); }; }, []); const onLoadData = async ({ key, children, dataRef, type }: any) => { if (type === 'tag' || type === 'all-saved-queries' || type === 'saved-query-group' || type === 'unmatched-saved-queries') return; if (hasSidebarLazyChildren(children)) return; if (type === 'connection') { await loadDatabases({ key, dataRef }); } else if (type === 'jvm-mode' || type === 'jvm-resource') { await loadJVMResources({ key, dataRef }); } else if (type === 'database') { await loadTables({ key, dataRef }); } else if (type === 'external-sql-root') { await refreshGlobalExternalSQLRootNode(false); } else if (type === 'table') { // Expand table to show object categories const conn = dataRef; const folders: TreeNode[] = [ { title: t('sidebar.table_folder.columns'), key: `${key}-columns`, icon: , type: 'folder-columns', isLeaf: true, dataRef: conn }, { title: t('sidebar.table_folder.indexes'), key: `${key}-indexes`, icon: , type: 'folder-indexes', isLeaf: true, dataRef: conn }, { title: t('sidebar.table_folder.foreign_keys'), key: `${key}-fks`, icon: , type: 'folder-fks', isLeaf: true, dataRef: conn }, { title: t('sidebar.table_folder.triggers'), key: `${key}-triggers`, icon: , type: 'folder-triggers', isLeaf: true, dataRef: conn } ]; replaceTreeNodeChildren(key, folders); } }; const isStructureOnlyDbType = (connectionId: string): boolean => { const conn = connections.find(c => c.id === connectionId); if (!conn) return false; const dbType = resolveDataSourceType(conn.config); return dbType === 'elasticsearch' || dbType === 'mongodb' || dbType === 'redis' || dbType === 'iotdb'; }; const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => { const { tableName, dbName, id } = node.dataRef; const forceReadOnly = readOnly || isStructureOnlyDbType(id); addTab({ id: `design-${id}-${dbName}-${tableName}`, title: forceReadOnly ? t('sidebar.tab.table_structure', { table: tableName }) : t('sidebar.tab.design_table', { table: tableName }), type: 'design', connectionId: id, dbName: dbName, tableName: tableName, initialTab: initialTab, readOnly: forceReadOnly }); }; const openNewTableDesign = (node: any) => { const { dbName, id } = node.dataRef; if (isStructureOnlyDbType(id)) { message.warning(t('sidebar.message.visual_new_table_unsupported')); return; } addTab({ id: `new-table-${id}-${dbName}-${Date.now()}`, title: t('sidebar.tab.new_table', { database: dbName }), type: 'design', connectionId: id, dbName: dbName, tableName: '', // Empty tableName signals creation mode initialTab: 'columns', readOnly: false }); }; const onSelect = (keys: React.Key[], info: any) => { if (isV2Ui && info?.node?.type === 'v2-table-section') { return; } if (Date.now() < treeDragSelectSuppressUntilRef.current) { return; } if (isTreeDragging) { return; } setSelectedKeys(keys); selectedNodesRef.current = info.selectedNodes || []; if (keys.length === 0) { if (shouldClearSidebarActiveContextOnEmptySelect(isV2Ui)) { setActiveContext(null); } return; } if (shouldSkipSidebarSelectWhileDragging(isTreeDragging, info)) return; const { type, dataRef, key, title } = info.node; const nodeConnectionId = resolveSidebarNodeConnectionId(info.node, connectionIds); // Update active context if (type === 'connection') { setActiveContext({ connectionId: key, dbName: '' }); } else if (type === 'database') { setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName }); } else if (type === 'table') { setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName }); } else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') { setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: '' }); } else if (type === 'view' || type === 'materialized-view' || type === 'db-trigger' || type === 'db-event' || type === 'routine') { setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName }); } else if (type === 'saved-query') { setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName }); } else if (type === 'redis-db') { setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` }); } if (type === 'folder-columns') openDesign(info.node, 'columns', false); else if (type === 'folder-indexes') openDesign(info.node, 'indexes', false); else if (type === 'folder-fks') openDesign(info.node, 'foreignKeys', false); else if (type === 'folder-triggers') openDesign(info.node, 'triggers', false); else if (type === 'object-group' && dataRef?.groupKey === 'tables') { // 单击延迟打开表概览,双击时会取消此定时器 if (clickTimerRef.current) clearTimeout(clickTimerRef.current); const { id, dbName: gDbName, schemaName } = dataRef; clickTimerRef.current = setTimeout(() => { clickTimerRef.current = null; addTab({ id: `table-overview-${id}-${gDbName}${schemaName ? `-${schemaName}` : ''}`, title: t('sidebar.tab.table_overview', { database: gDbName, schema: schemaName ? ` (${schemaName})` : '', }), type: 'table-overview' as any, connectionId: id, dbName: gDbName, schemaName, } as any); }, 250); } }; const onExpand = (newExpandedKeys: React.Key[], info?: any) => { setExpandedKeys(newExpandedKeys); setAutoExpandParent(false); if (!shouldSkipSidebarLoadOnExpandWhileDragging(isTreeDragging, info)) { void onLoadData(info.node); } }; const onDoubleClick = (e: any, node: any) => { // 双击时取消单击延迟动作(如表概览打开),让双击只触发展开/折叠 if (clickTimerRef.current) { clearTimeout(clickTimerRef.current); clickTimerRef.current = null; } const { type, dataRef, key: nodeKey } = node; if (isV2Ui && type === 'v2-table-section') { return; } const nodeConnectionId = resolveSidebarNodeConnectionId(node, connectionIds); if (type === 'connection') { setSelectedKeys([nodeKey]); selectedNodesRef.current = [node]; setActiveContext({ connectionId: nodeKey, dbName: '' }); } else if (type === 'database') { setSelectedKeys([nodeKey]); selectedNodesRef.current = [node]; setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName }); } else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') { setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: '' }); } else if (type === 'table' || type === 'view' || type === 'materialized-view' || type === 'db-trigger' || type === 'db-event' || type === 'routine') { setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName }); } else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName }); else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` }); if (node.type === 'table') { const { tableName, dbName, id } = node.dataRef; // 记录表访问 recordTableAccess(id, dbName, tableName); addTab({ id: node.key, title: tableName, type: 'table', connectionId: id, dbName, tableName, objectType: 'table', }); return; } else if (node.type === 'view' || node.type === 'materialized-view') { const { viewName, dbName, id, schemaName } = node.dataRef; addTab({ id: node.key, title: viewName, type: 'table', connectionId: id, dbName, tableName: viewName, objectType: node.type === 'materialized-view' ? 'materialized-view' : 'view', schemaName, sidebarLocateKey: String(node.key || ''), }); return; } else if (node.type === 'saved-query') { const q = node.dataRef; addTab({ id: q.id, title: resolveSavedQueryDisplayName(q.name), type: 'query', connectionId: q.connectionId, dbName: q.dbName, query: q.sql, savedQueryId: q.id, }); return; } else if (node.type === 'external-sql-file') { void openExternalSQLFile(node); return; } else if (node.type === 'redis-db') { const { id, redisDB } = node.dataRef; addTab({ id: `redis-keys-${id}-db${redisDB}`, title: `db${redisDB}`, type: 'redis-keys', connectionId: id, redisDB: redisDB }); return; } else if (node.type === 'db-trigger') { const { triggerName, triggerTableName, schemaName, dbName, id } = node.dataRef; addTab({ id: `trigger-${node.key}`, title: t('sidebar.tab.trigger', { name: triggerName }), type: 'trigger', connectionId: id, dbName, triggerName, triggerTableName, schemaName, sidebarLocateKey: String(node.key || ''), }); return; } else if (node.type === 'db-event') { openEventDefinition(node); return; } else if (node.type === 'routine') { const { routineName, routineType, dbName, id } = node.dataRef; const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'); addTab({ id: `routine-def-${node.key}`, title: t('sidebar.tab.routine_definition', { type: typeLabel, name: routineName }), type: 'routine-def', connectionId: id, dbName, routineName, routineType }); return; } else if (node.type === 'jvm-mode') { const { providerMode, id } = node.dataRef; const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection; openJVMOverviewTab(conn, providerMode); return; } else if (node.type === 'jvm-resource') { const { providerMode, resourcePath, resourceKind, id } = node.dataRef; const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection; openJVMResourceTab(conn, providerMode, resourcePath, resourceKind); return; } else if (node.type === 'jvm-monitoring') { const { providerMode, id } = node.dataRef; const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection; openJVMMonitoringTab(conn, providerMode); return; } else if (node.type === 'jvm-diagnostic') { const conn = (connections.find((item) => item.id === node.dataRef.id) || node.dataRef) as SavedConnection; openJVMDiagnosticTab(conn); return; } const key = node.key; const isExpanded = expandedKeys.includes(key); const newExpandedKeys = isExpanded ? expandedKeys.filter(k => k !== key) : [...expandedKeys, key]; setExpandedKeys(newExpandedKeys); if (!isExpanded) { setAutoExpandParent(false); if (shouldLoadSidebarNodeOnExpand(node)) { void onLoadData(node); } } }; const handleCopyStructure = async (node: any) => { const { config, dbName, tableName } = node.dataRef; const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, dbName, tableName); if (res.success) { navigator.clipboard.writeText(res.data as string); message.success(t('table_overview.message.copy_structure_success')); } else { message.error(res.message); } }; const resolveCopyObjectNameLabel = (node: any): string => { if (node?.type === 'view') return t('sidebar.copy_object_name.label.view'); if (node?.type === 'materialized-view') return t('sidebar.copy_object_name.label.materialized_view'); if (node?.type === 'db-event') return t('sidebar.copy_object_name.label.event'); return t('sidebar.copy_object_name.label.table'); }; const handleCopyTableName = async (node: any) => { const objectName = resolveSidebarTableNameForCopy(node); const label = resolveCopyObjectNameLabel(node); if (!objectName) { message.warning(t('sidebar.copy_object_name.empty', { label })); return; } try { await navigator.clipboard.writeText(objectName); message.success(t('sidebar.copy_object_name.copied', { label })); } catch (e: any) { message.error(t('sidebar.copy_object_name.failed', { label, error: e?.message || String(e) })); } }; const handleExport = async (node: any, options: { format: string; xlsxMaxRowsPerSheet?: number }) => { const { config, dbName, tableName } = node.dataRef; const rowCount = Number(node?.dataRef?.rowCount); const totalRowsKnown = Number.isFinite(rowCount) && rowCount > 0; await runExportWithProgress({ title: `导出 ${tableName}`, targetName: tableName, format: options.format, totalRows: totalRowsKnown ? rowCount : undefined, run: (jobId) => ExportTableWithOptions( buildRpcConnectionConfig(config) as any, dbName, tableName, { ...options, jobId, totalRowsHint: totalRowsKnown ? rowCount : 0, totalRowsKnown, } as any, ), }); }; const openExportDialog = async (node: any) => { const tableName = String(node?.dataRef?.tableName || node?.title || '').trim(); if (!tableName) { message.warning('未识别到表名,无法导出'); return; } const connectionId = resolveSidebarNodeConnectionId(node, connectionIds) || String(node?.dataRef?.id || '').trim(); const dbName = String(node?.dataRef?.dbName || '').trim(); addTab(buildTableExportTab({ connectionId, dbName, tableName, title: `导出 ${tableName}`, objectType: node?.type === 'view' ? 'view' : (node?.type === 'materialized-view' ? 'materialized-view' : 'table'), schemaName: typeof node?.dataRef?.schemaName === 'string' ? node.dataRef.schemaName : undefined, sidebarLocateKey: typeof node?.key === 'string' ? node.key : undefined, rowCountByScope: Number.isFinite(Number(node?.dataRef?.rowCount)) && Number(node?.dataRef?.rowCount) > 0 ? { all: Math.trunc(Number(node.dataRef.rowCount)) } : undefined, })); }; const handleCopyTableAsInsert = async (node: any) => { await handleExport(node, { format: 'sql' }); }; const openTableDdlInDesigner = (node: any) => { openDesign(node, 'ddl', true); }; const openTableInERView = (node: any) => { onDoubleClick(null, node); setTimeout(() => { window.dispatchEvent(new CustomEvent('gonavi:data-grid:set-view-mode', { detail: { connectionId: node.dataRef?.id, dbName: node.dataRef?.dbName, tableName: node.dataRef?.tableName, viewMode: 'er', }, })); }, 0); }; const injectTablePromptToAI = async (node: any, promptKind: 'explain' | 'query') => { const conn = node.dataRef; const tableName = String(conn?.tableName || node?.title || '').trim(); if (!conn?.id || !conn?.dbName || !tableName) { message.warning('当前表缺少连接上下文,无法发送给 AI'); return; } let ddl = ''; try { const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, conn.dbName, tableName); if (res.success) { ddl = String(res.data || '').trim(); addAIContext(conn.id, { dbName: conn.dbName, tableName, ddl }); } } catch { // AI 入口仍可基于表名工作,DDL 获取失败不阻断打开面板。 } const prompt = promptKind === 'explain' ? [ `请解释数据表 ${conn.dbName}.${tableName} 的结构和业务含义。`, '重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。', ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '', ].filter(Boolean).join('\n') : [ `请基于数据表 ${conn.dbName}.${tableName} 生成 3 条常用查询 SQL。`, '要求包含:数据预览查询、按关键字段过滤查询、一个聚合或统计查询。', ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '', ].filter(Boolean).join('\n'); const wasClosed = !useStore.getState().aiPanelVisible; if (wasClosed) setAIPanelVisible(true); setTimeout(() => { window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); }, wasClosed ? 350 : 0); }; const normalizeConnConfig = (raw: any) => ( buildRpcConnectionConfig(raw) ); const handleExportDatabaseSQL = async (node: any, includeData: boolean) => { const conn = node.dataRef; const dbName = conn.dbName || node.title; const hide = message.loading( includeData ? t('sidebar.message.exporting_database_backup', { database: dbName }) : t('sidebar.message.exporting_database_schema', { database: dbName }), 0, ); try { const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(conn.config), dbName, includeData); hide(); if (res.success) { message.success(t('sidebar.message.export_success')); } else if (res.message !== '已取消') { message.error(t('sidebar.message.export_failed', { error: res.message })); } } catch (e: any) { hide(); message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); } }; const handleExportSchemaSQL = async (node: any, includeData: boolean) => { const conn = node?.dataRef; const dbName = String(conn?.dbName || '').trim(); const schemaName = String(conn?.schemaName || '').trim(); if (!conn || !dbName || !schemaName) { message.error(t('sidebar.message.schema_export_target_missing')); return; } const hide = message.loading( includeData ? t('sidebar.message.exporting_schema_backup', { schema: schemaName }) : t('sidebar.message.exporting_schema_structure', { schema: schemaName }), 0, ); try { const res = await (window as any).go.app.App.ExportSchemaSQL( buildRpcConnectionConfig(conn.config, { database: dbName }) as any, dbName, schemaName, includeData, ); hide(); if (res.success) { message.success(t('sidebar.message.export_success')); } else if (res.message !== '已取消') { message.error(t('sidebar.message.export_failed', { error: res.message })); } } catch (e: any) { hide(); message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); } }; const handleExportTablesSQL = async (nodes: any[], includeData: boolean) => { if (!nodes || nodes.length === 0) return; const first = nodes[0].dataRef; const dbName = first.dbName; const connId = first.id; const allSame = nodes.every(n => n?.dataRef?.id === connId && n?.dataRef?.dbName === dbName); if (!allSame) { message.error(t('sidebar.message.export_tables_same_database_required')); return; } const tableNames = nodes.map(n => n.dataRef.tableName).filter(Boolean); const hide = message.loading( includeData ? t('sidebar.message.backing_up_selected_tables', { count: tableNames.length }) : t('sidebar.message.exporting_selected_table_schema', { count: tableNames.length }), 0, ); try { const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(first.config), dbName, tableNames, includeData); hide(); if (res.success) { message.success(t('sidebar.message.export_success')); } else if (res.message !== '已取消') { message.error(t('sidebar.message.export_failed', { error: res.message })); } } catch (e: any) { hide(); message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); } }; const openBatchOperationModal = async () => { // Check if current selected node is database or table let connId = ''; let dbName = ''; if (selectedNodesRef.current.length > 0) { const node = selectedNodesRef.current[0]; if (node.type === 'database') { connId = node.dataRef.id; dbName = node.title; } else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { connId = node.dataRef.id; dbName = node.dataRef.dbName; } } setSelectedConnection(connId); setSelectedDatabase(dbName); setBatchTables([]); setCheckedTableKeys([]); setAvailableDatabases([]); setBatchFilterKeyword(''); setBatchFilterType('all'); setBatchSelectionScope('filtered'); if (connId) { const conn = connections.find(c => c.id === connId); if (conn) { await loadDatabasesForBatch(conn); if (dbName) { await loadTablesForBatch(conn, dbName); } } } setIsBatchModalOpen(true); }; const openBatchTableExportWorkbench = () => { let connId = ''; let dbName = ''; if (selectedNodesRef.current.length > 0) { const node = selectedNodesRef.current[0]; if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { connId = node.key as string; } else if (node.type === 'database') { connId = node.dataRef.id; dbName = node.title; } else if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { connId = node.dataRef.id; dbName = node.dataRef.dbName; } } addTab(buildBatchTableExportWorkbenchTab({ connectionId: connId, dbName: dbName || undefined, title: dbName ? `批量导出 ${dbName} 对象` : '批量导出对象', })); }; const loadDatabasesForBatch = async (conn: SavedConnection) => { 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: "" } }; const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any); if (res.success) { const dbRows: any[] = Array.isArray(res.data) ? res.data : []; let dbs = dbRows.map((row: any) => { const dbName = row.Database || row.database; return { title: dbName, key: `${conn.id}-${dbName}`, dbName: dbName }; }); if (conn.includeDatabases && conn.includeDatabases.length > 0) { dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName)); } setAvailableDatabases(dbs); } else { message.error(t('sidebar.message.load_database_list_failed', { error: res.message })); } }; const loadTablesForBatch = async (conn: SavedConnection, dbName: string) => { setBatchDbContext({ conn, dbName }); 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: "" } }; const [res, viewResult] = await Promise.all([ DBGetTables(buildRpcConnectionConfig(config) as any, dbName), loadViews(conn, dbName).catch(() => ({ views: [], supported: false })), ]); if (!res.success) { message.error(t('sidebar.message.load_table_list_failed', { error: res.message })); return; } const tableRows: any[] = Array.isArray(res.data) ? res.data : []; const viewRows: SidebarViewMetadataEntry[] = Array.isArray(viewResult.views) ? viewResult.views : []; const viewSet = new Set( viewRows.flatMap((view) => { const names = [view.viewName.toLowerCase()]; if (view.schemaName && !view.viewName.includes('.')) { names.push(`${view.schemaName}.${view.viewName}`.toLowerCase()); } return names; }) ); const tableObjects: BatchObjectItem[] = tableRows .map((row: any) => Object.values(row)[0] as string) .filter((tableName: string) => !viewSet.has(tableName.toLowerCase())) .map((tableName: string) => ({ title: getSidebarTableDisplayName(conn, tableName), key: `${conn.id}-${dbName}-table-${tableName}`, objectName: tableName, objectType: 'table' as const, dataRef: { ...conn, tableName, dbName, objectType: 'table' }, })); const viewObjects: BatchObjectItem[] = viewRows.map((view) => { const keyName = buildSidebarObjectKeyName(dbName, view.schemaName, view.viewName); return { title: getSidebarTableDisplayName(conn, view.viewName), key: `${conn.id}-${dbName}-view-${keyName}`, objectName: view.viewName, objectType: 'view' as const, dataRef: { ...conn, tableName: view.viewName, schemaName: view.schemaName, dbName, objectType: 'view' }, }; }); tableObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); viewObjects.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase())); setBatchTables([...tableObjects, ...viewObjects]); setCheckedTableKeys([]); }; const handleConnectionChange = async (connId: string) => { setSelectedConnection(connId); setSelectedDatabase(''); setBatchTables([]); setCheckedTableKeys([]); setBatchFilterKeyword(''); setBatchFilterType('all'); setBatchSelectionScope('filtered'); const conn = connections.find(c => c.id === connId); if (conn) { await loadDatabasesForBatch(conn); } }; const handleDatabaseChange = async (dbName: string) => { setSelectedDatabase(dbName); setBatchFilterKeyword(''); setBatchFilterType('all'); setBatchSelectionScope('filtered'); const conn = connections.find(c => c.id === selectedConnection); if (conn && dbName) { await loadTablesForBatch(conn, dbName); } }; const handleBatchExport = async (mode: BatchTableExportMode) => { const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key)); if (selectedObjects.length === 0) { message.warning(t('sidebar.message.select_object_required')); return; } setIsBatchModalOpen(false); const { conn, dbName } = batchDbContext; const objectNames = selectedObjects.map(t => t.objectName); const selectedViewCount = selectedObjects.filter(item => item.objectType === 'view').length; const loadingText = mode === 'backup' ? t('sidebar.message.backing_up_selected_objects', { count: objectNames.length }) : mode === 'dataOnly' ? t('sidebar.message.exporting_selected_object_data', { count: objectNames.length, format: 'INSERT' }) : t('sidebar.message.exporting_selected_object_schema', { count: objectNames.length }); const hide = message.loading(loadingText, 0); try { const app = (window as any).go.app.App; const res = mode === 'dataOnly' ? await app.ExportTablesDataSQL(normalizeConnConfig(conn.config), dbName, objectNames) : await app.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, objectNames, mode === 'backup'); hide(); if (res.success) { if (mode !== 'schema' && selectedViewCount > 0) { message.success(t('sidebar.message.export_success_skipped_views', { count: selectedViewCount })); } else { message.success(t('sidebar.message.export_success')); } } else if (res.message !== '已取消') { message.error(t('sidebar.message.export_failed', { error: res.message })); } } catch (e: any) { hide(); message.error(t('sidebar.message.export_failed', { error: e?.message || String(e) })); } }; const handleBatchClear = async () => { const selectedObjects = batchTables.filter(t => checkedTableKeys.includes(t.key)); if (selectedObjects.length === 0) { message.warning(t('sidebar.message.select_object_required')); return; } const { conn, dbName } = batchDbContext; const objectNames = selectedObjects.map(t => t.objectName); const ok = await new Promise((resolve) => { Modal.confirm({ title: t('sidebar.modal.confirm_clear_selected_tables.title'), content: t('sidebar.modal.confirm_clear_selected_tables.content', { connection: conn.name, database: dbName, }), okText: t('sidebar.action.continue'), cancelText: t('sidebar.action.cancel'), onOk: () => resolve(true), onCancel: () => resolve(false), }); }); if (!ok) return; setIsBatchModalOpen(false); const hide = message.loading(t('sidebar.message.clearing_selected_tables', { count: objectNames.length }), 0); const startTime = Date.now(); try { const app = (window as any).go.app.App; const res = await app.ClearTables(normalizeConnConfig(conn.config), dbName, objectNames); hide(); const duration = Date.now() - startTime; if (res.success) { message.success(t('sidebar.message.clear_success')); // 构造 SQL 日志 let logSql = `/* Clear Tables (${objectNames.length} tables) */\n`; if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) { logSql += res.data.executedSQLs.join(';\n') + ';'; } else { logSql += objectNames.map(name => name).join('; '); } addSqlLog({ id: Date.now().toString(), timestamp: Date.now(), sql: logSql, status: 'success', duration, message: res.message, dbName, affectedRows: res.data?.count || 0 }); } else if (res.message !== '已取消') { message.error(t('sidebar.message.clear_failed', { error: res.message })); // 记录失败的日志 let logSql = `/* Clear Tables (${objectNames.length} tables) - FAILED */\n`; if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) { logSql += res.data.executedSQLs.join(';\n') + ';'; } else { logSql += objectNames.map(name => name).join('; '); } addSqlLog({ id: Date.now().toString(), timestamp: Date.now(), sql: logSql, status: 'error', duration, message: res.message, dbName }); } } catch (e: any) { const duration = Date.now() - startTime; hide(); const errMsg = e?.message || String(e); message.error(t('sidebar.message.clear_failed', { error: errMsg })); // 记录异常的日志 let logSql = `/* Clear Tables (${objectNames.length} tables) - ERROR */\n`; logSql += objectNames.map(name => name).join('; '); addSqlLog({ id: Date.now().toString(), timestamp: Date.now(), sql: logSql, status: 'error', duration, message: errMsg, dbName }); } }; const handleCheckAll = (checked: boolean) => { if (batchSelectionScope === 'all') { setCheckedTableKeys(checked ? allBatchObjectKeys : []); return; } if (filteredBatchObjectKeys.length === 0) { return; } if (checked) { setCheckedTableKeys(prev => { const nextSet = new Set(prev); filteredBatchObjectKeys.forEach((key) => nextSet.add(key)); return allBatchObjectKeys.filter((key) => nextSet.has(key)); }); return; } const filteredKeySet = new Set(filteredBatchObjectKeys); setCheckedTableKeys(prev => prev.filter((key) => !filteredKeySet.has(key))); }; const handleInvertSelection = () => { if (batchSelectionScope === 'all') { setCheckedTableKeys(prev => allBatchObjectKeys.filter((key) => !prev.includes(key))); return; } if (filteredBatchObjectKeys.length === 0) { return; } setCheckedTableKeys(prev => { const nextSet = new Set(prev); filteredBatchObjectKeys.forEach((key) => { if (nextSet.has(key)) { nextSet.delete(key); } else { nextSet.add(key); } }); return allBatchObjectKeys.filter((key) => nextSet.has(key)); }); }; const openBatchDatabaseModal = async () => { // Check if current selected node is connection or database let connId = ''; if (selectedNodesRef.current.length > 0) { const node = selectedNodesRef.current[0]; if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { connId = node.key as string; } else if (node.type === 'database') { connId = node.dataRef.id; } else if (node.type === 'table') { connId = node.dataRef.id; } } setSelectedDbConnection(connId); setBatchDatabases([]); setCheckedDbKeys([]); if (connId) { const conn = connections.find(c => c.id === connId); if (conn) { await loadDatabasesForDbBatch(conn); } } setIsBatchDbModalOpen(true); }; const openBatchDatabaseExportWorkbench = () => { let connId = ''; if (selectedNodesRef.current.length > 0) { const node = selectedNodesRef.current[0]; if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') { connId = node.key as string; } else if (node.type === 'database' || node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { connId = node.dataRef.id; } } addTab(buildBatchDatabaseExportWorkbenchTab({ connectionId: connId, title: '批量导出库', })); }; const loadDatabasesForDbBatch = async (conn: SavedConnection) => { setBatchConnContext(conn); 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: "" } }; const res = await DBGetDatabases(buildRpcConnectionConfig(config) as any); if (res.success) { const dbRows: any[] = Array.isArray(res.data) ? res.data : []; let dbs = dbRows.map((row: any) => { const dbName = row.Database || row.database; return { title: dbName, key: `${conn.id}-${dbName}`, dbName: dbName, dataRef: { ...conn, dbName } }; }); if (conn.includeDatabases && conn.includeDatabases.length > 0) { dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName)); } setBatchDatabases(dbs); setCheckedDbKeys([]); } else { message.error(t('sidebar.message.load_database_list_failed', { error: res.message })); } }; const handleDbConnectionChange = async (connId: string) => { setSelectedDbConnection(connId); const conn = connections.find(c => c.id === connId); if (conn) { await loadDatabasesForDbBatch(conn); } }; const handleBatchDbExport = async (includeData: boolean) => { const selectedDbs = batchDatabases.filter(db => checkedDbKeys.includes(db.key)); if (selectedDbs.length === 0) { message.warning(t('sidebar.message.select_database_required')); return; } setIsBatchDbModalOpen(false); for (const db of selectedDbs) { const hide = message.loading( includeData ? t('sidebar.message.exporting_database_backup', { database: db.dbName }) : t('sidebar.message.exporting_database_schema', { database: db.dbName }), 0, ); try { const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(batchConnContext.config), db.dbName, includeData); hide(); if (res.success) { message.success(t('sidebar.message.database_export_success', { database: db.dbName })); } else if (res.message !== '已取消') { message.error(t('sidebar.message.database_export_failed', { database: db.dbName, error: res.message })); break; } else { break; // User cancelled } } catch (e: any) { hide(); message.error(t('sidebar.message.database_export_failed', { database: db.dbName, error: e?.message || String(e) })); break; } } }; const handleCheckAllDb = (checked: boolean) => { if (checked) { setCheckedDbKeys(batchDatabases.map(db => db.key)); } else { setCheckedDbKeys([]); } }; const handleInvertSelectionDb = () => { const allKeys = batchDatabases.map(db => db.key); const newChecked = allKeys.filter(k => !checkedDbKeys.includes(k)); setCheckedDbKeys(newChecked); }; const handleRunSQLFile = async (node: any) => { const res = await OpenSQLFile(); if (res.success) { const data = normalizeSQLFileDialogData(res.data); // 大文件:后端返回文件路径,走流式执行 if (data.isLargeFile) { const connId = node.type === 'connection' ? node.key : node.dataRef?.id; const dbName = node.dataRef?.dbName || ''; const conn = connections.find(c => c.id === connId); if (!conn) { message.error(t('sidebar.message.connection_config_not_found')); return; } startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB || ''); return; } // 小文件:加载到编辑器 const { dbName, id } = node.dataRef; const connectionId = node.type === 'connection' ? String(node.key) : String(id || node.dataRef.id || ''); addTab({ id: data.filePath ? buildExternalSQLTabId(connectionId, dbName || '', data.filePath) : `query-${Date.now()}`, title: data.fileName || t('sidebar.sql_file_exec.title'), type: 'query', connectionId, dbName: dbName, query: data.content, filePath: data.filePath || undefined, }); } else if (res.message !== '已取消') { message.error(t('sidebar.message.read_file_failed', { error: res.message })); } }; const handleOpenSQLFileFromToolbar = async () => { const ctx = useStore.getState().activeContext; if (!ctx?.connectionId) { message.warning(t('sidebar.message.select_connection_or_database_first')); return; } const res = await OpenSQLFile(); if (res.success) { const data = normalizeSQLFileDialogData(res.data); // 大文件:后端流式执行 if (data.isLargeFile) { const conn = connections.find(c => c.id === ctx.connectionId); if (!conn) { message.error(t('sidebar.message.connection_config_not_found')); return; } startSQLFileExecution(conn.config, ctx.dbName || '', data.filePath, data.fileSizeMB || ''); return; } // 小文件 addTab({ id: data.filePath ? buildExternalSQLTabId(ctx.connectionId, ctx.dbName || '', data.filePath) : `query-${Date.now()}`, title: data.fileName || t('sidebar.sql_file_exec.title'), type: 'query', connectionId: ctx.connectionId, dbName: ctx.dbName || undefined, query: data.content, filePath: data.filePath || undefined, }); } else if (res.message !== '已取消') { message.error(t('sidebar.message.read_file_failed', { error: res.message })); } }; // SQL 文件流式执行状态 const [sqlFileExecState, setSqlFileExecState] = useState<{ open: boolean; jobId: string; fileSizeMB: string; status: SQLFileExecutionStatus; executed: number; failed: number; total: number; percent: number; currentSQL: string; resultMessage: string; }>({ open: false, jobId: '', fileSizeMB: '', status: 'running', executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: '' }); const startSQLFileExecution = (config: any, dbName: string, filePath: string, fileSizeMB: string) => { const jobId = `sqlfile-${Date.now()}`; setSqlFileExecState({ open: true, jobId, fileSizeMB, status: 'running', executed: 0, failed: 0, total: 0, percent: 0, currentSQL: '', resultMessage: '' }); // 监听进度事件 const offProgress = EventsOn('sqlfile:progress', (event: any) => { if (!event || event.jobId !== jobId) return; setSqlFileExecState(prev => ({ ...prev, status: event.status || prev.status, executed: typeof event.executed === 'number' ? event.executed : prev.executed, failed: typeof event.failed === 'number' ? event.failed : prev.failed, total: typeof event.total === 'number' ? event.total : prev.total, percent: typeof event.percent === 'number' ? Math.min(100, event.percent) : prev.percent, currentSQL: typeof event.currentSQL === 'string' ? event.currentSQL : prev.currentSQL, })); }); // 异步执行 ExecuteSQLFile(config, dbName, filePath, jobId).then(res => { offProgress(); setSqlFileExecState(prev => ({ ...prev, status: res.success ? 'done' : (prev.status === 'cancelled' ? 'cancelled' : 'error'), percent: 100, resultMessage: res.message || '', })); }).catch(err => { offProgress(); setSqlFileExecState(prev => ({ ...prev, status: 'error', resultMessage: String(err?.message || err), })); }); }; const refreshDatabaseNode = async (dbNodeKey: string) => { if (!dbNodeKey) { return; } const dbNode = findTreeNodeByKey(treeData, dbNodeKey); if (dbNode && dbNode.type === 'database') { await loadTables(dbNode); } }; const normalizeExternalSQLFileName = (rawName: unknown): string => { const name = String(rawName || '').trim(); if (!name) return ''; return /\.sql$/i.test(name) ? name : `${name}.sql`; }; const normalizeExternalSQLDirectoryName = (rawName: unknown): string => { return String(rawName || '').trim(); }; const getExternalSQLParentDirectoryPath = (node: any): string => { const path = String(node?.dataRef?.path || '').trim(); if (node?.type === 'external-sql-directory' || node?.type === 'external-sql-folder') { return path; } if (node?.type === 'external-sql-file') { const index = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); return index > 0 ? path.slice(0, index) : ''; } return ''; }; const resolveExternalSQLExecutionContext = (): { connectionId: string; dbName: string } => { const activeStoreContext = useStore.getState().activeContext; const selectedConnectionId = selectedNodesRef.current .map((node) => resolveSidebarNodeConnectionId(node, connectionIds)) .find(Boolean) || ''; return { connectionId: String( activeStoreContext?.connectionId || activeTab?.connectionId || selectedConnectionId || '', ).trim(), dbName: String( activeStoreContext?.dbName || activeTab?.dbName || '', ).trim(), }; }; const normalizeSQLFileDialogData = (data: unknown): { content: string; filePath: string; fileName: string; isLargeFile: boolean; fileSizeMB?: string } => { if (data && typeof data === 'object') { const payload = data as Record; const filePath = String(payload.filePath || '').trim(); return { content: String(payload.content ?? ''), filePath, fileName: String(payload.name || filePath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_file_exec.title')).trim(), isLargeFile: payload.isLargeFile === true, fileSizeMB: String(payload.fileSizeMB || '').trim() || undefined, }; } return { content: String(data || ''), filePath: '', fileName: t('sidebar.sql_file_exec.title'), isLargeFile: false, }; }; const openExternalSQLFile = async (fileNode: any) => { const fileContext = { connectionId: String(fileNode?.dataRef?.connectionId || '').trim(), dbName: String(fileNode?.dataRef?.dbName || '').trim(), }; const fallbackContext = resolveExternalSQLExecutionContext(); const connectionId = fileContext.connectionId || fallbackContext.connectionId; const dbName = fileContext.dbName || fallbackContext.dbName; const filePath = String(fileNode?.dataRef?.path || '').trim(); const fileName = String(fileNode?.dataRef?.name || fileNode?.title || t('sidebar.sql_file.default_name')).trim() || t('sidebar.sql_file.default_name'); if (!filePath) { message.error(t('sidebar.message.sql_file_path_incomplete')); return; } const res = await ReadSQLFile(filePath); if (!res.success) { if (res.message !== '已取消') { message.error(t('sidebar.message.read_sql_file_failed', { error: res.message })); } return; } const data = res.data; if (data && typeof data === 'object' && data.isLargeFile) { if (!connectionId) { message.warning(t('sidebar.message.select_host_before_large_sql_file')); return; } const conn = connections.find((item) => item.id === connectionId); if (!conn) { message.error(t('sidebar.message.connection_config_not_found')); return; } startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB); return; } addTab({ id: buildExternalSQLTabId(connectionId, dbName, filePath), title: fileName, type: 'query', connectionId, dbName: dbName || undefined, query: String(data || ''), filePath, }); }; const openCreateExternalSQLFileModal = (node: any) => { const directoryPath = getExternalSQLParentDirectoryPath(node); if (!directoryPath) { message.error(t('sidebar.message.external_sql_file_parent_missing')); return; } setExternalSQLFileModalMode('create'); setExternalSQLFileTarget(node); externalSQLFileForm.setFieldsValue({ name: 'new-query.sql' }); setIsExternalSQLFileModalOpen(true); }; const openRenameExternalSQLFileModal = (node: any) => { const currentName = String(node?.dataRef?.name || node?.title || '').trim(); if (!currentName) { message.error(t('sidebar.message.external_sql_file_rename_target_missing')); return; } setExternalSQLFileModalMode('rename'); setExternalSQLFileTarget(node); externalSQLFileForm.setFieldsValue({ name: currentName }); setIsExternalSQLFileModalOpen(true); }; const openCreateExternalSQLDirectoryModal = (node: any) => { const directoryPath = getExternalSQLParentDirectoryPath(node); if (!directoryPath) { message.error(t('sidebar.message.external_sql_directory_parent_missing')); return; } setExternalSQLFileModalMode('create-directory'); setExternalSQLFileTarget(node); externalSQLFileForm.setFieldsValue({ name: 'new-folder' }); setIsExternalSQLFileModalOpen(true); }; const openRenameExternalSQLDirectoryModal = (node: any) => { const currentName = String(node?.dataRef?.name || node?.title || '').trim(); if (!currentName) { message.error(t('sidebar.message.external_sql_directory_rename_target_missing')); return; } setExternalSQLFileModalMode('rename-directory'); setExternalSQLFileTarget(node); externalSQLFileForm.setFieldsValue({ name: currentName }); setIsExternalSQLFileModalOpen(true); }; const handleExternalSQLFileModalOk = async () => { try { const values = await externalSQLFileForm.validateFields(); const isDirectoryMode = isExternalSQLDirectoryModalMode(externalSQLFileModalMode); const name = isDirectoryMode ? normalizeExternalSQLDirectoryName(values.name) : normalizeExternalSQLFileName(values.name); if (!name) { message.error(t(isDirectoryMode ? 'sidebar.message.sql_directory_name_required' : 'sidebar.message.sql_file_name_required')); return; } if (externalSQLFileModalMode === 'create') { const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); if (!directoryPath) { message.error(t('sidebar.message.external_sql_file_parent_missing')); return; } const res = await CreateSQLFile(directoryPath, name); if (!res.success) { message.error(t('sidebar.message.create_sql_file_failed', { error: res.message })); return; } await refreshGlobalExternalSQLRootNode(false); message.success(t('sidebar.message.sql_file_created')); } else if (externalSQLFileModalMode === 'rename') { const filePath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); if (!filePath) { message.error(t('sidebar.message.external_sql_file_rename_target_missing')); return; } const res = await RenameSQLFile(filePath, name); if (!res.success) { message.error(t('sidebar.message.rename_sql_file_failed', { error: res.message })); return; } await refreshGlobalExternalSQLRootNode(false); message.success(t('sidebar.message.sql_file_renamed')); } else if (externalSQLFileModalMode === 'create-directory') { const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); if (!directoryPath) { message.error(t('sidebar.message.external_sql_directory_parent_missing')); return; } const res = await CreateSQLDirectory(directoryPath, name); if (!res.success) { message.error(t('sidebar.message.create_sql_directory_failed', { error: res.message })); return; } await refreshGlobalExternalSQLRootNode(false); message.success(t('sidebar.message.sql_directory_created')); } else { const directoryPath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); if (!directoryPath) { message.error(t('sidebar.message.external_sql_directory_rename_target_missing')); return; } const res = await RenameSQLDirectory(directoryPath, name); if (!res.success) { message.error(t('sidebar.message.rename_sql_directory_failed', { error: res.message })); return; } if (externalSQLFileTarget?.type === 'external-sql-directory') { const payload = (res.data && typeof res.data === 'object') ? res.data as Record : {}; const nextPath = String(payload.directoryPath || payload.path || '').trim(); const nextName = String(payload.name || name).trim(); const oldDirectoryId = String(externalSQLFileTarget?.dataRef?.id || '').trim(); if (!nextPath || !oldDirectoryId) { message.error(t('sidebar.message.external_sql_directory_rename_sync_failed')); await refreshGlobalExternalSQLRootNode(false); return; } const nextDirectory: ExternalSQLDirectory = { id: buildExternalSQLDirectoryId('', '', nextPath), name: nextName || nextPath.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), path: nextPath, createdAt: Number(externalSQLFileTarget?.dataRef?.createdAt) || Date.now(), }; deleteExternalSQLDirectory(oldDirectoryId); saveExternalSQLDirectory(nextDirectory); const nextDirectories = [ ...externalSQLDirectories.filter((item) => item.id !== oldDirectoryId), nextDirectory, ]; await refreshGlobalExternalSQLRootNode(false, nextDirectories); } else { await refreshGlobalExternalSQLRootNode(false); } message.success(t('sidebar.message.sql_directory_renamed')); } setIsExternalSQLFileModalOpen(false); setExternalSQLFileTarget(null); externalSQLFileForm.resetFields(); } catch { // Validate failed } }; const handleDeleteExternalSQLFile = (node: any) => { const filePath = String(node?.dataRef?.path || '').trim(); const fileName = String(node?.dataRef?.name || node?.title || t('sidebar.sql_file.default_name')).trim(); if (!filePath) { message.error(t('sidebar.message.external_sql_file_delete_target_missing')); return; } Modal.confirm({ title: t('sidebar.modal.confirm_delete_sql_file.title'), content: t('sidebar.modal.confirm_delete_sql_file.content', { name: fileName }), okText: t('sidebar.action.delete'), cancelText: t('sidebar.action.cancel'), okButtonProps: { danger: true }, onOk: async () => { const res = await DeleteSQLFile(filePath); if (!res.success) { message.error(t('sidebar.message.delete_sql_file_failed', { error: res.message })); return; } await refreshGlobalExternalSQLRootNode(false); message.success(t('sidebar.message.sql_file_deleted')); }, }); }; const handleDeleteExternalSQLDirectory = (node: any) => { const directoryPath = String(node?.dataRef?.path || '').trim(); const directoryName = String(node?.dataRef?.name || node?.title || t('sidebar.sql_directory.default_name')).trim(); if (!directoryPath) { message.error(t('sidebar.message.external_sql_directory_delete_target_missing')); return; } Modal.confirm({ title: t('sidebar.modal.confirm_delete_sql_directory.title'), content: t('sidebar.modal.confirm_delete_sql_directory.content', { name: directoryName }), okText: t('sidebar.action.delete'), cancelText: t('sidebar.action.cancel'), okButtonProps: { danger: true }, onOk: async () => { const res = await DeleteSQLDirectory(directoryPath); if (!res.success) { message.error(t('sidebar.message.delete_sql_directory_failed', { error: res.message })); return; } if (node?.type === 'external-sql-directory') { const directoryId = String(node?.dataRef?.id || '').trim(); if (directoryId) { deleteExternalSQLDirectory(directoryId); const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); await refreshGlobalExternalSQLRootNode(false, nextDirectories); } else { await refreshGlobalExternalSQLRootNode(false); } } else { await refreshGlobalExternalSQLRootNode(false); } message.success(t('sidebar.message.sql_directory_deleted')); }, }); }; const handleAddExternalSQLDirectory = async (node: any) => { const currentDirectory = externalSQLDirectories[0]?.path || ''; const selection = await SelectSQLDirectory(currentDirectory); if (!selection.success) { if (selection.message !== '已取消') { message.error(t('sidebar.message.select_sql_directory_failed', { error: selection.message })); } return; } const payload = (selection.data && typeof selection.data === 'object') ? selection.data as Record : {}; const path = String(payload.path || '').trim(); const name = String(payload.name || '').trim(); if (!path) { message.error(t('sidebar.message.sql_directory_path_invalid')); return; } const directoryId = buildExternalSQLDirectoryId('', '', path); const nextDirectory: ExternalSQLDirectory = { id: directoryId, name: name || path.split(/[\\/]/).filter(Boolean).pop() || t('sidebar.sql_directory.default_name'), path, createdAt: Date.now(), }; saveExternalSQLDirectory(nextDirectory); const nextDirectories = [ ...externalSQLDirectories.filter((item) => item.path.replace(/\\/g, '/').toLowerCase() !== path.replace(/\\/g, '/').toLowerCase()), nextDirectory, ]; setExpandedKeys((prev) => Array.from(new Set([...prev, 'external-sql-root']))); setAutoExpandParent(false); await refreshGlobalExternalSQLRootNode(false, nextDirectories); message.success(t('sidebar.message.external_sql_directory_added')); }; const handleRemoveExternalSQLDirectory = async (node: any) => { const directoryId = String(node?.dataRef?.id || '').trim(); if (!directoryId) { message.error(t('sidebar.message.external_sql_directory_not_found')); return; } deleteExternalSQLDirectory(directoryId); const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); await refreshGlobalExternalSQLRootNode(false, nextDirectories); message.success(t('sidebar.message.external_sql_directory_removed')); }; const handleRefreshExternalSQLDirectory = async (node: any) => { void node; await refreshGlobalExternalSQLRootNode(true); message.success(t('sidebar.message.external_sql_directory_refreshed')); }; const handleCreateDatabase = async () => { try { const values = await createDbForm.validateFields(); const conn = targetConnection.dataRef; const config = { ...conn.config, port: Number(conn.config.port), password: conn.config.password || "", database: (conn.config.type === 'oracle' || conn.config.type === 'dameng') ? (conn.config.database || "") : "", useSSH: conn.config.useSSH || false, ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; const res = await CreateDatabase(buildRpcConnectionConfig(config) as any, values.name); if (res.success) { message.success("数据库创建成功"); setIsCreateDbModalOpen(false); createDbForm.resetFields(); // Refresh node loadDatabases(targetConnection); } else { message.error("创建失败: " + res.message); } } catch (e) { // Validate failed } }; const openCreateSchemaModal = (node: any) => { const dialect = getMetadataDialect(node?.dataRef as SavedConnection); if (!isPostgresSchemaDialect(dialect)) { message.warning(t('sidebar.message.schema_create_unsupported')); return; } setCreateSchemaTarget(node); createSchemaForm.resetFields(); setIsCreateSchemaModalOpen(true); }; const handleCreateSchema = async () => { try { const values = await createSchemaForm.validateFields(); const node = createSchemaTarget; const conn = node?.dataRef; const dbName = String(conn?.dbName || node?.title || '').trim(); if (!conn || !dbName) { message.error(t('sidebar.message.schema_target_missing')); return; } const res = await CreateSchema(buildRpcConnectionConfig(conn.config, { database: dbName }) as any, dbName, values.name); if (res.success) { message.success(t('sidebar.message.schema_created')); setIsCreateSchemaModalOpen(false); setCreateSchemaTarget(null); createSchemaForm.resetFields(); await loadTables(node); } else { message.error(t('sidebar.message.operation_create_failed', { error: res.message })); } } catch (e) { // Validate failed } }; const openRenameSchemaModal = (node: any) => { const dialect = getMetadataDialect(node?.dataRef as SavedConnection); const schemaName = String(node?.dataRef?.schemaName || '').trim(); if (!isPostgresSchemaDialect(dialect) || !schemaName) { message.warning('当前节点不支持通过此入口编辑模式'); return; } setRenameSchemaTarget(node); renameSchemaForm.setFieldsValue({ newName: schemaName }); setIsRenameSchemaModalOpen(true); }; const handleRenameSchema = async () => { try { const values = await renameSchemaForm.validateFields(); const node = renameSchemaTarget; const conn = node?.dataRef; const dbName = String(conn?.dbName || '').trim(); const oldSchemaName = String(conn?.schemaName || '').trim(); const newSchemaName = String(values?.newName || '').trim(); if (!conn || !dbName || !oldSchemaName || !newSchemaName) { message.error('未找到目标模式,无法编辑'); return; } if (oldSchemaName === newSchemaName) { message.warning('新旧模式名称相同,无需修改'); return; } const res = await (window as any).go.app.App.RenameSchema( buildRpcConnectionConfig(conn.config, { database: dbName }) as any, dbName, oldSchemaName, newSchemaName, ); if (res.success) { message.success('模式重命名成功'); const schemaKeyPrefix = `${conn.id}-${dbName}-schema-${oldSchemaName || 'default'}`; setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); await loadTables(getDatabaseNodeRef(conn, dbName)); setIsRenameSchemaModalOpen(false); setRenameSchemaTarget(null); renameSchemaForm.resetFields(); } else { message.error('编辑失败: ' + res.message); } } catch (e) { // Validate failed } }; const handleDeleteSchema = (node: any) => { const conn = node?.dataRef; const dbName = String(conn?.dbName || '').trim(); const schemaName = String(conn?.schemaName || '').trim(); if (!conn || !dbName || !schemaName) { message.error('未找到目标模式,无法删除'); return; } Modal.confirm({ title: '确认删除模式', content: `确定删除模式 "${schemaName}" 吗?这将删除该模式及其中所有对象,操作不可恢复。`, okButtonProps: { danger: true }, onOk: async () => { const res = await (window as any).go.app.App.DropSchema( buildRpcConnectionConfig(conn.config, { database: dbName }) as any, dbName, schemaName, ); if (res.success) { message.success('模式删除成功'); const schemaKeyPrefix = `${conn.id}-${dbName}-schema-${schemaName || 'default'}`; setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); await loadTables(getDatabaseNodeRef(conn, dbName)); } else { message.error('删除失败: ' + res.message); } } }); }; const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => { return buildRpcConnectionConfig(conn.config, { database: resolveSidebarRuntimeDatabase( conn?.config?.type, conn?.config?.driver, conn?.config?.database, overrideDatabase, clearDatabase, conn?.config?.oceanBaseProtocol, ), }); }; const buildJVMRuntimeConfig = (conn: SavedConnection & { dbName?: string }, providerMode: string) => { const sourceJVM = conn.config.jvm || {}; return buildRpcConnectionConfig(conn.config, { database: '', jvm: { ...sourceJVM, preferredMode: providerMode as 'jmx' | 'endpoint' | 'agent', allowedModes: [providerMode as 'jmx' | 'endpoint' | 'agent'], }, }); }; const openJVMOverviewTab = (conn: SavedConnection, providerMode: string) => { addTab({ id: `jvm-overview-${conn.id}-${providerMode}`, title: buildJVMTabTitle(conn.name, 'overview', providerMode), type: 'jvm-overview', connectionId: conn.id, providerMode: providerMode as 'jmx' | 'endpoint' | 'agent', }); }; const openJVMMonitoringTab = (conn: SavedConnection, providerMode: string) => { addTab({ id: `jvm-monitoring-${conn.id}-${providerMode}`, title: buildJVMTabTitle(conn.name, 'monitoring', providerMode), type: 'jvm-monitoring', connectionId: conn.id, providerMode: providerMode as 'jmx' | 'endpoint' | 'agent', }); }; const buildJVMDiagnosticTreeNodes = (conn: SavedConnection): TreeNode[] => { const descriptor = buildJVMDiagnosticActionDescriptor(conn.id, conn.config.jvm?.diagnostic); if (!descriptor) { return []; } return [{ title: descriptor.title, key: descriptor.key, icon: , type: 'jvm-diagnostic', dataRef: { ...conn, diagnosticTransport: descriptor.transport, }, isLeaf: true, }]; }; const openJVMResourceTab = (conn: SavedConnection, providerMode: string, resourcePath: string, resourceKind?: string) => { const trimmedResourcePath = String(resourcePath || '').trim(); addTab({ id: `jvm-resource-${conn.id}-${providerMode}-${encodeURIComponent(trimmedResourcePath)}`, title: trimmedResourcePath ? `${buildJVMTabTitle(conn.name, 'resource', providerMode)} · ${trimmedResourcePath}` : buildJVMTabTitle(conn.name, 'resource', providerMode), type: 'jvm-resource', connectionId: conn.id, providerMode: providerMode as 'jmx' | 'endpoint' | 'agent', resourcePath: trimmedResourcePath, resourceKind, }); }; const openJVMDiagnosticTab = (conn: SavedConnection) => { const transport = conn.config.jvm?.diagnostic?.transport || 'agent-bridge'; addTab({ id: `jvm-diagnostic-${conn.id}`, title: buildJVMTabTitle(conn.name, 'diagnostic', transport), type: 'jvm-diagnostic', connectionId: conn.id, }); }; const getConnectionNodeRef = (connRef: any) => { const latestConn = connections.find(c => c.id === connRef.id); return { key: connRef.id, dataRef: latestConn || connRef }; }; const getDatabaseNodeRef = (connRef: any, dbName: string) => { const latestConn = connections.find(c => c.id === connRef.id); return { key: `${connRef.id}-${dbName}`, dataRef: { ...(latestConn || connRef), dbName } }; }; const extractObjectName = (fullName: string) => { return splitQualifiedName(String(fullName || '').trim()).objectName || String(fullName || '').trim(); }; const handleRenameDatabase = async () => { if (!renameDbTarget) return; try { const values = await renameDbForm.validateFields(); const conn = renameDbTarget.dataRef; const oldDbName = String(conn.dbName || '').trim(); const newDbName = String(values.newName || '').trim(); if (!oldDbName || !newDbName) { message.error(t('sidebar.message.database_name_required')); return; } if (oldDbName === newDbName) { message.warning(t('sidebar.message.database_name_unchanged')); return; } const config = buildRuntimeConfig(conn, conn.dbName); const res = await RenameDatabase(buildRpcConnectionConfig(config) as any, oldDbName, newDbName); if (res.success) { message.success(t('sidebar.message.database_renamed')); setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`))); setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${oldDbName}`))); await loadDatabases(getConnectionNodeRef(conn)); setIsRenameDbModalOpen(false); setRenameDbTarget(null); renameDbForm.resetFields(); } else { message.error(t('sidebar.message.operation_rename_failed', { error: res.message })); } } catch (e) { // Validate failed } }; const handleDeleteDatabase = (node: any) => { const conn = node.dataRef; const dbName = String(conn.dbName || '').trim(); if (!dbName) return; Modal.confirm({ title: t('sidebar.modal.confirm_delete_database.title'), content: t('sidebar.modal.confirm_delete_database.content', { name: dbName }), okButtonProps: { danger: true }, onOk: async () => { const config = buildRuntimeConfig(conn, conn.dbName); const res = await DropDatabase(buildRpcConnectionConfig(config) as any, dbName); if (res.success) { message.success(t('sidebar.message.database_deleted')); closeTabsByDatabase(conn.id, dbName); setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`))); setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`))); await loadDatabases(getConnectionNodeRef(conn)); } else { message.error(t('sidebar.message.operation_drop_failed', { error: res.message })); } } }); }; const handleRenameTable = async () => { if (!renameTableTarget) return; try { const values = await renameTableForm.validateFields(); const conn = renameTableTarget.dataRef; const oldTableName = String(conn.tableName || '').trim(); const newTableName = String(values.newName || '').trim(); if (!oldTableName || !newTableName) { message.error("表名不能为空"); return; } if (extractObjectName(oldTableName) === newTableName || oldTableName === newTableName) { message.warning("新旧表名相同,无需修改"); return; } const config = buildRuntimeConfig(conn, conn.dbName); const res = await RenameTable(buildRpcConnectionConfig(config) as any, conn.dbName, oldTableName, newTableName); if (res.success) { message.success("表重命名成功"); await loadTables(getDatabaseNodeRef(conn, conn.dbName)); setIsRenameTableModalOpen(false); setRenameTableTarget(null); renameTableForm.resetFields(); } else { message.error("重命名失败: " + res.message); } } catch (e) { // Validate failed } }; const handleDeleteTable = (node: any) => { const conn = node.dataRef; const tableName = String(conn.tableName || '').trim(); if (!tableName) return; Modal.confirm({ title: '确认删除表', content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`, okButtonProps: { danger: true }, onOk: async () => { const config = buildRuntimeConfig(conn, conn.dbName); const res = await DropTable(buildRpcConnectionConfig(config) as any, conn.dbName, tableName); if (res.success) { message.success("表删除成功"); await loadTables(getDatabaseNodeRef(conn, conn.dbName)); } else { message.error("删除失败: " + res.message); } } }); }; const handleTableDataDangerAction = async (node: any, action: TableDataDangerActionKind) => { const conn = node.dataRef; const tableName = String(conn.tableName || '').trim(); if (!tableName) return; const { label, progressLabel } = getTableDataDangerActionMeta(action); const confirmed = await new Promise((resolve) => { Modal.confirm({ title: `确认${label}`, content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`, okText: '继续', cancelText: '取消', okButtonProps: { danger: true }, onOk: () => resolve(true), onCancel: () => resolve(false), }); }); if (!confirmed) return; const config = buildRuntimeConfig(conn, conn.dbName); const app = (window as any).go.app.App; const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables'; const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0); const startTime = Date.now(); try { const res = await app[methodName](buildRpcConnectionConfig(config) as any, conn.dbName, [tableName]); hide(); const duration = Date.now() - startTime; const executedSQLs = Array.isArray(res.data?.executedSQLs) ? res.data.executedSQLs : []; const logSql = executedSQLs.length > 0 ? executedSQLs.join(';\n') + ';' : `/* ${label} ${tableName} */`; if (res.success) { message.success(`${progressLabel}成功`); addSqlLog({ id: Date.now().toString(), timestamp: Date.now(), sql: logSql, status: 'success', duration, message: res.message, dbName: conn.dbName, affectedRows: res.data?.count || 0, }); await loadTables(getDatabaseNodeRef(conn, conn.dbName)); return; } addSqlLog({ id: Date.now().toString(), timestamp: Date.now(), sql: logSql, status: 'error', duration, message: res.message, dbName: conn.dbName, }); if (res.message !== '已取消') { message.error(`${progressLabel}失败: ${res.message}`); } } catch (e: any) { const duration = Date.now() - startTime; const errMsg = e?.message || String(e); hide(); addSqlLog({ id: Date.now().toString(), timestamp: Date.now(), sql: `/* ${label} ${tableName} - ERROR */`, status: 'error', duration, message: errMsg, dbName: conn.dbName, }); message.error(`${progressLabel}失败: ${errMsg}`); } }; // --- 视图操作 --- const openViewDefinition = (node: any) => { const { viewName, dbName, id, schemaName } = node.dataRef; const isMaterialized = node.type === 'materialized-view' || node.dataRef?.objectKind === 'materialized-view'; addTab({ id: `view-def-${id}-${dbName}-${viewName}`, title: t(isMaterialized ? 'sidebar.tab.materialized_view_definition' : 'sidebar.tab.view_definition', { name: viewName }), type: 'view-def', connectionId: id, dbName, viewName, viewKind: isMaterialized ? 'materialized' : 'view', schemaName, sidebarLocateKey: String(node.key || ''), }); }; const openEditView = async (node: any) => { const conn = node.dataRef; const { viewName, dbName, id } = conn; // 获取视图定义后打开查询编辑器 const dialect = getMetadataDialect(conn as SavedConnection); const sqlTemplateHeader = `-- ${t('sidebar.sql_template.edit_view', { name: viewName })}`; let template = `${sqlTemplateHeader}\n-- ${t('sidebar.sql_template.modify_then_execute')}\nCREATE OR REPLACE VIEW ${viewName} AS\nSELECT * FROM your_table;`; try { const config = buildRuntimeConfig(conn, dbName); let queries: string[] = []; switch (dialect) { case 'mysql': case 'starrocks': queries = [`SHOW CREATE VIEW \`${viewName.replace(/`/g, '``')}\``]; break; case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': { const parts = splitQualifiedName(viewName); const schema = parts.schemaName || 'public'; const name = parts.objectName || viewName; queries = [`SELECT pg_get_viewdef('${escapeSQLLiteral(schema)}.${escapeSQLLiteral(name)}'::regclass, true) AS view_definition`]; break; } case 'sqlserver': queries = buildSqlServerObjectDefinitionQueries('view', viewName, dbName, 'view_definition'); break; case 'sqlite': queries = [`SELECT sql AS view_definition FROM sqlite_master WHERE type='view' AND name='${escapeSQLLiteral(viewName)}'`]; break; case 'duckdb': { const parts = splitQualifiedName(viewName); const viewSchema = escapeSQLLiteral(parts.schemaName || 'main'); const viewObject = escapeSQLLiteral(parts.objectName || viewName); queries = [`SELECT view_definition FROM information_schema.views WHERE table_schema='${viewSchema}' AND table_name='${viewObject}' LIMIT 1`]; break; } } for (const query of queries) { const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, query); if (result.success && Array.isArray(result.data) && result.data.length > 0) { const row = result.data[0] as Record; const def = dialect === 'sqlserver' ? extractSqlServerDefinitionRows(result.data, ['view_definition', 'definition']) : row.view_definition || row.VIEW_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || ''; if (def) { if (dialect === 'mysql') { template = `${sqlTemplateHeader}\n${normalizeMySQLViewDDLForEditing(viewName, def)}`; } else if (dialect === 'sqlserver') { template = /^\s*create\s+view\b/i.test(String(def)) ? `${sqlTemplateHeader}\n${def}` : `${sqlTemplateHeader}\nCREATE VIEW ${viewName} AS\n${def}`; } else { template = `${sqlTemplateHeader}\nCREATE OR REPLACE VIEW ${viewName} AS\n${def}`; } break; } } } } catch { /* 降级使用模板 */ } addTab({ id: `query-edit-view-${Date.now()}`, title: t('sidebar.tab.edit_view', { name: viewName }), type: 'query', connectionId: id, dbName, query: template }); }; const openCreateView = (node: any) => { const conn = node.dataRef; const { dbName, id } = conn; const dialect = getMetadataDialect(conn as SavedConnection); let template: string; switch (dialect) { case 'mysql': case 'starrocks': template = `CREATE VIEW \`view_name\` AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; break; case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; break; case 'sqlserver': template = `CREATE VIEW dbo.view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; break; case 'oracle': case 'dm': template = `CREATE OR REPLACE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; break; case 'sqlite': case 'duckdb': template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; break; default: template = `CREATE VIEW view_name AS\nSELECT column1, column2\nFROM table_name\nWHERE condition;`; } addTab({ id: `query-create-view-${Date.now()}`, title: t('sidebar.tab.create_view'), type: 'query', connectionId: id, dbName, query: template }); }; const openCreateStarRocksMaterializedView = (node: any) => { const conn = node.dataRef; const { dbName, id } = conn; const schemaPrefix = String(conn.schemaName || dbName || '').trim(); const mvName = schemaPrefix ? `${schemaPrefix}.mv_name` : 'mv_name'; const template = buildStarRocksMaterializedViewPreviewSql({ name: mvName, query: 'SELECT\n column1,\n COUNT(*) AS cnt\nFROM table_name\nGROUP BY column1', distributionColumnNames: ['column1'], refreshClause: 'REFRESH ASYNC', properties: '"replication_num" = "1"', }); addTab({ id: `query-create-starrocks-mv-${Date.now()}`, title: t('sidebar.v2_database_menu.new_materialized_view'), type: 'query', connectionId: id, dbName, query: template, }); }; const openCreateStarRocksExternalCatalog = (node: any) => { const conn = node.dataRef; const { dbName, id } = conn; addTab({ id: `query-create-starrocks-catalog-${Date.now()}`, title: t('sidebar.v2_database_menu.new_external_catalog'), type: 'query', connectionId: id, dbName, query: `CREATE EXTERNAL CATALOG catalog_name\nPROPERTIES (\n "type" = "hive",\n "hive.metastore.uris" = "thrift://127.0.0.1:9083"\n);`, }); }; const openCreateStarRocksRollup = (node: any) => { const conn = node.dataRef; const { tableName, dbName, id } = conn; const safeTable = String(tableName || 'table_name').trim(); const safeTableParts = [splitQualifiedName(safeTable).schemaName, splitQualifiedName(safeTable).objectName].filter(Boolean); const quotedTable = safeTable.includes('`') ? safeTable : (safeTableParts.length > 0 ? safeTableParts : [safeTable]).map(part => `\`${part.replace(/`/g, '``')}\``).join('.'); addTab({ id: `query-create-starrocks-rollup-${Date.now()}`, title: '新增 Rollup', type: 'query', connectionId: id, dbName, query: `ALTER TABLE ${quotedTable}\nADD ROLLUP rollup_name (column1, column2);`, }); }; const handleDropView = (node: any) => { const conn = node.dataRef; const viewName = String(conn.viewName || '').trim(); if (!viewName) return; Modal.confirm({ title: '确认删除视图', content: `确定删除视图 "${viewName}" 吗?该操作不可恢复。`, okButtonProps: { danger: true }, onOk: async () => { const config = buildRuntimeConfig(conn, conn.dbName); const res = await DropView(buildRpcConnectionConfig(config) as any, conn.dbName, viewName); if (res.success) { message.success("视图删除成功"); await loadTables(getDatabaseNodeRef(conn, conn.dbName)); } else { message.error("删除失败: " + res.message); } } }); }; const handleRenameView = async () => { if (!renameViewTarget) return; try { const values = await renameViewForm.validateFields(); const conn = renameViewTarget.dataRef; const oldViewName = String(conn.viewName || '').trim(); const newViewName = String(values.newName || '').trim(); if (!oldViewName || !newViewName) { message.error("视图名称不能为空"); return; } if (extractObjectName(oldViewName) === newViewName || oldViewName === newViewName) { message.warning("新旧视图名相同,无需修改"); return; } const config = buildRuntimeConfig(conn, conn.dbName); const res = await RenameView(buildRpcConnectionConfig(config) as any, conn.dbName, oldViewName, newViewName); if (res.success) { message.success("视图重命名成功"); await loadTables(getDatabaseNodeRef(conn, conn.dbName)); setIsRenameViewModalOpen(false); setRenameViewTarget(null); renameViewForm.resetFields(); } else { message.error("重命名失败: " + res.message); } } catch (e) { // Validate failed } }; const openRenameSavedQueryModal = (query: SavedQuery) => { setRenameSavedQueryTarget(query); renameSavedQueryForm.setFieldsValue({ name: query.name || t('query_editor.save_modal.unnamed') }); setIsRenameSavedQueryModalOpen(true); }; const resolveSavedQueryDisplayName = (name: string | null | undefined) => { const rawName = String(name || '').trim(); return rawName || t('query_editor.save_modal.unnamed'); }; const handleRenameSavedQuery = async () => { if (!renameSavedQueryTarget) return; try { const values = await renameSavedQueryForm.validateFields(); const nextName = String(values.name || '').trim(); if (!nextName) { message.error(t('query_editor.save_modal.name_required')); return; } if (nextName === renameSavedQueryTarget.name) { message.warning(t('sidebar.message.saved_query_name_unchanged')); return; } const persisted = await saveQuery({ ...renameSavedQueryTarget, name: nextName, }); const updateSavedQueryNode = (list: TreeNode[]): TreeNode[] => list.map(node => { if (node.type === 'saved-query' && node.dataRef?.id === renameSavedQueryTarget.id) { return { ...node, title: persisted.name, dataRef: { ...(node.dataRef || renameSavedQueryTarget), ...persisted }, }; } return node.children ? { ...node, children: updateSavedQueryNode(node.children) } : node; }); const nextTreeData = updateSavedQueryNode(treeDataRef.current); treeDataRef.current = nextTreeData; setTreeData(nextTreeData); tabs .filter(tab => tab.type === 'query' && (tab.savedQueryId === renameSavedQueryTarget.id || tab.id === renameSavedQueryTarget.id)) .forEach(tab => updateQueryTabDraft(tab.id, { title: persisted.name })); message.success(t('sidebar.message.saved_query_renamed')); setIsRenameSavedQueryModalOpen(false); setRenameSavedQueryTarget(null); renameSavedQueryForm.resetFields(); } catch (e) { if (e instanceof Error) { message.error('重命名查询失败: ' + e.message); } } }; const isSavedQueryUnmatched = useCallback((query: SavedQuery): boolean => { return query.bindingStatus === 'orphan' || !connectionIdSet.has(query.connectionId); }, [connectionIdSet]); const handleRebindSavedQuery = useCallback(async (query: SavedQuery, target: SavedConnection) => { if (!query?.id || !target?.id) return; try { const backendApp = (window as any).go?.app?.App; let persisted: SavedQuery; if (typeof backendApp?.RebindSavedQuery === 'function') { persisted = await backendApp.RebindSavedQuery(query.id, target.id); await saveQuery(persisted); } else { persisted = await saveQuery({ ...query, connectionId: target.id, originalConnectionId: query.originalConnectionId || query.connectionId, bindingStatus: 'active', }); } message.success(`查询已绑定到 ${target.name || target.id}`); tabs .filter(tab => tab.type === 'query' && (tab.savedQueryId === query.id || tab.id === query.id)) .forEach(tab => updateQueryTabDraft(tab.id, { title: persisted.name, connectionId: persisted.connectionId, dbName: persisted.dbName, })); } catch (error) { message.error('绑定查询失败: ' + (error instanceof Error ? error.message : String(error))); } }, [saveQuery, tabs, updateQueryTabDraft]); // --- 函数/存储过程操作 --- const openRoutineDefinition = (node: any) => { const { routineName, routineType, dbName, id } = node.dataRef; const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'); addTab({ id: `routine-def-${id}-${dbName}-${routineName}`, title: t('sidebar.tab.routine_definition', { type: typeLabel, name: routineName }), type: 'routine-def', connectionId: id, dbName, routineName, routineType }); }; const openEventDefinition = (node: any) => { const { eventName, dbName, id } = node.dataRef; addTab({ id: `event-def-${id}-${dbName}-${eventName}`, title: t('sidebar.tab.event', { name: eventName }), type: 'event-def', connectionId: id, dbName, eventName, }); }; const openEditRoutine = async (node: any) => { const conn = node.dataRef; const { routineName, routineType, dbName, id } = conn; const dialect = getMetadataDialect(conn as SavedConnection); const tabTypeKey = routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'; const tabTypeLabel = t(tabTypeKey); const sqlTemplateHeader = `-- ${t('sidebar.sql_template.edit_routine', { type: tabTypeLabel, name: routineName })}`; let template = sqlTemplateHeader; try { const config = buildRuntimeConfig(conn, dbName); let query = ''; const parsedRoutine = splitQualifiedName(routineName); const name = parsedRoutine.objectName || routineName; const schema = parsedRoutine.schemaName; switch (dialect) { case 'mysql': case 'starrocks': query = `SHOW CREATE ${routineType} \`${name.replace(/`/g, '``')}\``; break; case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': { const schemaRef = schema || 'public'; query = `SELECT pg_get_functiondef(p.oid) AS routine_definition FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE n.nspname = '${escapeSQLLiteral(schemaRef)}' AND p.proname = '${escapeSQLLiteral(name)}' LIMIT 1`; break; } case 'sqlserver': query = ''; break; case 'oracle': case 'dm': { const owner = schema ? escapeSQLLiteral(schema).toUpperCase() : ''; if (owner) { query = `SELECT TEXT FROM ALL_SOURCE WHERE OWNER = '${owner}' AND NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`; } else { query = `SELECT TEXT FROM USER_SOURCE WHERE NAME = '${escapeSQLLiteral(name).toUpperCase()}' AND TYPE = '${routineType}' ORDER BY LINE`; } break; } case 'duckdb': { const schemaRef = schema || 'main'; query = `SELECT schema_name, function_name, parameters, macro_definition FROM duckdb_functions() WHERE internal = false AND lower(function_type) = 'macro' AND schema_name = '${escapeSQLLiteral(schemaRef)}' AND function_name = '${escapeSQLLiteral(name)}' LIMIT 1`; break; } } const queries = dialect === 'sqlserver' ? buildSqlServerObjectDefinitionQueries('routine', routineName, dbName, 'routine_definition') : [query].filter(Boolean); for (const queryText of queries) { const result = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, queryText); if (result.success && Array.isArray(result.data) && result.data.length > 0) { if (dialect === 'oracle' || dialect === 'dm') { const lines = result.data.map((row: any) => row.text || row.TEXT || Object.values(row)[0] || '').join(''); if (lines) { template = `${sqlTemplateHeader}\nCREATE OR REPLACE ${lines}`; break; } } else if (dialect === 'duckdb') { const row = result.data[0] as Record; const ddl = buildDuckDBMacroDDL( String(getCaseInsensitiveRawValue(row, ['schema_name']) || schema || '').trim(), String(getCaseInsensitiveRawValue(row, ['function_name']) || name || '').trim(), getCaseInsensitiveRawValue(row, ['parameters']), getCaseInsensitiveRawValue(row, ['macro_definition']) ); if (ddl) { template = `${sqlTemplateHeader}\n${ddl}`; break; } } else { const row = result.data[0] as Record; const def = dialect === 'sqlserver' ? extractSqlServerDefinitionRows(result.data, ['routine_definition', 'definition']) : row.routine_definition || row.ROUTINE_DEFINITION || Object.values(row).find(v => typeof v === 'string' && String(v).length > 10) || ''; if (def) { template = `${sqlTemplateHeader}\n${def}`; break; } } } } } catch { /* 降级使用模板 */ } addTab({ id: `query-edit-routine-${Date.now()}`, title: t('sidebar.tab.edit_routine', { type: tabTypeLabel, name: routineName }), type: 'query', connectionId: id, dbName, query: template }); }; const openCreateRoutine = (node: any, type: 'FUNCTION' | 'PROCEDURE') => { const conn = node.dataRef; const { dbName, id } = conn; const dialect = getMetadataDialect(conn as SavedConnection); const isProc = type === 'PROCEDURE'; let template: string; switch (dialect) { case 'mysql': case 'starrocks': template = isProc ? `DELIMITER $$\nCREATE PROCEDURE proc_name(IN param1 INT)\nBEGIN\n SELECT * FROM table_name WHERE id = param1;\nEND$$\nDELIMITER ;` : `DELIMITER $$\nCREATE FUNCTION func_name(param1 INT)\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN param1 * 2;\nEND$$\nDELIMITER ;`; break; case 'postgres': case 'kingbase': case 'highgo': case 'vastbase': case 'opengauss': case 'gaussdb': template = isProc ? `CREATE OR REPLACE PROCEDURE proc_name(param1 integer)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n -- procedure body\nEND;\n$$;` : `CREATE OR REPLACE FUNCTION func_name(param1 integer)\nRETURNS integer\nLANGUAGE plpgsql\nAS $$\nBEGIN\n RETURN param1 * 2;\nEND;\n$$;`; break; case 'sqlserver': template = isProc ? `CREATE PROCEDURE dbo.proc_name\n @param1 INT\nAS\nBEGIN\n SELECT * FROM table_name WHERE id = @param1;\nEND;` : `CREATE FUNCTION dbo.func_name(@param1 INT)\nRETURNS INT\nAS\nBEGIN\n RETURN @param1 * 2;\nEND;`; break; case 'oracle': case 'dm': template = isProc ? `CREATE OR REPLACE PROCEDURE proc_name(param1 IN NUMBER)\nIS\nBEGIN\n -- procedure body\n NULL;\nEND;` : `CREATE OR REPLACE FUNCTION func_name(param1 IN NUMBER)\nRETURN NUMBER\nIS\nBEGIN\n RETURN param1 * 2;\nEND;`; break; case 'duckdb': template = isProc ? `-- ${t('sidebar.sql_template.duckdb_procedure_unsupported')}\n-- ${t('sidebar.sql_template.duckdb_macro_hint')}\nCREATE MACRO func_name(param1) AS (param1 * 2);` : `CREATE MACRO func_name(param1) AS (param1 * 2);`; break; default: template = isProc ? `CREATE PROCEDURE proc_name()\nBEGIN\n -- procedure body\nEND;` : `CREATE FUNCTION func_name()\nRETURNS INTEGER\nBEGIN\n RETURN 0;\nEND;`; } addTab({ id: `query-create-routine-${Date.now()}`, title: isProc ? t('sidebar.tab.create_procedure') : t('sidebar.tab.create_function'), type: 'query', connectionId: id, dbName, query: template }); }; const handleDropRoutine = (node: any) => { const conn = node.dataRef; const routineName = String(conn.routineName || '').trim(); const routineType = String(conn.routineType || 'FUNCTION').trim(); if (!routineName) return; const typeLabel = t(routineType === 'PROCEDURE' ? 'sidebar.object.procedure' : 'sidebar.object.function'); Modal.confirm({ title: t('sidebar.modal.confirm_delete_routine.title', { type: typeLabel }), content: t('sidebar.modal.confirm_delete_routine.content', { type: typeLabel, name: routineName }), okButtonProps: { danger: true }, onOk: async () => { const config = buildRuntimeConfig(conn, conn.dbName); const res = await DropFunction(buildRpcConnectionConfig(config) as any, conn.dbName, routineName, routineType); if (res.success) { message.success(t('sidebar.message.routine_deleted', { type: typeLabel })); await loadTables(getDatabaseNodeRef(conn, conn.dbName)); } else { message.error(t('sidebar.message.delete_failed', { error: res.message })); } } }); }; const resolveMessagePublishTarget = (node: any): SidebarMessagePublishTarget | null => { const connectionId = String(node?.dataRef?.id || '').trim(); const liveConnection = connections.find((item) => item.id === connectionId); const sourceConnection = (liveConnection || node?.dataRef) as SavedConnection | undefined; if (!sourceConnection?.config) return null; const capabilities = getDataSourceCapabilities(sourceConnection.config); if (!capabilities.supportsMessagePublish) return null; return { connection: sourceConnection, executionDbName: String(node?.dataRef?.dbName || ''), destination: String(node?.dataRef?.tableName || node?.title || '').trim(), }; }; const openMessagePublishModal = (node: any) => { const target = resolveMessagePublishTarget(node); if (!target) { message.warning('当前对象不支持测试发送消息'); return; } setMessagePublishTarget(target); }; const handleMessagePublishSuccess = (result: { destination: string; affectedRows: number }) => { const destination = String(result.destination || '').trim(); const suffix = result.affectedRows > 0 ? `(已提交 ${result.affectedRows} 条)` : ''; message.success(`测试消息已发送到 ${destination || '目标'}${suffix}`); setMessagePublishTarget(null); }; const handleV2TableContextMenuAction = (node: any, action: V2TableContextMenuActionKey) => { switch (action) { case 'pin-table': case 'unpin-table': { toggleSidebarTablePinned(node, action === 'pin-table'); return; } case 'open-data': case 'open-new-tab': onDoubleClick(null, node); return; case 'design-table': openDesign(node, 'columns', false); return; case 'new-query': { const tableName = String(node.dataRef?.tableName || '').trim(); const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName); addTab({ id: `query-${Date.now()}`, title: t('query.new'), type: 'query', connectionId: node.dataRef.id, dbName: node.dataRef.dbName, query: queryTemplate }); return; } case 'publish-message': openMessagePublishModal(node); return; case 'view-ddl': openTableDdlInDesigner(node); return; case 'view-er': openTableInERView(node); return; case 'copy-table-name': void handleCopyTableName(node); return; case 'copy-structure': void handleCopyStructure(node); return; case 'copy-insert': void handleCopyTableAsInsert(node); return; case 'rename-table': setRenameTableTarget(node); renameTableForm.setFieldsValue({ newName: extractObjectName(node.dataRef?.tableName || node.title) }); setIsRenameTableModalOpen(true); return; case 'new-rollup': openCreateStarRocksRollup(node); return; case 'backup-table': void handleExport(node, { format: 'sql' }); return; case 'refresh-stats': refreshV2TableContextMenuStats(node); return; case 'export-data': void openExportDialog(node); return; case 'ai-explain': void injectTablePromptToAI(node, 'explain'); return; case 'ai-generate-query': void injectTablePromptToAI(node, 'query'); return; case 'truncate-table': void handleTableDataDangerAction(node, 'truncate'); return; case 'drop-table': handleDeleteTable(node); return; default: return; } }; const toggleSidebarTablePinned = (node: any, pinned?: boolean) => { const conn = node?.dataRef || {}; const tableName = String(conn.tableName || node?.title || '').trim(); const dbName = String(conn.dbName || '').trim(); if (!conn.id || !dbName || !tableName) return; const currentlyPinned = isSidebarTablePinned( pinnedSidebarTables, String(conn.id || ''), dbName, tableName, String(conn.schemaName || ''), ); const shouldPin = pinned ?? !currentlyPinned; setSidebarTablePinned(conn.id, dbName, tableName, conn.schemaName || '', shouldPin); void loadTables(getDatabaseNodeRef(conn, dbName)); message.success(shouldPin ? t('sidebar.message.table_pinned') : t('sidebar.message.table_unpinned')); }; const handleTableGroupSortAction = (node: any, sortBy: 'name' | 'frequency') => { const groupData = node.dataRef; setTableSortPreference(groupData.id, groupData.dbName, sortBy); const dbNode = { key: `${groupData.id}-${groupData.dbName}`, dataRef: groupData }; loadTables(dbNode); }; const handleV2TableGroupContextMenuAction = (node: any, action: V2TableGroupContextMenuActionKey) => { switch (action) { case 'new-table': openNewTableDesign(node); return; case 'sort-by-name': handleTableGroupSortAction(node, 'name'); return; case 'sort-by-frequency': handleTableGroupSortAction(node, 'frequency'); return; default: return; } }; const closeDatabaseNode = (node: any) => { const dbConnId = String(node.dataRef?.id || ''); const dbName = String(node.dataRef?.dbName || node.title || '').trim(); loadingNodesRef.current.delete(`tables-${dbConnId}-${dbName}`); setConnectionStates(prev => { const next = { ...prev }; delete next[node.key]; return next; }); setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`))); setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`))); replaceTreeNodeChildren(node.key, undefined); if (dbConnId && dbName) { closeTabsByDatabase(dbConnId, dbName); } message.success(t('sidebar.message.database_closed')); }; const openDatabaseQuery = (node: any) => { addTab({ id: `query-${Date.now()}`, title: t('sidebar.tab.new_query_database', { database: node.title }), type: 'query', connectionId: node.dataRef.id, dbName: node.title, query: '' }); }; const handleV2DatabaseContextMenuAction = (node: any, action: V2DatabaseContextMenuActionKey) => { switch (action) { case 'new-table': openNewTableDesign(node); return; case 'new-schema': openCreateSchemaModal(node); return; case 'new-materialized-view': openCreateStarRocksMaterializedView(node); return; case 'new-external-catalog': openCreateStarRocksExternalCatalog(node); return; case 'rename-db': setRenameDbTarget(node); renameDbForm.setFieldsValue({ newName: node.dataRef?.dbName || '' }); setIsRenameDbModalOpen(true); return; case 'refresh': loadTables(node); return; case 'export-db-schema': void handleExportDatabaseSQL(node, false); return; case 'backup-db-sql': void handleExportDatabaseSQL(node, true); return; case 'disconnect-db': closeDatabaseNode(node); return; case 'new-query': openDatabaseQuery(node); return; case 'run-sql': handleRunSQLFile(node); return; case 'drop-db': handleDeleteDatabase(node); return; default: return; } }; const refreshConnectionNode = (node: any) => { const connKey = String(node?.key || node?.dataRef?.id || ''); if (!connKey) return; setExpandedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`))); setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`))); Array.from(loadingNodesRef.current).forEach((loadingKey) => { if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) { loadingNodesRef.current.delete(loadingKey); } }); loadDatabases(node); }; const releaseConnectionResources = async (conn: SavedConnection | undefined) => { if (!conn?.config) return; const res = await DBReleaseConnection(buildRpcConnectionConfig(conn.config, { id: conn.id }) as any); if (res && res.success === false) { throw new Error(res.message || '释放连接失败'); } }; const disconnectConnectionNode = async (node: any) => { const connKey = String(node?.key || node?.dataRef?.id || ''); if (!connKey) return; const conn = (connections.find((item) => item.id === connKey) || node?.dataRef) as SavedConnection | undefined; Array.from(loadingNodesRef.current).forEach((loadingKey) => { if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) { loadingNodesRef.current.delete(loadingKey); } }); setConnectionStates(prev => { const next = { ...prev }; Object.keys(next).forEach(k => { if (k === connKey || k.startsWith(`${connKey}-`)) { delete next[k]; } }); return next; }); setExpandedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`))); setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`))); replaceTreeNodeChildren(connKey, undefined); closeTabsByConnection(connKey); try { await releaseConnectionResources(conn); } catch (error: any) { message.warning(error?.message || '连接已从侧边栏断开,但后端连接释放失败'); } message.success(t('connection.sidebar.disconnect.success')); }; const deleteConnectionNode = (node: any) => { Modal.confirm({ title: t('connection.sidebar.delete.confirmTitle'), content: t('connection.sidebar.delete.confirmContent', { name: node.title }), onOk: async () => { const connId = String(node.key); const backendApp = (window as any).go?.app?.App; if (typeof backendApp?.DeleteConnection !== 'function') { message.error(t('connection.sidebar.delete.backendUnavailable')); throw new Error('DeleteConnection unavailable'); } try { await backendApp.DeleteConnection(connId); closeTabsByConnection(connId); removeConnection(connId); message.success(t('connection.sidebar.delete.success')); } catch (error: any) { message.error(error?.message || t('connection.sidebar.delete.failureFallback')); throw error; } } }); }; const createConnectionTreeNode = (conn: SavedConnection): TreeNode => ({ title: conn.name, key: conn.id, icon: getDbIcon(resolveConnectionIconType(conn), resolveConnectionAccentColor(conn), 22), type: 'connection', dataRef: conn, isLeaf: false, }); const getConnectionNodeForAction = (conn: SavedConnection): TreeNode => { return findTreeNodeByKeyRef.current(treeDataRef.current, conn.id) || createConnectionTreeNode(conn); }; const handleV2ConnectionContextMenuAction = (node: any, action: V2ConnectionContextMenuActionKey) => { const connId = String(node?.key || node?.dataRef?.id || ''); if (!connId) return; switch (action) { case 'new-db': setTargetConnection(node); setIsCreateDbModalOpen(true); return; case 'refresh': refreshConnectionNode(node); return; case 'new-query': addTab({ id: `query-${Date.now()}`, title: buildConnectionRootQueryTabTitle(), type: 'query', connectionId: connId, dbName: undefined, query: '' }); return; case 'open-sql-file': handleRunSQLFile(node); return; case 'new-command': addTab({ id: `redis-cmd-${connId}-${Date.now()}`, title: buildConnectionRootRedisCommandTabTitle(), type: 'redis-command', connectionId: connId, redisDB: 0 }); return; case 'open-monitor': addTab({ id: `redis-monitor-${connId}-${Date.now()}`, title: buildConnectionRootRedisMonitorTabTitle(), type: 'redis-monitor', connectionId: connId, redisDB: 0 }); return; case 'edit': if (onEditConnection) onEditConnection(node.dataRef); return; case 'copy-connection': void handleDuplicateConnection(node.dataRef as SavedConnection); return; case 'disconnect': void disconnectConnectionNode(node); return; case 'delete': deleteConnectionNode(node); return; case 'move-to-ungrouped': moveConnectionToTag(connId, null); return; default: if (action.startsWith('move-to-tag:')) { moveConnectionToTag(connId, action.slice('move-to-tag:'.length)); } } }; const handleV2ConnectionGroupContextMenuAction = (group: V2RailConnectionGroup, action: V2ConnectionGroupContextMenuActionKey) => { const tag = connectionTags.find((item) => item.id === group.id); if (!tag) return; if (action === 'edit-group') { createTagForm.setFieldsValue({ name: tag.name, connectionIds: tag.connectionIds }); setRenameViewTarget({ title: tag.name, key: `tag-${tag.id}`, type: 'tag', dataRef: tag, }); setIsCreateTagModalOpen(true); return; } if (action === 'delete-group') { Modal.confirm({ title: t('connection.sidebar.group.deleteConfirmTitle'), content: t('connection.sidebar.group.deleteConfirmContent', { name: tag.name }), onOk: () => { removeConnectionTag(tag.id); }, }); } }; const onSearch = (e: React.ChangeEvent) => { const { value } = e.target; setSearchValue(value); }; const toggleSearchScope = (scope: SearchScope) => { setSearchScopes((prev) => { if (scope === 'smart') { return ['smart']; } const withoutSmart = prev.filter((item) => item !== 'smart'); if (withoutSmart.includes(scope)) { const next = withoutSmart.filter((item) => item !== scope); return next.length > 0 ? next : ['smart']; } return [...withoutSmart, scope]; }); }; const setSearchScopeChecked = (scope: SearchScope, checked: boolean) => { if (scope === 'smart') { if (checked) { setSearchScopes(['smart']); } else if (searchScopes.length === 1 && searchScopes[0] === 'smart') { setSearchScopes(['smart']); } else { setSearchScopes((prev) => { const next = prev.filter((item) => item !== 'smart'); return next.length > 0 ? next : ['smart']; }); } return; } if (checked) { setSearchScopes((prev) => { const withoutSmart = prev.filter((item) => item !== 'smart'); if (withoutSmart.includes(scope)) { return withoutSmart; } return [...withoutSmart, scope]; }); } else { setSearchScopes((prev) => { const next = prev.filter((item) => item !== scope && item !== 'smart'); return next.length > 0 ? next : ['smart']; }); } }; const currentLanguage = getCurrentLanguage(); const searchScopeSummary = useMemo(() => { if (searchScopes.includes('smart')) { return t('sidebar.command_search.scope.summary_smart'); } return searchScopes.map((scope) => t(SEARCH_SCOPE_LABEL_KEY_MAP[scope])).join(' + '); }, [searchScopes, currentLanguage]); const searchScopePopoverContent = useMemo(() => { const smartSelected = searchScopes.includes('smart'); const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart'); const borderColor = overlayTheme.sectionBorder.replace('1px solid ', ''); const mutedTextColor = overlayTheme.mutedText; const titleColor = overlayTheme.titleText; const panelBg = overlayTheme.shellBg; const smartBg = smartSelected ? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)') : (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)'); const smartBorder = smartSelected ? (darkMode ? 'rgba(255,214,102,0.42)' : 'rgba(245,176,65,0.34)') : borderColor; const getOptionCardStyle = (checked: boolean) => ({ display: 'flex', alignItems: 'center' as const, justifyContent: 'space-between' as const, gap: 12, padding: '10px 12px', borderRadius: 12, border: `1px solid ${checked ? (darkMode ? 'rgba(118,169,250,0.44)' : 'rgba(24,144,255,0.32)') : borderColor}`, background: checked ? (darkMode ? 'rgba(64,124,255,0.18)' : 'rgba(24,144,255,0.08)') : (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)'), transition: 'all 120ms ease', }); return (
{t('sidebar.command_search.scope.title')}
{t('sidebar.command_search.scope.description')}
{t('sidebar.command_search.scope.manual_title')}
{t('sidebar.command_search.scope.multi_select')}
{scopedOptions.map((option) => { const checked = searchScopes.includes(option.value); return ( ); })}
{t('sidebar.command_search.scope.manual_help')}
); }, [darkMode, overlayTheme, searchScopes, currentLanguage]); const getConnectionHostSearchText = (node: TreeNode): string => { if (node.type !== 'connection') return ''; const config = node.dataRef?.config || {}; return resolveConnectionHostTokens(config).join(' '); }; const getConnectionNameSearchText = (node: TreeNode): string => { if (node.type !== 'connection') return ''; const name = node.dataRef?.name ?? node.title; return String(name || '').toLowerCase(); }; const matchByScopes = (node: TreeNode, keyword: string, scopes: SearchScope[]): boolean => { const title = String(node.title || '').toLowerCase(); if (scopes.includes('database') && node.type === 'database' && title.includes(keyword)) { return true; } if (scopes.includes('tag') && node.type === 'tag' && title.includes(keyword)) { return true; } if (scopes.includes('host') && node.type === 'connection' && getConnectionHostSearchText(node).includes(keyword)) { return true; } if (scopes.includes('object') && (isV2SidebarObjectNode(node) || node.type === 'object-group') && title.includes(keyword)) { return true; } if (node.type === 'external-sql-root' || node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') { const pathText = String(node?.dataRef?.path || '').toLowerCase(); return title.includes(keyword) || pathText.includes(keyword); } return false; }; const loop = (data: TreeNode[], keyword: string): TreeNode[] => { const isSmartMode = searchScopes.includes('smart'); const result: TreeNode[] = []; data.forEach((item) => { const titleMatch = String(item.title || '').toLowerCase().includes(keyword); const smartMatch = item.type === 'connection' ? getConnectionNameSearchText(item).includes(keyword) || getConnectionHostSearchText(item).includes(keyword) : titleMatch; const scopedMatch = matchByScopes(item, keyword, searchScopes); const selfMatch = isSmartMode ? smartMatch : scopedMatch; const filteredChildren = item.children ? loop(item.children, keyword) : []; if (selfMatch) { const shouldKeepFullSubtree = isSmartMode || item.type === 'connection' || item.type === 'database' || item.type === 'tag' || item.type === 'external-sql-root' || item.type === 'external-sql-directory' || item.type === 'external-sql-folder'; if (item.children && shouldKeepFullSubtree) { result.push(item); } else if (item.children && filteredChildren.length > 0) { result.push({ ...item, children: filteredChildren }); } else { result.push(item); } return; } if (filteredChildren.length > 0) { result.push({ ...item, children: filteredChildren }); } }); return result; }; const displayTreeData = useMemo(() => { const keyword = deferredSearchValue.trim().toLowerCase(); if (!keyword) return treeData; return loop(treeData, keyword); }, [deferredSearchValue, searchScopes, treeData]); const commandSearchTreeItems = useMemo(() => { const result: V2CommandSearchItem[] = []; const visit = (nodes: TreeNode[]) => { nodes.forEach((node) => { const dataRef = node.dataRef || {}; if (node.type === 'connection') { const conn = dataRef as SavedConnection; result.push({ key: `node-${node.key}`, kind: 'node', title: String(node.title || conn.name || t('connection.unnamed')), meta: resolveConnectionHostSummary(conn.config) || conn.config?.type || t('connection.sidebar.menu.section'), icon: getDbIcon(resolveConnectionIconType(conn), resolveConnectionAccentColor(conn), 16), node, }); } else if (node.type === 'database') { const conn = connections.find((item) => item.id === dataRef.id); result.push({ key: `node-${node.key}`, kind: 'node', title: String(node.title || dataRef.dbName || t('database.unnamed')), meta: conn?.name || dataRef.id || t('database.label'), icon: , node, }); } else if ( node.type === 'table' || node.type === 'view' || node.type === 'materialized-view' || node.type === 'db-trigger' || node.type === 'db-event' || node.type === 'routine' ) { const conn = connections.find((item) => item.id === dataRef.id); const objectName = String(dataRef.tableName || dataRef.viewName || dataRef.triggerName || dataRef.eventName || dataRef.routineName || node.title || '').trim(); const displayName = String(node.title || extractObjectName(objectName) || objectName).trim(); result.push({ key: `node-${node.key}`, kind: 'node', title: displayName, meta: [conn?.name || dataRef.id, dataRef.dbName].filter(Boolean).join(' · '), icon: node.type === 'table' ? : (node.type === 'db-event' ? : (node.type === 'routine' ? : )), node, }); } if (node.children) visit(node.children); }); }; visit(treeData); return result; }, [connections, treeData]); const commandSearchRecentItems = useMemo(() => { return sqlLogs.slice(0, 5).map((log) => ({ key: `recent-${log.id}`, kind: 'recent', title: log.sql.replace(/\s+/g, ' ').trim() || 'SQL 记录', meta: `${new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · ${log.duration}ms${log.dbName ? ` · ${log.dbName}` : ''}`, icon: , sql: log.sql, dbName: log.dbName, })); }, [sqlLogs]); const commandSearchActionItems = useMemo(() => [ { key: 'action-new-query', kind: 'action', title: t('query.new'), meta: '打开一个新的 SQL 编辑页', shortcut: resolveShortcutDisplay(shortcutOptions, 'newQueryTab', activeShortcutPlatform), icon: , onRun: () => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab')), }, { key: 'action-new-connection', kind: 'action', title: '新建数据源', meta: '创建数据库、运行时或其他数据源连接', shortcut: resolveShortcutDisplay(shortcutOptions, 'newConnection', activeShortcutPlatform), icon: , onRun: () => onCreateConnection?.(), }, { key: 'action-open-ai', kind: 'action', title: '打开 AI 数据洞察', meta: '让 AI 分析当前数据库上下文', shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleAIPanel', activeShortcutPlatform), icon: , onRun: () => onToggleAI?.(), }, { key: 'action-open-sql-log', kind: 'action', title: '查看 SQL 执行日志', meta: '打开最近执行记录面板', shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleLogPanel', activeShortcutPlatform), icon: , onRun: () => onToggleLogPanel?.(), }, ], [activeShortcutPlatform, onCreateConnection, onToggleAI, onToggleLogPanel, shortcutOptions]); const v2CommandSearchQuery = useMemo( () => parseV2CommandSearchQuery(deferredV2CommandSearchValue), [deferredV2CommandSearchValue], ); const normalizedV2CommandSearchValue = v2CommandSearchQuery.normalizedKeyword; const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object'; const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai'; const filteredCommandSearchTreeItems = useMemo(() => { return filterV2CommandSearchTreeItems(commandSearchTreeItems, v2CommandSearchQuery); }, [commandSearchTreeItems, v2CommandSearchQuery]); const filteredCommandSearchActionItems = useMemo(() => { if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return []; if (!normalizedV2CommandSearchValue) return commandSearchActionItems; return commandSearchActionItems.filter((item) => { const haystack = `${item.title} ${item.meta}`.toLowerCase(); return haystack.includes(normalizedV2CommandSearchValue); }); }, [commandSearchActionItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]); const filteredCommandSearchRecentItems = useMemo(() => { if (v2CommandSearchObjectMode || v2CommandSearchAiMode) return []; if (!normalizedV2CommandSearchValue) return commandSearchRecentItems; return commandSearchRecentItems.filter((item) => { const haystack = `${item.title} ${item.meta}`.toLowerCase(); return haystack.includes(normalizedV2CommandSearchValue); }); }, [commandSearchRecentItems, normalizedV2CommandSearchValue, v2CommandSearchAiMode, v2CommandSearchObjectMode]); const commandSearchAiItem = useMemo(() => { if (!v2CommandSearchAiMode || !v2CommandSearchQuery.aiPrompt) return []; return [{ key: 'action-ask-ai', kind: 'action', title: '让 AI 回答', meta: v2CommandSearchQuery.aiPrompt, shortcut: '↵', icon: , onRun: () => { const wasClosed = !useStore.getState().aiPanelVisible; if (wasClosed) setAIPanelVisible(true); window.setTimeout(() => { window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt: v2CommandSearchQuery.aiPrompt }, })); }, wasClosed ? 350 : 0); }, }]; }, [setAIPanelVisible, v2CommandSearchAiMode, v2CommandSearchQuery.aiPrompt]); const commandSearchFlatItems = useMemo( () => [ ...commandSearchAiItem, ...filteredCommandSearchTreeItems, ...filteredCommandSearchActionItems, ...filteredCommandSearchRecentItems, ], [commandSearchAiItem, filteredCommandSearchActionItems, filteredCommandSearchRecentItems, filteredCommandSearchTreeItems], ); useEffect(() => { setV2CommandActiveIndex(0); }, [v2CommandSearchValue, commandSearchFlatItems.length]); const flattenConnectionNodes = useCallback((nodes: TreeNode[]): TreeNode[] => { const result: TreeNode[] = []; nodes.forEach((node) => { if (node.type === 'connection') { result.push(node); } if (node.children) { result.push(...flattenConnectionNodes(node.children)); } }); return result; }, []); const activeConnectionId = resolveV2ActiveConnectionId({ activeContextConnectionId: activeContext?.connectionId, activeTabConnectionId: activeTab?.connectionId, selectedKeys, connectionIds, fallbackConnectionId: selectedNodesRef.current .map((node) => resolveSidebarNodeConnectionId(node, connectionIds)) .find(Boolean), }); const activeConnection = connections.find((conn) => conn.id === activeConnectionId) || null; const activeConnectionDisplayName = String(activeConnection?.name || '').trim() || t('sidebar.active_connection.no_host_selected'); const activeDatabaseDisplayName = useMemo(() => { if (activeContext && typeof activeContext === 'object' && 'dbName' in activeContext) { return String(activeContext.dbName || '').trim(); } return String(activeTab?.dbName || '').trim(); }, [activeContext, activeTab?.dbName]); const activeConnectionTreeData = useMemo(() => { const externalSQLNodes = displayTreeData.filter((node) => node.type === 'external-sql-root'); if (!activeConnection) return displayTreeData; const activeConnectionNode = displayTreeData.find((node) => node.type === 'connection' && node.key === activeConnection.id); if (activeConnectionNode) { return [ ...(activeConnectionNode.children && activeConnectionNode.children.length > 0 ? activeConnectionNode.children : []), ...externalSQLNodes, ]; } const filterTree = (nodes: TreeNode[]): TreeNode[] => nodes.flatMap((node) => { if (node.type === 'tag') { return filterTree(node.children || []); } if (node.type === 'connection') { if (node.key !== activeConnection.id) return []; return node.children && node.children.length > 0 ? filterTree(node.children) : []; } return [{ ...node, children: node.children ? filterTree(node.children) : undefined }]; }); const filtered = filterTree(displayTreeData); return [...filtered, ...externalSQLNodes]; }, [activeConnection, displayTreeData]); const v2VisibleTreeData = useMemo(() => { if (v2ExplorerFilter === 'all') { return displayTreeData; } return filterV2ExplorerTreeByKind(activeConnectionTreeData, v2ExplorerFilter); }, [activeConnectionTreeData, displayTreeData, v2ExplorerFilter]); const v2TreeHorizontalScrollWidth = useMemo( () => estimateV2TreeHorizontalScrollWidth(v2VisibleTreeData, treeViewportWidth), [treeViewportWidth, v2VisibleTreeData], ); const effectiveTreeHeight = isV2Ui && v2TreeHorizontalScrollWidth ? Math.max(1, treeHeight - V2_TREE_HORIZONTAL_SCROLL_BOTTOM_RESERVE) : treeHeight; const v2TreeMetrics = useMemo(() => { const databaseTableCounts = new Map(); const objectGroupCounts = new Map(); let activeObjectCount = 0; const visitAndCount = (node: TreeNode): number => { const childCount = (node.children || []).reduce((total, child) => total + visitAndCount(child), 0); const totalCount = (isV2SidebarObjectNode(node) ? 1 : 0) + childCount; if (node.type === 'database') { const tableCount = (node.children || []).reduce((total, child) => { if (child.type === 'object-group' && child?.dataRef?.groupKey === 'tables') { return total + (Array.isArray(child.children) ? child.children.filter((item) => item.type === 'table').length : 0); } if (child?.dataRef?.groupKey === 'schema' && Array.isArray(child.children)) { return total + child.children.reduce((schemaTotal, schemaChild) => { if (schemaChild.type === 'object-group' && schemaChild?.dataRef?.groupKey === 'tables') { return schemaTotal + (Array.isArray(schemaChild.children) ? schemaChild.children.filter((item) => item.type === 'table').length : 0); } return schemaTotal; }, 0); } return total; }, 0); databaseTableCounts.set(node.key, tableCount); } else if (node.type === 'object-group') { objectGroupCounts.set(node.key, childCount); } return totalCount; }; activeObjectCount = v2VisibleTreeData.reduce((total, node) => total + visitAndCount(node), 0); return { activeObjectCount, databaseTableCounts, objectGroupCounts, }; }, [v2VisibleTreeData]); const activeConnectionObjectCount = v2TreeMetrics.activeObjectCount; const legacyToolbarButtonColor = darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(0,0,0,0.65)'; const legacyToolbarStyle: React.CSSProperties = { padding: '6px 16px', display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 8, alignItems: 'center', justifyItems: 'center', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}`, background: darkMode ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.015)', }; const legacyToolbarItemStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'center', minWidth: 0, }; const legacyToolbarDisabledWrapStyle: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', }; const connectionStatusMap = useMemo(() => { const statusMap = new Map(); const sortedConnectionIds = connections .map((conn) => conn.id) .sort((a, b) => b.length - a.length); connections.forEach((conn) => { statusMap.set(conn.id, 'idle'); }); Object.entries(connectionStates).forEach(([key, value]) => { const ownState = statusMap.get(key); if (ownState !== undefined) { statusMap.set(key, value === 'success' ? 'live' : 'error'); return; } if (value !== 'success') return; const ownerId = sortedConnectionIds.find((id) => key.startsWith(`${id}-`)); if (ownerId && statusMap.get(ownerId) === 'idle') { statusMap.set(ownerId, 'live'); } }); return statusMap; }, [connectionStates, connections]); const buildRailConnectionStatus = useCallback((connectionId: string): 'live' | 'error' | 'idle' => { return connectionStatusMap.get(connectionId) || 'idle'; }, [connectionStatusMap]); const openV2ConnectionContextMenu = ( event: React.MouseEvent, connOrNode: SavedConnection | TreeNode, ) => { event.preventDefault(); event.stopPropagation(); const node = (connOrNode as TreeNode).type === 'connection' ? connOrNode as TreeNode : getConnectionNodeForAction(connOrNode as SavedConnection); if (!node?.key || !node?.dataRef) return; const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY); setContextMenu({ x: position.x, y: position.y, sourceX: event.clientX, sourceY: event.clientY, items: [], kind: 'v2-connection', node, rootClassName: 'gn-v2-table-context-menu-popup', overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }, maxHeight: position.maxHeight, }); }; const getV2TreeMetaText = (node: any): string => { if (node.type === 'tag') { const count = flattenConnectionNodes(node.children || []).length; return count > 0 ? count.toLocaleString() : ''; } if (node.type === 'database') { const count = v2TreeMetrics.databaseTableCounts.get(node.key) || 0; return count > 0 ? count.toLocaleString() : ''; } if (node.type === 'object-group') { const count = v2TreeMetrics.objectGroupCounts.get(node.key) || 0; return count > 0 ? count.toLocaleString() : ''; } if (node.type === 'redis-db') { const match = String(node.title || '').match(/\((\d+)\)/); return match?.[1] || ''; } if (node.type === 'table') { const rowCount = Number(node?.dataRef?.rowCount); return Number.isFinite(rowCount) && rowCount >= 0 ? formatSidebarRowCount(rowCount) : ''; } return ''; }; const getV2TableContextMenuStatsKey = (node: any): string => { const id = String(node?.dataRef?.id || ''); const dbName = String(node?.dataRef?.dbName || ''); const tableName = String(node?.dataRef?.tableName || node?.title || ''); return `${id}::${dbName}::${tableName}`; }; const readNumericMetadataValue = (row: Record, keys: string[]): number | undefined => { const value = getCaseInsensitiveRawValue(row, keys); if (value === undefined || value === null || value === '') return undefined; const normalized = Number(String(value).replace(/,/g, '')); return Number.isFinite(normalized) ? normalized : undefined; }; const buildV2TableStatusSQL = (node: any): string => { const conn = node.dataRef as SavedConnection & { dbName?: string; tableName?: string; schemaName?: string }; const dialect = getMetadataDialect(conn); const dbName = String(conn?.dbName || '').trim(); const tableName = String(conn?.tableName || node?.title || '').trim(); const objectName = extractObjectName(tableName); const schemaName = String(conn?.schemaName || splitQualifiedName(tableName).schemaName || '').trim(); switch (dialect) { case 'mysql': case 'starrocks': return [ 'SELECT TABLE_ROWS AS table_rows, DATA_LENGTH AS data_length, INDEX_LENGTH AS index_length, ENGINE AS engine', 'FROM information_schema.tables', `WHERE table_schema = '${escapeSQLLiteral(dbName)}'`, `AND table_name = '${escapeSQLLiteral(objectName)}'`, 'LIMIT 1', ].join('\n'); case 'postgres': case 'kingbase': case 'vastbase': case 'highgo': case 'opengauss': case 'gaussdb': { const schema = schemaName || 'public'; return [ "SELECT c.reltuples::bigint AS table_rows, pg_total_relation_size(c.oid) AS data_length, pg_indexes_size(c.oid) AS index_length, 'heap' AS engine", 'FROM pg_class c', 'JOIN pg_namespace n ON n.oid = c.relnamespace', "WHERE c.relkind = 'r'", `AND n.nspname = '${escapeSQLLiteral(schema)}'`, `AND c.relname = '${escapeSQLLiteral(objectName)}'`, 'LIMIT 1', ].join('\n'); } case 'sqlserver': { const safeTable = tableName.replace(/'/g, "''"); return [ 'SELECT SUM(p.rows) AS table_rows, SUM(a.total_pages) * 8 * 1024 AS data_length, SUM(a.used_pages) * 8 * 1024 AS index_length, NULL AS engine', 'FROM sys.tables t', 'JOIN sys.indexes i ON t.object_id = i.object_id', 'JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id', 'JOIN sys.allocation_units a ON p.partition_id = a.container_id', `WHERE t.object_id = OBJECT_ID('${safeTable}')`, ].join('\n'); } case 'clickhouse': return [ 'SELECT total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length, engine AS engine', 'FROM system.tables', `WHERE database = '${escapeSQLLiteral(dbName)}'`, `AND name = '${escapeSQLLiteral(objectName)}'`, 'LIMIT 1', ].join('\n'); case 'oracle': case 'dm': { const owner = (schemaName || dbName || '').toUpperCase(); return [ 'SELECT num_rows AS table_rows, 0 AS data_length, 0 AS index_length, NULL AS engine', 'FROM all_tables', `WHERE owner = '${escapeSQLLiteral(owner)}'`, `AND table_name = '${escapeSQLLiteral(objectName.toUpperCase())}'`, 'FETCH FIRST 1 ROWS ONLY', ].join('\n'); } case 'sqlite': case 'duckdb': return `SELECT COUNT(*) AS table_rows, 0 AS data_length, 0 AS index_length, NULL AS engine FROM ${tableName}`; default: return ''; } }; const renderV2TableContextMenu = (node: any) => { const tableName = String(node?.dataRef?.tableName || node?.title || '').trim(); const statsKey = getV2TableContextMenuStatsKey(node); const stats = v2TableContextMenuStats[statsKey]; const isStarRocks = getMetadataDialect(node.dataRef as SavedConnection) === 'starrocks'; const supportsMessagePublish = Boolean(resolveMessagePublishTarget(node)); const isPinned = isSidebarTablePinned( pinnedSidebarTables, String(node?.dataRef?.id || ''), String(node?.dataRef?.dbName || ''), tableName, String(node?.dataRef?.schemaName || ''), ); return ( { setContextMenu(null); handleV2TableContextMenuAction(node, action); }} /> ); }; const renderV2TableGroupContextMenu = (node: any) => { const groupData = node.dataRef || {}; const sortPreferenceKey = `${groupData.id}-${groupData.dbName}`; const currentSort = tableSortPreference[sortPreferenceKey] || 'name'; return ( { setContextMenu(null); handleV2TableGroupContextMenuAction(node, action); }} /> ); }; const renderV2DatabaseContextMenu = (node: any) => { const dialect = getMetadataDialect(node.dataRef as SavedConnection); const capabilities = getDataSourceCapabilities((node.dataRef as SavedConnection)?.config); return ( { setContextMenu(null); handleV2DatabaseContextMenuAction(node, action); }} /> ); }; const handleV2SchemaContextMenuAction = (node: any, action: V2SchemaContextMenuActionKey) => { switch (action) { case 'rename-schema': openRenameSchemaModal(node); return; case 'refresh-schema': void loadTables(getDatabaseNodeRef(node?.dataRef, String(node?.dataRef?.dbName || '').trim())); return; case 'export-schema': void handleExportSchemaSQL(node, false); return; case 'backup-schema-sql': void handleExportSchemaSQL(node, true); return; case 'drop-schema': handleDeleteSchema(node); return; default: return; } }; const renderV2SchemaContextMenu = (node: any) => ( { setContextMenu(null); handleV2SchemaContextMenuAction(node, action); }} /> ); const renderV2ConnectionContextMenu = (node: any) => { const conn = node.dataRef as SavedConnection; const capabilities = getDataSourceCapabilities(conn?.config); const currentTagId = connectionTags.find((tag) => tag.connectionIds.includes(String(conn.id || node.key)))?.id || ''; return ( ({ id: tag.id, name: tag.name, selected: tag.id === currentTagId, }))} onAction={(action) => { setContextMenu(null); handleV2ConnectionContextMenuAction(node, action); }} /> ); }; const renderV2ConnectionGroupContextMenu = (group: V2RailConnectionGroup) => ( { setContextMenu(null); handleV2ConnectionGroupContextMenuAction(group, action); }} /> ); const renderV2SidebarContextMenuContent = (menu: SidebarContextMenuState) => { if (!menu.node) return null; if (menu.kind === 'v2-table') return renderV2TableContextMenu(menu.node); if (menu.kind === 'v2-database') return renderV2DatabaseContextMenu(menu.node); if (menu.kind === 'v2-schema') return renderV2SchemaContextMenu(menu.node); if (menu.kind === 'v2-table-group') return renderV2TableGroupContextMenu(menu.node); if (menu.kind === 'v2-connection') return renderV2ConnectionContextMenu(menu.node); if (menu.kind === 'v2-connection-group') return renderV2ConnectionGroupContextMenu(menu.node); return null; }; useEffect(() => { if (!contextMenu?.kind) return; const onPointerDown = (event: MouseEvent) => { const target = event.target instanceof Node ? event.target : null; if (target && contextMenuPortalRef.current?.contains(target)) return; setContextMenu(null); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setContextMenu(null); }; document.addEventListener('mousedown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, [contextMenu?.kind]); useEffect(() => { if (!contextMenu?.kind) return; const frame = requestAnimationFrame(() => { const portal = contextMenuPortalRef.current; if (!portal) return; const rect = portal.getBoundingClientRect(); const content = portal.querySelector('.gn-v2-table-context-menu') as HTMLElement | null; const measuredHeight = Math.max(rect.height, content?.scrollHeight || 0); const position = resolveSidebarContextMenuPosition(contextMenu.sourceX ?? contextMenu.x, contextMenu.sourceY ?? contextMenu.y, { width: rect.width || SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH, height: measuredHeight || SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT, }); setContextMenu(prev => { if (!prev?.kind) return prev; if (prev.x === position.x && prev.y === position.y && prev.maxHeight === position.maxHeight) return prev; return { ...prev, x: position.x, y: position.y, maxHeight: position.maxHeight }; }); }); return () => cancelAnimationFrame(frame); }, [contextMenu?.kind, contextMenu?.x, contextMenu?.y]); const fetchV2TableContextMenuStats = async (node: any) => { const statsKey = getV2TableContextMenuStatsKey(node); if (!statsKey || v2TableContextMenuStats[statsKey]?.loading) return; const sql = buildV2TableStatusSQL(node); if (!sql) { setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); return; } setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { ...prev[statsKey], loading: true } })); const startTime = Date.now(); try { const conn = node.dataRef; const res = await DBQuery(buildRuntimeConfig(conn, conn.dbName) as any, conn.dbName || '', sql); if (!res.success || !Array.isArray(res.data) || res.data.length === 0) { setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); return; } const row = res.data[0] as Record; setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { rowCount: readNumericMetadataValue(row, ['table_rows', 'TABLE_ROWS', 'rows', 'num_rows', 'reltuples', 'total_rows']), dataLength: readNumericMetadataValue(row, ['data_length', 'DATA_LENGTH', 'total_bytes']), indexLength: readNumericMetadataValue(row, ['index_length', 'INDEX_LENGTH']), engine: getCaseInsensitiveValue(row, ['engine', 'ENGINE']), }, })); addSqlLog({ id: `${Date.now()}-table-stats`, timestamp: Date.now(), sql, status: 'success', duration: Date.now() - startTime, dbName: conn.dbName, }); } catch (error: any) { setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { unavailable: true } })); addSqlLog({ id: `${Date.now()}-table-stats-error`, timestamp: Date.now(), sql, status: 'error', duration: Date.now() - startTime, message: error?.message || String(error), dbName: node?.dataRef?.dbName, }); } }; const refreshV2TableContextMenuStats = (node: any) => { const statsKey = getV2TableContextMenuStatsKey(node); setV2TableContextMenuStats(prev => ({ ...prev, [statsKey]: { loading: true } })); void fetchV2TableContextMenuStats(node); }; const renderV2TreeTitle = (node: any, hoverTitle: string, statusBadge: React.ReactNode) => { const rawTitle = String(node.title ?? ''); const groupKey = String(node?.dataRef?.groupKey || ''); const dragText = resolveSidebarObjectDragText(node); if (node.type === 'v2-table-section') { return ( {rawTitle} ); } const displayTitle = (() => { if (node.type === 'queries-folder') return t('sidebar.tree.saved_queries'); if (node.type === 'external-sql-root') return t('sidebar.external_sql.root'); if (node.type === 'object-group') { const objectGroupTitle = resolveV2ObjectGroupTitle(node); if (objectGroupTitle) return objectGroupTitle; } return rawTitle; })(); const metaText = getV2TreeMetaText(node); const isMono = node.type === 'table' || node.type === 'view' || node.type === 'materialized-view' || node.type === 'db-trigger' || node.type === 'db-event' || node.type === 'routine' || node.type === 'saved-query' || node.type === 'external-sql-file'; const titleClassName = [ 'gn-v2-tree-title', isMono ? 'is-mono' : '', node.type === 'object-group' ? 'is-group' : '', node.type === 'table' && node?.dataRef?.pinnedSidebarTable ? 'is-pinned-table' : '', ].filter(Boolean).join(' '); const tablePinAction = node.type === 'table' ? ( ) : null; if (node.type === 'connection') { return ( {statusBadge} {displayTitle} ); } return ( <> { snapshotTreeSelectionBeforeDrag(); treeDragSelectSuppressUntilRef.current = Date.now() + 600; setIsTreeDragging(true); event.stopPropagation(); event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.setData('text/plain', dragText); event.dataTransfer.setData( SIDEBAR_SQL_EDITOR_DRAG_MIME, encodeSidebarSqlEditorDragPayload({ text: dragText, nodeType: node.type, connectionId: String(node?.dataRef?.id || ''), dbName: String(node?.dataRef?.dbName || ''), }), ); } : undefined} onDragEnd={dragText ? () => { restoreTreeSelectionAfterDrag(); setIsTreeDragging(false); } : undefined} > {statusBadge} {displayTitle} {metaText && {metaText}} {tablePinAction} ); }; const selectConnectionFromRail = useCallback((conn: SavedConnection) => { const key = conn.id; const connectionNode = findTreeNodeByKeyRef.current(treeDataRef.current, key); setSelectedKeys([key]); selectedNodesRef.current = connectionNode ? [connectionNode] : []; setActiveContext({ connectionId: key, dbName: '' }); mergeExpandedTreeKeys([key]); const targetNode = connectionNode || { key, dataRef: conn, type: 'connection', }; void loadDatabases(targetNode); }, [setActiveContext]); const runCommandSearchItem = useCallback((item?: V2CommandSearchItem) => { if (!item) return; closeV2CommandSearch(); if (item.kind === 'action') { item.onRun(); return; } if (item.kind === 'recent') { addTab({ id: `query-${Date.now()}`, title: '最近查询', type: 'query', connectionId: item.connectionId || activeContext?.connectionId || activeTab?.connectionId || '', dbName: item.dbName || activeContext?.dbName || activeTab?.dbName || '', query: item.sql, }); return; } const node = item.node; const dataRef = node.dataRef || {}; if (node.type === 'connection') { selectConnectionFromRail(dataRef as SavedConnection); return; } if (node.type === 'database') { setActiveContext({ connectionId: resolveSidebarNodeConnectionId(node, connectionIds) || dataRef.id, dbName: dataRef.dbName }); mergeExpandedTreeKeys([dataRef.id, node.key]); setSelectedKeys([node.key]); selectedNodesRef.current = [node]; scrollSidebarTreeToKey(node.key); return; } if (node.type === 'table' || node.type === 'view' || node.type === 'materialized-view') { void locateObjectInSidebar({ tabId: String(node.key || ''), connectionId: dataRef.id, dbName: dataRef.dbName, tableName: dataRef.tableName || dataRef.viewName, schemaName: dataRef.schemaName, objectGroup: node.type === 'table' ? 'tables' : (node.type === 'materialized-view' ? 'materializedViews' : 'views'), }); onDoubleClick(null, node); return; } if (node.type === 'db-trigger' || node.type === 'db-event' || node.type === 'routine') { setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); setSelectedKeys([node.key]); selectedNodesRef.current = [node]; scrollSidebarTreeToKey(node.key); onDoubleClick(null, node); } }, [activeContext, activeTab, addTab, closeV2CommandSearch, selectConnectionFromRail, setActiveContext]); const handleV2CommandSearchKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'ArrowDown') { event.preventDefault(); setV2CommandActiveIndex((prev) => { if (commandSearchFlatItems.length === 0) return 0; return Math.min(prev + 1, commandSearchFlatItems.length - 1); }); return; } if (event.key === 'ArrowUp') { event.preventDefault(); setV2CommandActiveIndex((prev) => Math.max(prev - 1, 0)); return; } if (event.key === 'Enter') { if (!shouldRunV2CommandSearchEnter({ key: event.key, isComposing: event.nativeEvent.isComposing, keyCode: event.nativeEvent.keyCode, activeItemCount: commandSearchFlatItems.length, })) { return; } event.preventDefault(); runCommandSearchItem(commandSearchFlatItems[v2CommandActiveIndex]); return; } if (event.key === 'Escape') { event.preventDefault(); closeV2CommandSearch(); } }; expandConnectionFromRailRef.current = (connectionId: string) => { const conn = connections.find((item) => item.id === connectionId); if (conn) { selectConnectionFromRail(conn); } }; const getNodeMenuItems = (node: any): MenuProps['items'] => buildSidebarLegacyNodeMenuItems(node, { addTab, getMetadataDialect, handleV2DatabaseContextMenuAction, isPostgresSchemaDialect, handleExportSchemaSQL, openRenameSchemaModal, loadTables, getDatabaseNodeRef, handleDeleteSchema, tableSortPreference, isStructureOnlyDbType, openNewTableDesign, handleTableGroupSortAction, openCreateView, openCreateStarRocksMaterializedView, openCreateRoutine, createTagForm, setRenameViewTarget, setIsCreateTagModalOpen, removeConnectionTag, setExpandedKeys, setLoadedKeys, loadingNodesRef, loadDatabases, buildConnectionRootRedisCommandTabTitle, buildConnectionRootRedisMonitorTabTitle, onEditConnection, handleDuplicateConnection, disconnectConnectionNode, deleteConnectionNode, connectionTags, moveConnectionToTag, setTargetConnection, setIsCreateDbModalOpen, buildConnectionRootQueryTabTitle, handleRunSQLFile, openCreateStarRocksExternalCatalog, openEditView, renameViewForm, setIsRenameViewModalOpen, handleDropView, onDoubleClick, openViewDefinition, openRoutineDefinition, openEditRoutine, handleDropRoutine, openEventDefinition, resolveMessagePublishTarget, openMessagePublishModal, openDesign, openCreateStarRocksRollup, handleCopyTableName, handleCopyStructure, handleExport, setRenameTableTarget, renameTableForm, setIsRenameTableModalOpen, handleTableDataDangerAction, handleDeleteTable, openExportDialog, isSavedQueryUnmatched, connections, handleRebindSavedQuery, openRenameSavedQueryModal, resolveSavedQueryDisplayName, deleteQuery, treeDataRef, setTreeData, handleAddExternalSQLDirectory, openCreateExternalSQLFileModal, openCreateExternalSQLDirectoryModal, openRenameExternalSQLDirectoryModal, handleRefreshExternalSQLDirectory, handleDeleteExternalSQLDirectory, handleRemoveExternalSQLDirectory, openExternalSQLFile, openRenameExternalSQLFileModal, handleDeleteExternalSQLFile, extractObjectName, }); const titleRender = (node: any) => { let status: 'success' | 'error' | 'default' = 'default'; if (node.type === 'connection' || node.type === 'database') { if (connectionStates[node.key] === 'success') status = 'success'; else if (connectionStates[node.key] === 'error') status = 'error'; } const statusBadge = node.type === 'connection' || node.type === 'database' ? ( isV2Ui ?
)}
{isV2Ui && !v2UseLegacySidebarFilter ? (
) : isV2Ui ? (
} />
) : ( } style={{ borderRadius: 6, border: 'none', background: darkMode ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.03)', boxShadow: 'none', padding: '4px 8px', color: darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)', }} suffix={(
{ if (!isSearchScopePopoverOpen) { e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'; e.currentTarget.style.color = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.65)'; } }} onMouseLeave={(e) => { if (!isSearchScopePopoverOpen) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = searchScopes.includes('smart') ? (darkMode ? '#ffd666' : '#1677ff') : (darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'); } }} > {searchScopes.includes('smart') ? t('sidebar.command_search.scope.compact_smart') : searchScopes.length}
)} /> )}
{isV2Ui && (
{V2_EXPLORER_FILTER_OPTIONS.map((item) => ( ))}
)} {/* Toolbar */} {!isV2Ui && (
)}
node.type === 'connection' || node.type === 'tag' }} onDragStart={() => { snapshotTreeSelectionBeforeDrag(); treeDragSelectSuppressUntilRef.current = Date.now() + 600; setIsTreeDragging(true); }} onDragEnter={() => { treeDragSelectSuppressUntilRef.current = Date.now() + 600; setIsTreeDragging(true); }} onDragEnd={() => { restoreTreeSelectionAfterDrag(); setIsTreeDragging(false); }} onDrop={handleDrop} loadData={onLoadData} treeData={isV2Ui ? v2VisibleTreeData : displayTreeData} onDoubleClick={onDoubleClick} onSelect={onSelect} titleRender={titleRender} expandedKeys={expandedKeys} onExpand={onExpand} loadedKeys={loadedKeys} onLoad={setLoadedKeys} autoExpandParent={autoExpandParent} selectedKeys={selectedKeys} blockNode height={effectiveTreeHeight} scrollWidth={isV2Ui ? v2TreeHorizontalScrollWidth : undefined} onRightClick={onRightClick} />
{isV2Ui && (
)} {contextMenu?.kind && typeof document !== 'undefined' && createPortal(
event.stopPropagation()} onClick={(event) => event.stopPropagation()} onContextMenu={(event) => event.preventDefault()} > {renderV2SidebarContextMenuContent(contextMenu)}
, document.body, )} {contextMenu && !contextMenu.kind && ( { if (!open) setContextMenu(null); }} trigger={['contextMenu']} rootClassName={contextMenu.rootClassName} overlayStyle={contextMenu.overlayStyle} >
)} , renameViewTarget?.type === 'tag' ? t('sidebar.modal.tag.edit_title') : t('sidebar.modal.tag.create_title'), renameViewTarget?.type === 'tag' ? t('sidebar.modal.tag.edit_description') : t('sidebar.modal.tag.create_description') )} open={isCreateTagModalOpen} centered styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }} onOk={() => { createTagForm.validateFields().then(values => { if (renameViewTarget?.type === 'tag') { // Rename updateConnectionTag({ ...renameViewTarget.dataRef, name: values.name, connectionIds: values.connectionIds || [] }); // update cross-connections const allOtherTagsIds = connectionTags.filter(t => t.id !== renameViewTarget.dataRef.id).flatMap(t => t.connectionIds); (values.connectionIds || []).forEach((cid: string) => { if (allOtherTagsIds.includes(cid)) { moveConnectionToTag(cid, renameViewTarget.dataRef.id); } }); } else { // Create const tagId = Date.now().toString(); addConnectionTag({ id: tagId, name: values.name, connectionIds: values.connectionIds || [] }); (values.connectionIds || []).forEach((cid: string) => { moveConnectionToTag(cid, tagId); }); } setIsCreateTagModalOpen(false); }); }} onCancel={() => setIsCreateTagModalOpen(false)} >
{connections.map(conn => ( {conn.name} {conn.config.host ? `(${conn.config.host})` : ''} ))}
setIsCreateDbModalOpen(false)} >
{/* Charset option could be added here */}
{ setIsCreateSchemaModalOpen(false); setCreateSchemaTarget(null); createSchemaForm.resetFields(); }} >
{ setIsRenameSchemaModalOpen(false); setRenameSchemaTarget(null); renameSchemaForm.resetFields(); }} >
{ setIsRenameDbModalOpen(false); setRenameDbTarget(null); renameDbForm.resetFields(); }} >
{ setIsRenameTableModalOpen(false); setRenameTableTarget(null); renameTableForm.resetFields(); }} >
{ setIsRenameViewModalOpen(false); setRenameViewTarget(null); renameViewForm.resetFields(); }} >
{ setIsRenameSavedQueryModalOpen(false); setRenameSavedQueryTarget(null); renameSavedQueryForm.resetFields(); }} okText={t('query_editor.action.rename_query')} cancelText={t('common.cancel')} >
{ setIsExternalSQLFileModalOpen(false); setExternalSQLFileTarget(null); externalSQLFileForm.resetFields(); }} okText={t(externalSQLFileModalMode === 'create' || externalSQLFileModalMode === 'create-directory' ? 'sidebar.external_sql_modal.action.create' : 'sidebar.external_sql_modal.action.rename')} cancelText={t('common.cancel')} >
{ const name = String(value || '').trim(); if (!name) return; if (/[\\/]/.test(name) || name === '.' || name === '..') { throw new Error(isExternalSQLDirectoryModalMode(externalSQLFileModalMode) ? t('sidebar.external_sql_modal.validation.directory_name_no_separator') : t('sidebar.external_sql_modal.validation.sql_file_name_no_separator')); } }, }, ]} extra={isExternalSQLDirectoryModalMode(externalSQLFileModalMode) ? t('sidebar.external_sql_modal.help.directory') : t('sidebar.external_sql_modal.help.sql_file')} >
, "批量操作表", "按对象批量导出结构、数据或完整备份。")} open={isBatchModalOpen} onCancel={() => setIsBatchModalOpen(false)} width={720} centered styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }} footer={
} >
先选择连接与数据库,再决定导出范围和目标对象。
{batchTables.length > 0 && (
setBatchFilterKeyword(e.target.value)} placeholder="筛选表/视图名称" prefix={} style={{ width: 260 }} /> setBatchSelectionScope(value as BatchSelectionScope)} style={{ width: 220 }} options={[ { label: '勾选作用于:当前筛选结果', value: 'filtered' }, { label: '勾选作用于:全部对象', value: 'all' }, ]} />
当前筛选命中 {filteredBatchObjects.length} / {batchTables.length} 个对象
)} {batchTables.length > 0 && ( <>
已选择 {checkedTableKeys.length} / {batchTables.length} 个对象
setCheckedTableKeys(values as string[])} style={{ width: '100%' }} >
{groupedBatchObjects.tables.length > 0 && (
表 ({groupedBatchObjects.tables.length})
{groupedBatchObjects.tables.map(table => ( {table.title} ))}
)} {groupedBatchObjects.views.length > 0 && (
视图 ({groupedBatchObjects.views.length})
{groupedBatchObjects.views.map(view => ( {view.title} ))}
)} {groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && (
无匹配对象
)}
)}
, "批量操作库", "按数据库批量导出结构,或生成结构加数据的备份。")} open={isBatchDbModalOpen} onCancel={() => setIsBatchDbModalOpen(false)} width={640} centered styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }} footer={[ , , ]} >
连接选定后会加载当前连接下可批量导出的数据库列表。
{batchDatabases.length > 0 && ( <>
已选择 {checkedDbKeys.length} / {batchDatabases.length} 个库
setCheckedDbKeys(values as string[])} style={{ width: '100%' }} > {batchDatabases.map(db => ( {db.title} ))}
)}
{/* SQL 文件流式执行进度 Modal */} { CancelSQLFileExecution(sqlFileExecState.jobId); setSqlFileExecState(prev => ({ ...prev, status: 'cancelled' })); }, onClose: () => setSqlFileExecState(prev => ({ ...prev, open: false })), })} onCancel={() => { if (sqlFileExecState.status !== 'running') { setSqlFileExecState(prev => ({ ...prev, open: false })); } }} styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none' }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} > setFindInDbContext({ open: false, connectionId: '', dbName: '' })} connectionId={findInDbContext.connectionId} dbName={findInDbContext.dbName} /> setMessagePublishTarget(null)} onSuccess={handleMessagePublishSuccess} />
); }); export default Sidebar;