feat(sql-files): 支持外部 SQL 目录树与双击打开

- 新增 SQL 目录选择、枚举与按路径读取接口,复用大文件执行能力
- Sidebar 增加外部 SQL 文件目录树、目录管理入口与双击打开查询标签
- 补充 external SQL 持久化与前后端回归测试

Fixes #319
This commit is contained in:
Syngnat
2026-04-17 21:02:48 +08:00
parent f3b78f9763
commit 65a9f4352e
11 changed files with 879 additions and 47 deletions

View File

@@ -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 <FolderOpenOutlined />;
case 'external-sql-directory':
return <HddOutlined />;
case 'external-sql-folder':
return <FolderOutlined />;
default:
return <FileTextOutlined />;
}
})();
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<Record<string, ExternalSQLTreeEntry[]>>((accumulator, item) => {
accumulator[item.id] = item.entries;
return accumulator;
}, {});
const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({
dbNodeKey: String(key),
connectionId: String(conn.id),
dbName: String(conn.dbName),
directories: 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', '表', <TableOutlined />, tableEntries.map(buildTableNode)),
@@ -1267,7 +1349,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
buildObjectGroup(key as string, 'triggers', '触发器', <FunctionOutlined />, 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<string, unknown> : {};
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: <PlusOutlined />,
onClick: () => {
void handleAddExternalSQLDirectory(node);
}
}
];
}
if (node.type === 'external-sql-directory') {
return [
{
key: 'refresh-external-sql-directory',
label: '刷新目录',
icon: <ReloadOutlined />,
onClick: () => {
void handleRefreshExternalSQLDirectory(node);
}
},
{ type: 'divider' },
{
key: 'remove-external-sql-directory',
label: '移除目录',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
void handleRemoveExternalSQLDirectory(node);
}
}
];
}
if (node.type === 'external-sql-file') {
return [
{
key: 'open-external-sql-file',
label: '打开 SQL 文件',
icon: <ConsoleSqlOutlined />,
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 (
<span
title={hoverTitle}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, width: '100%' }}
>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{statusBadge}
{displayTitle}
</span>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleAddExternalSQLDirectory(node);
}}
style={{ paddingInline: 4, height: 20 }}
/>
</span>
);
}
return <span title={hoverTitle}>{statusBadge}{displayTitle}</span>;