mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-09 16:09:41 +08:00
✨ feat(sql-files): 支持外部 SQL 目录树与双击打开
- 新增 SQL 目录选择、枚举与按路径读取接口,复用大文件执行能力 - Sidebar 增加外部 SQL 文件目录树、目录管理入口与双击打开查询标签 - 补充 external SQL 持久化与前后端回归测试 Fixes #319
This commit is contained in:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user