From 65a9f4352eb6260bf61c8f0b4248b8160470d5e0 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 17 Apr 2026 21:02:48 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sql-files):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=96=E9=83=A8=20SQL=20=E7=9B=AE=E5=BD=95=E6=A0=91?= =?UTF-8?q?=E4=B8=8E=E5=8F=8C=E5=87=BB=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SQL 目录选择、枚举与按路径读取接口,复用大文件执行能力 - Sidebar 增加外部 SQL 文件目录树、目录管理入口与双击打开查询标签 - 补充 external SQL 持久化与前后端回归测试 Fixes #319 --- frontend/src/components/Sidebar.tsx | 325 ++++++++++++++++-- frontend/src/main.tsx | 3 + frontend/src/store.test.ts | 48 +++ frontend/src/store.ts | 70 +++- frontend/src/types.ts | 16 + frontend/src/utils/externalSqlTree.test.ts | 67 ++++ frontend/src/utils/externalSqlTree.ts | 131 +++++++ frontend/wailsjs/go/app/App.d.ts | 6 + frontend/wailsjs/go/app/App.js | 12 + internal/app/methods_file.go | 175 ++++++++-- .../app/methods_file_sql_directory_test.go | 73 ++++ 11 files changed, 879 insertions(+), 47 deletions(-) create mode 100644 frontend/src/utils/externalSqlTree.test.ts create mode 100644 frontend/src/utils/externalSqlTree.ts create mode 100644 internal/app/methods_file_sql_directory_test.go diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ce15041..34f3cd0 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -36,9 +36,9 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, } from '@ant-design/icons'; import { useStore } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; - import { SavedConnection } from '../types'; + import { SavedConnection, ExternalSQLTreeEntry } from '../types'; import { getDbIcon } from './DatabaseIcons'; - import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App'; + import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile } 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'; @@ -49,6 +49,7 @@ import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata'; import { resolveConnectionHostTokens } from '../utils/tabDisplay'; import { buildTableSelectQuery } from '../utils/objectQueryTemplates'; +import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree'; const { Search } = Input; @@ -59,7 +60,7 @@ interface TreeNode { children?: TreeNode[]; icon?: React.ReactNode; dataRef?: any; - type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag'; + type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag'; } type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly'; @@ -118,7 +119,10 @@ const normalizeMySQLViewDDLForEditing = (viewName: string, rawDefinition: unknow const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => { const connections = useStore(state => state.connections); const savedQueries = useStore(state => state.savedQueries); + const externalSQLDirectories = useStore(state => state.externalSQLDirectories); 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 setActiveContext = useStore(state => state.setActiveContext); @@ -324,25 +328,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return; } - // Refresh queries for expanded databases - const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => { - for (const node of nodes) { - if (node.key === k) return node; - if (node.children) { - const res = findNode(node.children, k); - if (res) return res; - } - } - return null; - }; - expandedKeys.forEach(key => { - const node = findNode(treeData, key); + const node = findTreeNodeByKey(treeData, key); if (node && node.type === 'database') { loadTables(node); } }); - }, [autoFetchVisible, savedQueries]); + }, [autoFetchVisible, externalSQLDirectories, savedQueries]); useEffect(() => { setTreeData((prev) => { @@ -431,6 +423,68 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }); }; + 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; + }; + + 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 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 SIDEBAR_SCHEMA_DB_TYPES = new Set([ 'postgres', 'kingbase', @@ -997,6 +1051,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> 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: '已存查询', @@ -1038,11 +1093,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; }); - const [viewsResult, triggersResult, routinesResult] = await Promise.all([ - loadViews(conn, conn.dbName), - loadDatabaseTriggers(conn, conn.dbName), - loadFunctions(conn, conn.dbName), - ]); + const [viewsResult, triggersResult, routinesResult] = await Promise.all([ + loadViews(conn, conn.dbName), + loadDatabaseTriggers(conn, conn.dbName), + loadFunctions(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 triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : []; @@ -1258,7 +1340,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }; }); - setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...schemaNodes])); + setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...schemaNodes])); } else { const groupedNodes: TreeNode[] = [ buildObjectGroup(key as string, 'tables', '表', , tableEntries.map(buildTableNode)), @@ -1267,7 +1349,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> buildObjectGroup(key as string, 'triggers', '触发器', , triggerEntries.map(buildTriggerNode)), ]; - setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes])); + setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...groupedNodes])); } } else { setConnectionStates(prev => ({ ...prev, [key as string]: 'error' })); @@ -1383,6 +1465,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> setActiveContext({ connectionId: 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}` }); } @@ -1425,6 +1509,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName }); else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: 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') { @@ -1463,6 +1548,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> 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({ @@ -2139,6 +2227,119 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> }); }; + const refreshDatabaseNode = async (dbNodeKey: string) => { + if (!dbNodeKey) { + return; + } + const dbNode = findTreeNodeByKey(treeData, dbNodeKey); + if (dbNode && dbNode.type === 'database') { + await loadTables(dbNode); + } + }; + + const openExternalSQLFile = async (fileNode: any) => { + const connectionId = String(fileNode?.dataRef?.connectionId || '').trim(); + const dbName = String(fileNode?.dataRef?.dbName || '').trim(); + 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 文件上下文不完整,无法打开'); + return; + } + + const res = await ReadSQLFile(filePath); + if (!res.success) { + if (res.message !== '已取消') { + message.error('读取 SQL 文件失败: ' + res.message); + } + return; + } + + const data = res.data; + if (data && typeof data === 'object' && data.isLargeFile) { + const conn = connections.find((item) => item.id === connectionId); + if (!conn) { + message.error('未找到对应的连接配置'); + return; + } + startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB); + return; + } + + addTab({ + id: buildExternalSQLTabId(connectionId, dbName, filePath), + title: fileName, + type: 'query', + connectionId, + dbName, + query: String(data || ''), + }); + }; + + const handleAddExternalSQLDirectory = async (node: any) => { + const context = getNodeDatabaseContext(node); + if (!context?.connectionId || !context?.dbName || !context?.dbNodeKey) { + message.warning('请在具体数据库下添加外部 SQL 目录'); + return; + } + + const currentDirectory = externalSQLDirectories.find((item) => + item.connectionId === context.connectionId && item.dbName === context.dbName, + )?.path || ''; + const selection = await SelectSQLDirectory(currentDirectory); + if (!selection.success) { + if (selection.message !== '已取消') { + message.error('选择 SQL 目录失败: ' + 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('未获取到有效的 SQL 目录路径'); + return; + } + + const directoryId = buildExternalSQLDirectoryId(context.connectionId, context.dbName, path); + saveExternalSQLDirectory({ + id: directoryId, + name: name || path.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录', + path, + connectionId: context.connectionId, + dbName: context.dbName, + createdAt: Date.now(), + }); + + setExpandedKeys((prev) => Array.from(new Set([...prev, context.dbNodeKey, `${context.dbNodeKey}-external-sql`]))); + setAutoExpandParent(false); + await refreshDatabaseNode(context.dbNodeKey); + 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); + 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 目录已刷新'); + }; + const handleCreateDatabase = async () => { try { const values = await createDbForm.validateFields(); @@ -3690,6 +3891,55 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> ]; } + if (node.type === 'external-sql-root') { + return [ + { + key: 'add-external-sql-directory', + label: '添加 SQL 目录', + icon: , + onClick: () => { + void handleAddExternalSQLDirectory(node); + } + } + ]; + } + + if (node.type === 'external-sql-directory') { + return [ + { + key: 'refresh-external-sql-directory', + label: '刷新目录', + icon: , + onClick: () => { + void handleRefreshExternalSQLDirectory(node); + } + }, + { type: 'divider' }, + { + key: 'remove-external-sql-directory', + label: '移除目录', + icon: , + danger: true, + onClick: () => { + void handleRemoveExternalSQLDirectory(node); + } + } + ]; + } + + if (node.type === 'external-sql-file') { + return [ + { + key: 'open-external-sql-file', + label: '打开 SQL 文件', + icon: , + onClick: () => { + void openExternalSQLFile(node); + } + } + ]; + } + return []; }; @@ -3715,6 +3965,33 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> hoverTitle = rawTableName; } } + } else if (node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') { + hoverTitle = String(node?.dataRef?.path || displayTitle); + } + + if (node.type === 'external-sql-root') { + return ( + + + {statusBadge} + {displayTitle} + +