diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index e5b350c..32a927f 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -501,10 +501,16 @@ describe('DataGrid layout', () => { expect(source).toContain('type VirtualTableScrollReference = TableReference & {'); expect(source).toContain('const tableRef = useRef(null);'); expect(source).toContain('resolveDataGridHorizontalWheelDelta({'); + expect(source).toContain('const virtualHorizontalAlignmentRafRef = useRef(null);'); expect(source).toContain('const scheduleVirtualHorizontalWheel = useCallback'); expect(source).toContain('pendingTableHorizontalDeltaRef.current += delta;'); expect(source).toContain('tableHorizontalWheelRafRef.current = requestAnimationFrame'); + expect(source).toContain('const scheduleVirtualHorizontalAlignment = useCallback((preferredLeft?: number) => {'); + expect(source).toContain('virtualHorizontalElementsRef.current = { tableContainer: null, holderEl: null, innerEl: null, headerEl: null };'); + expect(source).toContain('applyVirtualHorizontalOffset(tableContainer, nextLeft, { forceInternalScroll: true });'); + expect(source).toContain('}, [horizontalScrollVisible, scheduleVirtualHorizontalAlignment, tableRenderData, tableScrollX, virtualEditingCell]);'); expect(source).toContain('tableInstance.scrollTo({ left: clampedOffset, top: holderEl.scrollTop });'); + expect(source).toContain('applyVirtualHorizontalOffset(tableContainer, latestExternalScroll.scrollLeft, { forceInternalScroll: true });'); expect(source).toContain('if (externalSyncRafRef.current !== null)'); expect(source).toContain('externalSyncRafRef.current = requestAnimationFrame'); expect(source).toContain('const scheduleSyncExternalScrollFromTargets = useCallback'); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index f35ad1f..ab0ebb4 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1921,6 +1921,7 @@ const DataGrid: React.FC = ({ const externalSyncRafRef = useRef(null); const tableTargetSyncRafRef = useRef(null); const tableHorizontalWheelRafRef = useRef(null); + const virtualHorizontalAlignmentRafRef = useRef(null); const pendingTableHorizontalDeltaRef = useRef(0); const pendingTableTargetSyncSourceRef = useRef(null); const scrollSnapshotRafRef = useRef(null); @@ -6311,7 +6312,7 @@ const DataGrid: React.FC = ({ return { holderEl, clampedOffset, currentOffset }; }, [resolveVirtualHorizontalElements, tableScrollX]); - const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => { + const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number, options?: { forceInternalScroll?: boolean }) => { const synced = syncVirtualHorizontalVisualOffset(tableContainer, nextOffset); if (!synced) { return false; @@ -6319,7 +6320,7 @@ const DataGrid: React.FC = ({ const { holderEl, clampedOffset, currentOffset } = synced; const deltaX = clampedOffset - currentOffset; - if (Math.abs(deltaX) < 0.5) return true; + if (Math.abs(deltaX) < 0.5 && !options?.forceInternalScroll) return true; const tableInstance = tableRef.current; if (tableInstance && typeof tableInstance.scrollTo === 'function') { @@ -6338,6 +6339,34 @@ const DataGrid: React.FC = ({ return true; }, [syncVirtualHorizontalVisualOffset]); + const scheduleVirtualHorizontalAlignment = useCallback((preferredLeft?: number) => { + if (!enableVirtual || !isTableSurfaceActive) return; + if (virtualHorizontalAlignmentRafRef.current !== null) { + cancelAnimationFrame(virtualHorizontalAlignmentRafRef.current); + } + virtualHorizontalAlignmentRafRef.current = requestAnimationFrame(() => { + virtualHorizontalAlignmentRafRef.current = null; + const tableContainer = tableContainerRef.current; + if (!(tableContainer instanceof HTMLElement)) return; + + virtualHorizontalElementsRef.current = { tableContainer: null, holderEl: null, innerEl: null, headerEl: null }; + const externalScroll = externalHorizontalScrollRef.current; + const nextLeft = Math.max(0, preferredLeft ?? externalScroll?.scrollLeft ?? lastTableScrollLeftRef.current); + const applied = applyVirtualHorizontalOffset(tableContainer, nextLeft, { forceInternalScroll: true }); + const resolvedLeft = applied ? readVirtualHorizontalOffset(tableContainer) : nextLeft; + lastTableScrollLeftRef.current = resolvedLeft; + if (externalScroll && Math.abs(externalScroll.scrollLeft - resolvedLeft) > 1) { + externalScroll.scrollLeft = resolvedLeft; + } + lastExternalScrollLeftRef.current = externalScroll?.scrollLeft ?? resolvedLeft; + requestAnimationFrame(() => { + const latestContainer = tableContainerRef.current; + if (!(latestContainer instanceof HTMLElement)) return; + syncVirtualHorizontalVisualOffset(latestContainer, resolvedLeft); + }); + }); + }, [applyVirtualHorizontalOffset, enableVirtual, isTableSurfaceActive, readVirtualHorizontalOffset, syncVirtualHorizontalVisualOffset]); + const flushVirtualHorizontalWheel = useCallback((tableContainer: HTMLElement) => { tableHorizontalWheelRafRef.current = null; const delta = pendingTableHorizontalDeltaRef.current; @@ -6586,7 +6615,7 @@ const DataGrid: React.FC = ({ // 虚拟表格路径:通过合成 WheelEvent 驱动 rc-virtual-list 内部状态, // rc-table 自动同步 header scrollLeft。 if (enableVirtual && tableContainer instanceof HTMLElement) { - const applied = applyVirtualHorizontalOffset(tableContainer, latestExternalScroll.scrollLeft); + const applied = applyVirtualHorizontalOffset(tableContainer, latestExternalScroll.scrollLeft, { forceInternalScroll: true }); if (applied) { // WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref requestAnimationFrame(() => { @@ -6875,6 +6904,17 @@ const DataGrid: React.FC = ({ return () => cancelAnimationFrame(rafId); }, [isTableSurfaceActive, totalWidth, mergedDisplayData.length, pagination?.total, pagination?.pageSize, recalculateTableMetrics]); + useEffect(() => { + if (!horizontalScrollVisible) return; + scheduleVirtualHorizontalAlignment(); + return () => { + if (virtualHorizontalAlignmentRafRef.current !== null) { + cancelAnimationFrame(virtualHorizontalAlignmentRafRef.current); + virtualHorizontalAlignmentRafRef.current = null; + } + }; + }, [horizontalScrollVisible, scheduleVirtualHorizontalAlignment, tableRenderData, tableScrollX, virtualEditingCell]); + // 虚拟表列对齐:antd 虚拟表 body 使用
+(非 ), // 不会自动拉伸列宽到视口。而 header
会被 antd 的 CSS 或 JS // 设置为 width:100% 自动拉伸。强制 header table 宽度等于 scroll.x, diff --git a/frontend/src/components/DataGridColumnTitle.test.tsx b/frontend/src/components/DataGridColumnTitle.test.tsx index 32103f1..06795bd 100644 --- a/frontend/src/components/DataGridColumnTitle.test.tsx +++ b/frontend/src/components/DataGridColumnTitle.test.tsx @@ -5,7 +5,12 @@ import { describe, expect, it, vi } from 'vitest'; import DataGridColumnTitle from './DataGridColumnTitle'; vi.mock('antd', () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children, title, rootClassName }: { children: React.ReactNode; title?: React.ReactNode; rootClassName?: string }) => ( + <> +
{title}
+ {children} + + ), })); describe('DataGridColumnTitle', () => { @@ -50,6 +55,43 @@ describe('DataGridColumnTitle', () => { expect(markup).toContain('align-items:flex-start'); }); + it('keeps column metadata tooltip readable in light theme', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-tooltip-root-class="gn-data-grid-column-meta-tooltip"'); + expect(markup).toContain('class="gn-data-grid-column-meta-tooltip-content"'); + expect(markup).toContain('color:var(--gn-fg-1, #fff)'); + expect(markup).not.toContain('color:#fff'); + }); + + it('keeps the configured warm metadata tooltip color in dark theme', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('color:rgba(255, 236, 179, 0.98)'); + }); + it('renders foreign-key jump affordance when reference target exists', () => { const markup = renderToStaticMarkup( = ({ return titleNode; } + const tooltipTextColor = darkMode ? columnMetaTooltipColor : 'var(--gn-fg-1, #fff)'; + return ( {hoverLines.join('\n')} )} + rootClassName="gn-data-grid-column-meta-tooltip" styles={{ root: { maxWidth: 640 } }} {...(!darkMode ? { color: 'rgba(0, 0, 0, 0.82)' } : {})} > diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index f3a6795..3ddcbfc 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -7,6 +7,7 @@ import Sidebar, { buildSidebarTableChildrenForUi, buildV2SidebarTableSectionedChildren, buildV2RailConnectionGroups, + estimateV2TreeHorizontalScrollWidth, filterV2ExplorerTreeByKind, getV2RailConnectionGroupBadgeText, hasSidebarLazyChildren, @@ -176,6 +177,12 @@ vi.mock('../../wailsjs/go/app/App', () => ({ SelectSQLDirectory: mocks.noop, ListSQLDirectory: mocks.noop, ReadSQLFile: mocks.noop, + CreateSQLFile: mocks.noop, + CreateSQLDirectory: mocks.noop, + DeleteSQLFile: mocks.noop, + DeleteSQLDirectory: mocks.noop, + RenameSQLFile: mocks.noop, + RenameSQLDirectory: mocks.noop, JVMProbeCapabilities: mocks.noop, GetDriverStatusList: mocks.noop, })); @@ -368,10 +375,47 @@ describe('Sidebar locate toolbar', () => { const locateActionIndex = markup.indexOf('data-sidebar-locate-current-tab-action="true"'); expect(markup).toContain('data-sidebar-locate-current-tab-action="true"'); - expect(markup).toContain('aria-label="定位当前打开表"'); + expect(markup).toContain('aria-label="定位当前标签页"'); expect(locateActionIndex).toBeGreaterThan(externalSqlActionIndex); }); + it('wires external SQL directory file actions to dedicated Wails APIs', () => { + const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); + const loadTablesSource = source.slice( + source.indexOf('const loadTables = async'), + source.indexOf('const locateObjectInSidebarRef'), + ); + + expect(source).toContain('CreateSQLFile(directoryPath, name)'); + expect(source).toContain('RenameSQLFile(filePath, name)'); + expect(source).toContain('DeleteSQLFile(filePath)'); + expect(source).toContain('CreateSQLDirectory(directoryPath, name)'); + expect(source).toContain('RenameSQLDirectory(directoryPath, name)'); + expect(source).toContain('DeleteSQLDirectory(directoryPath)'); + expect(source).toContain('refreshGlobalExternalSQLRootNode(false)'); + expect(source).toContain("request.objectGroup === 'externalSqlFiles'"); + expect(source).toContain('SQL 文件未在外部 SQL 目录中找到'); + expect(source).toContain('filePath: data.filePath || undefined'); + expect(source).toContain("key: 'add-external-sql-directory'"); + expect(source).toContain("key: 'new-external-sql-file'"); + expect(source).toContain("key: 'rename-external-sql-file'"); + expect(source).toContain("key: 'delete-external-sql-file'"); + expect(source).toContain("key: 'new-external-sql-directory'"); + expect(source).toContain("key: 'rename-external-sql-directory'"); + expect(source).toContain("key: 'delete-external-sql-directory'"); + expect(source).toContain('新建 SQL 文件'); + expect(source).toContain('重命名 SQL 文件'); + expect(source).toContain('确认删除 SQL 文件'); + expect(source).toContain('新建目录'); + expect(source).toContain('重命名目录'); + expect(source).toContain('确认删除目录'); + expect(source).toContain('仅支持删除空目录'); + expect(source).toContain('文件名不能包含路径分隔符'); + expect(source).toContain('目录名不能包含路径分隔符'); + expect(loadTablesSource).not.toContain('externalSQLRootNode'); + expect(loadTablesSource).not.toContain('dbExternalSQLDirectories'); + }); + it('keeps the legacy sidebar toolbar on a stable five-column grid layout', () => { const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); const markup = renderToStaticMarkup(); @@ -509,14 +553,25 @@ describe('Sidebar locate toolbar', () => { expect(css).not.toContain('.gn-v2-active-connection-trigger:hover'); }); - it('keeps v2 tree status dots circular while truncating only the label text', () => { + it('keeps v2 tree status dots circular while using virtual horizontal scroll for long labels', () => { const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'); const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); expect(source).toContain('gn-v2-tree-status is-${status}'); expect(source).toContain('data-sidebar-tree-folder-icon="true"'); + expect(source).toContain("overflow: 'hidden'"); + expect(source).not.toContain("overflowX: isV2Ui ? 'auto' : 'hidden'"); + expect(source).toContain('scrollWidth={isV2Ui ? v2TreeHorizontalScrollWidth : undefined}'); + expect(css).toMatch(/\.gn-v2-explorer-tree-shell \{[^}]*overflow: hidden !important;/s); + expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree \{[^}]*width: 100%;[^}]*min-width: 0;/s); + expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-holder-inner \{[^}]*width: 100%;[^}]*min-width: 100%;/s); + expect(css).not.toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-holder-inner \{[^}]*width: max-content;/s); + expect(css).not.toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-holder \{[^}]*overflow-x: auto !important;/s); + expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-scrollbar-horizontal \{[^}]*height: 12px !important;/s); + expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-list-scrollbar-horizontal \.ant-tree-list-scrollbar-thumb \{[^}]*height: 8px !important;/s); + expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-node-content-wrapper \{[^}]*display: flex !important;/s); expect(css).toMatch(/\.gn-v2-tree-title\.is-connection \{[^}]*align-items:\s*center;/s); - expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title \{[^}]*overflow: visible;/s); + expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title \{[^}]*flex: 1 1 auto;[^}]*overflow: visible;/s); expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title > \.gn-v2-tree-title \{[^}]*overflow: visible;/s); expect(css).toMatch(/\.gn-v2-tree-status \{[^}]*width: 14px;[^}]*height: 14px;[^}]*flex: 0 0 14px;[^}]*overflow: visible;/s); expect(css).toMatch(/\.gn-v2-tree-status::before \{[^}]*width: 7px;[^}]*height: 7px;[^}]*border-radius: 50%;/s); @@ -526,6 +581,32 @@ describe('Sidebar locate toolbar', () => { expect(css).not.toContain('.gn-v2-tree-connection-meta'); }); + it('estimates a v2 tree scroll width only when content is wider than the viewport', () => { + const narrowWidth = estimateV2TreeHorizontalScrollWidth([ + { + title: 'front_end_sys', + key: 'db-front-end', + type: 'database', + children: [{ + title: 'com_vod_error_file_tmp_with_a_very_long_table_name', + key: 'table-long', + type: 'table', + }], + }, + ] as any, 260); + const wideWidth = estimateV2TreeHorizontalScrollWidth([ + { + title: 'users', + key: 'table-users', + type: 'table', + }, + ] as any, 900); + + expect(narrowWidth).toBeGreaterThan(260); + expect(narrowWidth).toBeLessThanOrEqual(960); + expect(wideWidth).toBeUndefined(); + }); + it('does not repeat the active connection as an object-tree root in v2', () => { mocks.state.connections = [{ id: 'conn-local', diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0c29332..f06a07b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -53,9 +53,9 @@ import { useStore, } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; - import { SavedConnection, SavedQuery, ConnectionTag, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; + import { SavedConnection, SavedQuery, ConnectionTag, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types'; import { getDbIcon } from './DatabaseIcons'; - import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App'; + import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, 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'; @@ -114,6 +114,10 @@ type SidebarContextMenuState = { const SIDEBAR_CONTEXT_MENU_SAFE_GAP = 8; const SIDEBAR_CONTEXT_MENU_FALLBACK_WIDTH = 264; const SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT = 420; +type ExternalSQLFileModalMode = 'create' | 'rename' | 'create-directory' | 'rename-directory'; + +const isExternalSQLDirectoryModalMode = (mode: ExternalSQLFileModalMode): boolean => + mode === 'create-directory' || mode === 'rename-directory'; export const resolveSidebarContextMenuPosition = ( x: number, @@ -171,6 +175,7 @@ export const shouldLoadSidebarNodeOnExpand = ( if (!node || node.isLeaf === true || hasSidebarLazyChildren(node.children)) return false; return node.type === 'connection' || node.type === 'database' + || node.type === 'external-sql-root' || node.type === 'table' || node.type === 'jvm-mode' || node.type === 'jvm-resource'; @@ -417,6 +422,46 @@ const V2_EXPLORER_FILTER_GROUP_KEYS: Record, st events: ['events'], }; +const V2_TREE_HORIZONTAL_SCROLL_MAX_WIDTH = 960; +const V2_TREE_HORIZONTAL_SCROLL_BASE_WIDTH = 88; +const V2_TREE_HORIZONTAL_SCROLL_INDENT_WIDTH = 24; +const V2_TREE_HORIZONTAL_SCROLL_AVG_CHAR_WIDTH = 8; +const V2_TREE_HORIZONTAL_SCROLL_VIEWPORT_BUFFER = 48; + +export const estimateV2TreeHorizontalScrollWidth = ( + nodes: TreeNode[], + viewportWidth: number, +): number | undefined => { + const safeViewportWidth = Math.max(0, Math.ceil(viewportWidth || 0)); + let estimatedContentWidth = safeViewportWidth; + + const visit = (items: TreeNode[], depth: number) => { + items.forEach((node) => { + const title = String(node?.title || ''); + const metaText = node?.dataRef?.groupKey === 'tables' && Array.isArray(node.children) + ? String(node.children.length) + : ''; + const nodeWidth = V2_TREE_HORIZONTAL_SCROLL_BASE_WIDTH + + (depth * V2_TREE_HORIZONTAL_SCROLL_INDENT_WIDTH) + + ((title.length + metaText.length) * V2_TREE_HORIZONTAL_SCROLL_AVG_CHAR_WIDTH); + estimatedContentWidth = Math.max(estimatedContentWidth, nodeWidth); + if (node.children?.length) { + visit(node.children, depth + 1); + } + }); + }; + visit(nodes, 0); + + if (estimatedContentWidth <= safeViewportWidth + 8) { + return undefined; + } + const scrollWidth = Math.min( + V2_TREE_HORIZONTAL_SCROLL_MAX_WIDTH, + Math.max(safeViewportWidth + V2_TREE_HORIZONTAL_SCROLL_VIEWPORT_BUFFER, Math.ceil(estimatedContentWidth)), + ); + return scrollWidth; +}; + export const filterV2ExplorerTreeByKind = ( nodes: TreeNode[], filter: V2ExplorerFilter, @@ -432,6 +477,9 @@ export const filterV2ExplorerTreeByKind = ( }; const visit = (node: TreeNode): TreeNode | null => { + if (node.type === 'external-sql-root') { + return node; + } const groupKey = String(node?.dataRef?.groupKey || ''); if (node.type === 'object-group') { if (allowedGroupKeys.has(groupKey)) { @@ -1048,9 +1096,11 @@ const Sidebar: React.FC<{ // 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(() => { @@ -1062,6 +1112,7 @@ const Sidebar: React.FC<{ const resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { setTreeHeight(entry.contentRect.height); + setTreeViewportWidth(entry.contentRect.width); } }); resizeObserver.observe(treeContainerRef.current); @@ -1120,6 +1171,10 @@ const Sidebar: React.FC<{ 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); @@ -1196,7 +1251,7 @@ const Sidebar: React.FC<{ loadTables(node); } }); - }, [autoFetchVisible, externalSQLDirectories, savedQueries]); + }, [autoFetchVisible, savedQueries]); useEffect(() => { const previousSignatures = connectionReloadSignaturesRef.current; @@ -1328,7 +1383,8 @@ const Sidebar: React.FC<{ orderedNodes.push(...Array.from(tagNodesById.values())); orderedNodes.push(...Array.from(ungroupedNodesById.values())); - return orderedNodes; + const externalSQLRootNode = prev.find((node) => node.type === 'external-sql-root'); + return externalSQLRootNode ? [...orderedNodes, externalSQLRootNode] : orderedNodes; }); }, [connections, connectionTags, sidebarRootOrder]); @@ -1434,6 +1490,51 @@ const Sidebar: React.FC<{ }; }; + 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: `SQL 目录读取失败: ${directory.name} (${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('外部 SQL 目录已刷新'); + } + }, [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') { @@ -2414,8 +2515,6 @@ const Sidebar: React.FC<{ loadingNodesRef.current.add(loadKey); const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName); - const dbExternalSQLDirectories = useStore.getState().externalSQLDirectories.filter(directory => directory.connectionId === conn.id && directory.dbName === dbName); - const queriesNode: TreeNode = { title: '已存查询', key: `${key}-queries`, @@ -2483,34 +2582,6 @@ const Sidebar: React.FC<{ loadFunctions(conn, conn.dbName), loadDatabaseEvents(conn, conn.dbName), ]); - const externalSQLDirectoryResults = await Promise.all( - dbExternalSQLDirectories.map(async (directory) => { - const directoryRes = await ListSQLDirectory(directory.path); - if (!directoryRes.success) { - message.warning({ - key: `external-sql-${directory.id}`, - content: `SQL 目录读取失败: ${directory.name} (${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: dbExternalSQLDirectories, - directoryTrees: externalSQLTrees, - })); - const viewRows: string[] = Array.isArray(viewsResult.views) ? viewsResult.views : []; const materializedViewRows: string[] = Array.isArray(materializedViewsResult.views) ? materializedViewsResult.views : []; const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : []; @@ -2793,7 +2864,7 @@ const Sidebar: React.FC<{ }; }); - replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...schemaNodes]); + replaceTreeNodeChildren(key, [queriesNode, ...schemaNodes]); } else { const includeMaterializedViews = getMetadataDialect(conn as SavedConnection) === 'starrocks'; const includeEvents = supportsDatabaseEvents(conn as SavedConnection); @@ -2806,7 +2877,7 @@ const Sidebar: React.FC<{ ...(includeEvents ? [buildObjectGroup(key as string, 'events', '事件', , eventEntries.map(buildEventNode))] : []), ]; - replaceTreeNodeChildren(key, [queriesNode, externalSQLRootNode, ...groupedNodes]); + replaceTreeNodeChildren(key, [queriesNode, ...groupedNodes]); } } else { setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); @@ -2831,7 +2902,30 @@ const Sidebar: React.FC<{ const locateObjectInSidebar = async (detail: unknown) => { const request = normalizeSidebarLocateObjectRequest(detail); if (!request) { - message.warning('当前标签页没有可定位的表上下文'); + message.warning('当前标签页没有可定位的上下文'); + 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(`SQL 文件未在外部 SQL 目录中找到:${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; } @@ -2896,7 +2990,7 @@ const Sidebar: React.FC<{ const handleLocateActiveTabInSidebar = () => { if (!activeTabLocateRequest) { - message.warning('当前标签页没有可定位的表上下文'); + message.warning('当前标签页没有可定位的上下文'); return; } void locateObjectInSidebar(activeTabLocateRequest); @@ -2943,6 +3037,8 @@ const Sidebar: React.FC<{ 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; @@ -3053,8 +3149,6 @@ const Sidebar: React.FC<{ setActiveContext({ connectionId: nodeConnectionId || dataRef.id, dbName: dataRef.dbName }); } else if (type === 'saved-query') { setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName }); - } else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') { - setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName }); } else if (type === 'redis-db') { setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` }); } @@ -3113,7 +3207,6 @@ const Sidebar: React.FC<{ } 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 === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName }); else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` }); if (node.type === 'table') { @@ -3815,9 +3908,9 @@ const Sidebar: React.FC<{ const handleRunSQLFile = async (node: any) => { const res = await OpenSQLFile(); if (res.success) { - const data = res.data; + const data = normalizeSQLFileDialogData(res.data); // 大文件:后端返回文件路径,走流式执行 - if (data && typeof data === 'object' && data.isLargeFile) { + 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); @@ -3825,19 +3918,20 @@ const Sidebar: React.FC<{ message.error('未找到对应的连接配置'); return; } - startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB); + startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB || ''); return; } // 小文件:加载到编辑器 - const sqlContent = data; const { dbName, id } = node.dataRef; + const connectionId = node.type === 'connection' ? String(node.key) : String(id || node.dataRef.id || ''); addTab({ - id: `query-${Date.now()}`, - title: `运行外部SQL文件`, + id: data.filePath ? buildExternalSQLTabId(connectionId, dbName || '', data.filePath) : `query-${Date.now()}`, + title: data.fileName || `运行外部SQL文件`, type: 'query', - connectionId: node.type === 'connection' ? node.key : node.dataRef.id, + connectionId, dbName: dbName, - query: sqlContent + query: data.content, + filePath: data.filePath || undefined, }); } else if (res.message !== '已取消') { message.error('读取文件失败: ' + res.message); @@ -3852,25 +3946,26 @@ const Sidebar: React.FC<{ } const res = await OpenSQLFile(); if (res.success) { - const data = res.data; + const data = normalizeSQLFileDialogData(res.data); // 大文件:后端流式执行 - if (data && typeof data === 'object' && data.isLargeFile) { + if (data.isLargeFile) { const conn = connections.find(c => c.id === ctx.connectionId); if (!conn) { message.error('未找到对应的连接配置'); return; } - startSQLFileExecution(conn.config, ctx.dbName || '', data.filePath, data.fileSizeMB); + startSQLFileExecution(conn.config, ctx.dbName || '', data.filePath, data.fileSizeMB || ''); return; } // 小文件 addTab({ - id: `query-${Date.now()}`, - title: `运行外部SQL文件`, + id: data.filePath ? buildExternalSQLTabId(ctx.connectionId, ctx.dbName || '', data.filePath) : `query-${Date.now()}`, + title: data.fileName || `运行外部SQL文件`, type: 'query', connectionId: ctx.connectionId, dbName: ctx.dbName || undefined, - query: data + query: data.content, + filePath: data.filePath || undefined, }); } else if (res.message !== '已取消') { message.error('读取文件失败: ' + res.message); @@ -3944,13 +4039,80 @@ const Sidebar: React.FC<{ } }; + 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() || '运行外部SQL文件').trim(), + isLargeFile: payload.isLargeFile === true, + fileSizeMB: String(payload.fileSizeMB || '').trim() || undefined, + }; + } + return { + content: String(data || ''), + filePath: '', + fileName: '运行外部SQL文件', + isLargeFile: false, + }; + }; + const openExternalSQLFile = async (fileNode: any) => { - const connectionId = String(fileNode?.dataRef?.connectionId || '').trim(); - const dbName = String(fileNode?.dataRef?.dbName || '').trim(); + 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 || 'SQL文件').trim() || 'SQL文件'; - if (!connectionId || !dbName || !filePath) { - message.error('SQL 文件上下文不完整,无法打开'); + if (!filePath) { + message.error('SQL 文件路径不完整,无法打开'); return; } @@ -3964,6 +4126,10 @@ const Sidebar: React.FC<{ const data = res.data; if (data && typeof data === 'object' && data.isLargeFile) { + if (!connectionId) { + message.warning('请先选择一个 Host 后再执行大 SQL 文件'); + return; + } const conn = connections.find((item) => item.id === connectionId); if (!conn) { message.error('未找到对应的连接配置'); @@ -3978,22 +4144,226 @@ const Sidebar: React.FC<{ title: fileName, type: 'query', connectionId, - dbName, + dbName: dbName || undefined, query: String(data || ''), filePath, }); }; - const handleAddExternalSQLDirectory = async (node: any) => { - const context = getNodeDatabaseContext(node); - if (!context?.connectionId || !context?.dbName || !context?.dbNodeKey) { - message.warning('请在具体数据库下添加外部 SQL 目录'); + const openCreateExternalSQLFileModal = (node: any) => { + const directoryPath = getExternalSQLParentDirectoryPath(node); + if (!directoryPath) { + message.error('未找到可新建 SQL 文件的目录'); + 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('未找到可重命名的 SQL 文件'); + return; + } + setExternalSQLFileModalMode('rename'); + setExternalSQLFileTarget(node); + externalSQLFileForm.setFieldsValue({ name: currentName }); + setIsExternalSQLFileModalOpen(true); + }; + + const openCreateExternalSQLDirectoryModal = (node: any) => { + const directoryPath = getExternalSQLParentDirectoryPath(node); + if (!directoryPath) { + message.error('未找到可新建目录的位置'); + 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('未找到可重命名的目录'); + 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(isDirectoryMode ? '目录名不能为空' : 'SQL 文件名不能为空'); + return; + } + + if (externalSQLFileModalMode === 'create') { + const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); + if (!directoryPath) { + message.error('未找到可新建 SQL 文件的目录'); + return; + } + const res = await CreateSQLFile(directoryPath, name); + if (!res.success) { + message.error('新建 SQL 文件失败: ' + res.message); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success('SQL 文件已新建'); + } else if (externalSQLFileModalMode === 'rename') { + const filePath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); + if (!filePath) { + message.error('未找到可重命名的 SQL 文件'); + return; + } + const res = await RenameSQLFile(filePath, name); + if (!res.success) { + message.error('重命名 SQL 文件失败: ' + res.message); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success('SQL 文件已重命名'); + } else if (externalSQLFileModalMode === 'create-directory') { + const directoryPath = getExternalSQLParentDirectoryPath(externalSQLFileTarget); + if (!directoryPath) { + message.error('未找到可新建目录的位置'); + return; + } + const res = await CreateSQLDirectory(directoryPath, name); + if (!res.success) { + message.error('新建目录失败: ' + res.message); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success('目录已新建'); + } else { + const directoryPath = String(externalSQLFileTarget?.dataRef?.path || '').trim(); + if (!directoryPath) { + message.error('未找到可重命名的目录'); + return; + } + const res = await RenameSQLDirectory(directoryPath, name); + if (!res.success) { + message.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('目录已重命名,但无法同步外部 SQL 目录列表,请重新添加目录'); + await refreshGlobalExternalSQLRootNode(false); + return; + } + const nextDirectory: ExternalSQLDirectory = { + id: buildExternalSQLDirectoryId('', '', nextPath), + name: nextName || nextPath.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录', + 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('目录已重命名'); + } + + 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 || 'SQL 文件').trim(); + if (!filePath) { + message.error('未找到可删除的 SQL 文件'); return; } - const currentDirectory = externalSQLDirectories.find((item) => - item.connectionId === context.connectionId && item.dbName === context.dbName, - )?.path || ''; + Modal.confirm({ + title: '确认删除 SQL 文件', + content: `确定删除 "${fileName}" 吗?该操作会删除本地磁盘文件,无法恢复。`, + okText: '删除', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: async () => { + const res = await DeleteSQLFile(filePath); + if (!res.success) { + message.error('删除 SQL 文件失败: ' + res.message); + return; + } + await refreshGlobalExternalSQLRootNode(false); + message.success('SQL 文件已删除'); + }, + }); + }; + + const handleDeleteExternalSQLDirectory = (node: any) => { + const directoryPath = String(node?.dataRef?.path || '').trim(); + const directoryName = String(node?.dataRef?.name || node?.title || '目录').trim(); + if (!directoryPath) { + message.error('未找到可删除的目录'); + return; + } + + Modal.confirm({ + title: '确认删除目录', + content: `确定删除 "${directoryName}" 吗?该操作会删除本地磁盘目录,且仅支持删除空目录。`, + okText: '删除', + cancelText: '取消', + okButtonProps: { danger: true }, + onOk: async () => { + const res = await DeleteSQLDirectory(directoryPath); + if (!res.success) { + message.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('目录已删除'); + }, + }); + }; + + const handleAddExternalSQLDirectory = async (node: any) => { + const currentDirectory = externalSQLDirectories[0]?.path || ''; const selection = await SelectSQLDirectory(currentDirectory); if (!selection.success) { if (selection.message !== '已取消') { @@ -4010,42 +4380,39 @@ const Sidebar: React.FC<{ return; } - const directoryId = buildExternalSQLDirectoryId(context.connectionId, context.dbName, path); - saveExternalSQLDirectory({ + const directoryId = buildExternalSQLDirectoryId('', '', path); + const nextDirectory: ExternalSQLDirectory = { id: directoryId, name: name || path.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录', path, - connectionId: context.connectionId, - dbName: context.dbName, createdAt: Date.now(), - }); + }; + saveExternalSQLDirectory(nextDirectory); - setExpandedKeys((prev) => Array.from(new Set([...prev, context.dbNodeKey, `${context.dbNodeKey}-external-sql`]))); + 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 refreshDatabaseNode(context.dbNodeKey); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); message.success('外部 SQL 目录已添加'); }; const handleRemoveExternalSQLDirectory = async (node: any) => { const directoryId = String(node?.dataRef?.id || '').trim(); - const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim(); if (!directoryId) { message.error('未找到可移除的 SQL 目录'); return; } deleteExternalSQLDirectory(directoryId); - await refreshDatabaseNode(dbNodeKey); + const nextDirectories = externalSQLDirectories.filter((item) => item.id !== directoryId); + await refreshGlobalExternalSQLRootNode(false, nextDirectories); message.success('外部 SQL 目录已移除'); }; const handleRefreshExternalSQLDirectory = async (node: any) => { - const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim(); - if (!dbNodeKey) { - message.warning('当前目录缺少数据库上下文,无法刷新'); - return; - } - await refreshDatabaseNode(dbNodeKey); - message.success('外部 SQL 目录已刷新'); + await refreshGlobalExternalSQLRootNode(true); }; const handleCreateDatabase = async () => { @@ -5394,6 +5761,10 @@ const Sidebar: React.FC<{ 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; }; @@ -5413,7 +5784,10 @@ const Sidebar: React.FC<{ const shouldKeepFullSubtree = isSmartMode || item.type === 'connection' || item.type === 'database' - || item.type === 'tag'; + || 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) { @@ -5672,12 +6046,14 @@ const Sidebar: React.FC<{ 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 - : []; + return [ + ...(activeConnectionNode.children && activeConnectionNode.children.length > 0 ? activeConnectionNode.children : []), + ...externalSQLNodes, + ]; } const filterTree = (nodes: TreeNode[]): TreeNode[] => nodes.flatMap((node) => { if (node.type === 'tag') { @@ -5691,7 +6067,7 @@ const Sidebar: React.FC<{ }); const filtered = filterTree(displayTreeData); - return filtered; + return [...filtered, ...externalSQLNodes]; }, [activeConnection, displayTreeData]); const v2VisibleTreeData = useMemo(() => { if (v2ExplorerFilter === 'all') { @@ -5699,6 +6075,10 @@ const Sidebar: React.FC<{ } return filterV2ExplorerTreeByKind(activeConnectionTreeData, v2ExplorerFilter); }, [activeConnectionTreeData, displayTreeData, v2ExplorerFilter]); + const v2TreeHorizontalScrollWidth = useMemo( + () => estimateV2TreeHorizontalScrollWidth(v2VisibleTreeData, treeViewportWidth), + [treeViewportWidth, v2VisibleTreeData], + ); const v2TreeMetrics = useMemo(() => { const databaseTableCounts = new Map(); const objectGroupCounts = new Map(); @@ -7370,6 +7750,31 @@ const Sidebar: React.FC<{ if (node.type === 'external-sql-directory') { return [ + { + key: 'new-external-sql-file', + label: '新建 SQL 文件', + icon: , + onClick: () => { + openCreateExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-directory', + label: '新建目录', + icon: , + onClick: () => { + openCreateExternalSQLDirectoryModal(node); + } + }, + { + key: 'rename-external-sql-directory', + label: '重命名目录', + icon: , + onClick: () => { + openRenameExternalSQLDirectoryModal(node); + } + }, + { type: 'divider' }, { key: 'refresh-external-sql-directory', label: '刷新目录', @@ -7387,6 +7792,62 @@ const Sidebar: React.FC<{ onClick: () => { void handleRemoveExternalSQLDirectory(node); } + }, + { + key: 'delete-external-sql-directory', + label: '删除本地目录', + icon: , + danger: true, + onClick: () => { + handleDeleteExternalSQLDirectory(node); + } + } + ]; + } + + if (node.type === 'external-sql-folder') { + return [ + { + key: 'new-external-sql-file', + label: '新建 SQL 文件', + icon: , + onClick: () => { + openCreateExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-directory', + label: '新建目录', + icon: , + onClick: () => { + openCreateExternalSQLDirectoryModal(node); + } + }, + { + key: 'rename-external-sql-directory', + label: '重命名目录', + icon: , + onClick: () => { + openRenameExternalSQLDirectoryModal(node); + } + }, + { + key: 'refresh-external-sql-directory', + label: '刷新目录', + icon: , + onClick: () => { + void handleRefreshExternalSQLDirectory(node); + } + }, + { type: 'divider' }, + { + key: 'delete-external-sql-directory', + label: '删除目录', + icon: , + danger: true, + onClick: () => { + handleDeleteExternalSQLDirectory(node); + } } ]; } @@ -7400,6 +7861,40 @@ const Sidebar: React.FC<{ onClick: () => { void openExternalSQLFile(node); } + }, + { + key: 'rename-external-sql-file', + label: '重命名 SQL 文件', + icon: , + onClick: () => { + openRenameExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-file-sibling', + label: '在此目录新建 SQL 文件', + icon: , + onClick: () => { + openCreateExternalSQLFileModal(node); + } + }, + { + key: 'new-external-sql-directory-sibling', + label: '在此目录新建目录', + icon: , + onClick: () => { + openCreateExternalSQLDirectoryModal(node); + } + }, + { type: 'divider' }, + { + key: 'delete-external-sql-file', + label: '删除 SQL 文件', + icon: , + danger: true, + onClick: () => { + handleDeleteExternalSQLFile(node); + } } ]; } @@ -7903,13 +8398,13 @@ const Sidebar: React.FC<{ - +