mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +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>;
|
||||
|
||||
@@ -131,6 +131,9 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
|
||||
OpenDriverDownloadDirectory: async (path: string) => ({ success: true, data: { path } }),
|
||||
OpenDataRootDirectory: async () => ({ success: true }),
|
||||
SelectSQLDirectory: async (currentPath: string) => ({ success: false, message: currentPath ? '已取消' : '已取消' }),
|
||||
ListSQLDirectory: async () => ({ success: true, data: [] }),
|
||||
ReadSQLFile: async () => ({ success: false, message: '已取消' }),
|
||||
InstallUpdateAndRestart: async () => ({ success: false }),
|
||||
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
|
||||
ImportConnectionsPayload: async (raw: string, _password?: string) => {
|
||||
|
||||
@@ -139,4 +139,52 @@ describe('store appearance persistence', () => {
|
||||
expect(useStore.getState().globalProxy.password).toBe('proxy-secret');
|
||||
expect(useStore.getState().globalProxy.hasPassword).toBe(true);
|
||||
});
|
||||
|
||||
it('persists external SQL directories and restores valid items after reload', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().saveExternalSQLDirectory({
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
|
||||
expect(persisted.state.externalSQLDirectories).toEqual([
|
||||
{
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
externalSQLDirectories: [
|
||||
persisted.state.externalSQLDirectories[0],
|
||||
{ path: '', name: 'broken' },
|
||||
],
|
||||
},
|
||||
version: 7,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const reloaded = await importStore();
|
||||
expect(reloaded.useStore.getState().externalSQLDirectories).toEqual([
|
||||
{
|
||||
id: 'ext-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem, GlobalProxyConfig } from './types';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery, ConnectionTag, AIChatMessage, AIContextItem, GlobalProxyConfig, ExternalSQLDirectory } from './types';
|
||||
import {
|
||||
ShortcutAction,
|
||||
ShortcutBinding,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
cloneShortcutOptions,
|
||||
sanitizeShortcutOptions,
|
||||
} from './utils/shortcuts';
|
||||
import { buildExternalSQLDirectoryId } from './utils/externalSqlTree';
|
||||
import { toPersistedGlobalProxy } from './utils/globalProxyDraft';
|
||||
import {
|
||||
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||||
@@ -430,6 +431,7 @@ interface AppState {
|
||||
activeTabId: string | null;
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
externalSQLDirectories: ExternalSQLDirectory[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: AppearanceSettings;
|
||||
uiScale: number;
|
||||
@@ -488,6 +490,8 @@ interface AppState {
|
||||
|
||||
saveQuery: (query: SavedQuery) => void;
|
||||
deleteQuery: (id: string) => void;
|
||||
saveExternalSQLDirectory: (directory: ExternalSQLDirectory) => void;
|
||||
deleteExternalSQLDirectory: (id: string) => void;
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<AppearanceSettings>) => void;
|
||||
@@ -553,6 +557,29 @@ const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeExternalSQLDirectories = (value: unknown): ExternalSQLDirectory[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const result: ExternalSQLDirectory[] = [];
|
||||
value.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') return;
|
||||
const raw = entry as Record<string, unknown>;
|
||||
const path = toTrimmedString(raw.path);
|
||||
const connectionId = toTrimmedString(raw.connectionId);
|
||||
const dbName = toTrimmedString(raw.dbName);
|
||||
if (!path || !connectionId || !dbName) return;
|
||||
const fallbackName = path.split(/[\\/]/).filter(Boolean).pop() || `SQL目录-${index + 1}`;
|
||||
result.push({
|
||||
id: toTrimmedString(raw.id, buildExternalSQLDirectoryId(connectionId, dbName, path)) || buildExternalSQLDirectoryId(connectionId, dbName, path),
|
||||
name: toTrimmedString(raw.name, fallbackName) || fallbackName,
|
||||
path,
|
||||
connectionId,
|
||||
dbName,
|
||||
createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(),
|
||||
});
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const hasLegacyConnectionSecrets = (connections: SavedConnection[]): boolean => {
|
||||
return connections.some((connection) => {
|
||||
const config = connection?.config && typeof connection.config === 'object'
|
||||
@@ -809,6 +836,7 @@ export const useStore = create<AppState>()(
|
||||
activeTabId: null,
|
||||
activeContext: null,
|
||||
savedQueries: [],
|
||||
externalSQLDirectories: [],
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
uiScale: DEFAULT_UI_SCALE,
|
||||
@@ -1023,6 +1051,43 @@ export const useStore = create<AppState>()(
|
||||
|
||||
deleteQuery: (id) => set((state) => ({ savedQueries: state.savedQueries.filter(q => q.id !== id) })),
|
||||
|
||||
saveExternalSQLDirectory: (directory) => set((state) => {
|
||||
const path = toTrimmedString(directory.path);
|
||||
const connectionId = toTrimmedString(directory.connectionId);
|
||||
const dbName = toTrimmedString(directory.dbName);
|
||||
if (!path || !connectionId || !dbName) {
|
||||
return state;
|
||||
}
|
||||
const nextDirectory: ExternalSQLDirectory = {
|
||||
id: toTrimmedString(directory.id, buildExternalSQLDirectoryId(connectionId, dbName, path)) || buildExternalSQLDirectoryId(connectionId, dbName, path),
|
||||
name: toTrimmedString(directory.name, path.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录') || 'SQL目录',
|
||||
path,
|
||||
connectionId,
|
||||
dbName,
|
||||
createdAt: Number.isFinite(Number(directory.createdAt)) ? Number(directory.createdAt) : Date.now(),
|
||||
};
|
||||
const existingIndex = state.externalSQLDirectories.findIndex((item) =>
|
||||
item.id === nextDirectory.id
|
||||
|| (
|
||||
item.connectionId === nextDirectory.connectionId
|
||||
&& item.dbName === nextDirectory.dbName
|
||||
&& item.path === nextDirectory.path
|
||||
),
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
return { externalSQLDirectories: [...state.externalSQLDirectories, nextDirectory] };
|
||||
}
|
||||
return {
|
||||
externalSQLDirectories: state.externalSQLDirectories.map((item, index) =>
|
||||
index === existingIndex ? nextDirectory : item,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
deleteExternalSQLDirectory: (id) => set((state) => ({
|
||||
externalSQLDirectories: state.externalSQLDirectories.filter((item) => item.id !== id),
|
||||
})),
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
||||
setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }),
|
||||
@@ -1277,6 +1342,7 @@ export const useStore = create<AppState>()(
|
||||
nextState.connectionTags = sanitizeConnectionTags(state.connectionTags);
|
||||
}
|
||||
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
|
||||
nextState.externalSQLDirectories = sanitizeExternalSQLDirectories(state.externalSQLDirectories);
|
||||
nextState.theme = sanitizeTheme(state.theme);
|
||||
nextState.appearance = sanitizeAppearance(state.appearance, version);
|
||||
nextState.uiScale = sanitizeUiScale(state.uiScale);
|
||||
@@ -1312,6 +1378,7 @@ export const useStore = create<AppState>()(
|
||||
connections: sanitizeConnections(state.connections),
|
||||
connectionTags: sanitizeConnectionTags(state.connectionTags),
|
||||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||||
externalSQLDirectories: sanitizeExternalSQLDirectories(state.externalSQLDirectories),
|
||||
theme: sanitizeTheme(state.theme),
|
||||
appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION),
|
||||
uiScale: sanitizeUiScale(state.uiScale),
|
||||
@@ -1341,6 +1408,7 @@ export const useStore = create<AppState>()(
|
||||
const partialState: Partial<AppState> = {
|
||||
connectionTags: state.connectionTags,
|
||||
savedQueries: state.savedQueries,
|
||||
externalSQLDirectories: state.externalSQLDirectories,
|
||||
theme: state.theme,
|
||||
appearance: state.appearance,
|
||||
uiScale: state.uiScale,
|
||||
|
||||
@@ -166,6 +166,22 @@ export interface SavedQuery {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ExternalSQLDirectory {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ExternalSQLTreeEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
children?: ExternalSQLTreeEntry[];
|
||||
}
|
||||
|
||||
// Redis types
|
||||
export interface RedisKeyInfo {
|
||||
key: string;
|
||||
|
||||
67
frontend/src/utils/externalSqlTree.test.ts
Normal file
67
frontend/src/utils/externalSqlTree.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
|
||||
import { buildExternalSQLRootNode, buildExternalSQLTabId } from './externalSqlTree';
|
||||
|
||||
describe('externalSqlTree helpers', () => {
|
||||
it('builds external SQL root node with nested directory and file entries', () => {
|
||||
const directories: ExternalSQLDirectory[] = [
|
||||
{
|
||||
id: 'dir-1',
|
||||
name: 'scripts',
|
||||
path: 'D:/sql/scripts',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
createdAt: 1,
|
||||
},
|
||||
];
|
||||
const trees: Record<string, ExternalSQLTreeEntry[]> = {
|
||||
'dir-1': [
|
||||
{
|
||||
name: 'ddl',
|
||||
path: 'D:/sql/scripts/ddl',
|
||||
isDir: true,
|
||||
children: [
|
||||
{
|
||||
name: 'init.sql',
|
||||
path: 'D:/sql/scripts/ddl/init.sql',
|
||||
isDir: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const node = buildExternalSQLRootNode({
|
||||
dbNodeKey: 'conn-1-demo',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'demo',
|
||||
directories,
|
||||
directoryTrees: trees,
|
||||
});
|
||||
|
||||
expect(node.type).toBe('external-sql-root');
|
||||
expect(node.children).toHaveLength(1);
|
||||
expect(node.children?.[0]).toMatchObject({
|
||||
title: 'scripts',
|
||||
type: 'external-sql-directory',
|
||||
});
|
||||
expect(node.children?.[0].children?.[0]).toMatchObject({
|
||||
title: 'ddl',
|
||||
type: 'external-sql-folder',
|
||||
});
|
||||
expect(node.children?.[0].children?.[0].children?.[0]).toMatchObject({
|
||||
title: 'init.sql',
|
||||
type: 'external-sql-file',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds query tab ids with connection and database isolation', () => {
|
||||
const first = buildExternalSQLTabId('conn-1', 'demo', 'D:/sql/init.sql');
|
||||
const second = buildExternalSQLTabId('conn-1', 'demo2', 'D:/sql/init.sql');
|
||||
|
||||
expect(first).toContain('conn-1');
|
||||
expect(first).toContain('demo');
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
131
frontend/src/utils/externalSqlTree.ts
Normal file
131
frontend/src/utils/externalSqlTree.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { ExternalSQLDirectory, ExternalSQLTreeEntry } from '../types';
|
||||
|
||||
export type ExternalSQLNodeType =
|
||||
| 'external-sql-root'
|
||||
| 'external-sql-directory'
|
||||
| 'external-sql-folder'
|
||||
| 'external-sql-file';
|
||||
|
||||
export interface ExternalSQLTreeNode {
|
||||
title: string;
|
||||
key: string;
|
||||
isLeaf?: boolean;
|
||||
children?: ExternalSQLTreeNode[];
|
||||
type: ExternalSQLNodeType;
|
||||
dataRef: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type BuildExternalSQLRootNodeParams = {
|
||||
dbNodeKey: string;
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
directories: ExternalSQLDirectory[];
|
||||
directoryTrees: Record<string, ExternalSQLTreeEntry[]>;
|
||||
};
|
||||
|
||||
const normalizeExternalSQLPath = (value: string): string =>
|
||||
String(value || '').trim().replace(/\\/g, '/');
|
||||
|
||||
const resolveDirectoryDisplayName = (directory: ExternalSQLDirectory): string => {
|
||||
const explicitName = String(directory.name || '').trim();
|
||||
if (explicitName) return explicitName;
|
||||
const normalizedPath = normalizeExternalSQLPath(directory.path);
|
||||
const segments = normalizedPath.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] || 'SQL目录';
|
||||
};
|
||||
|
||||
export const buildExternalSQLDirectoryId = (connectionId: string, dbName: string, directoryPath: string): string =>
|
||||
`external-sql-dir:${String(connectionId || '').trim()}:${String(dbName || '').trim()}:${normalizeExternalSQLPath(directoryPath)}`;
|
||||
|
||||
export const buildExternalSQLTabId = (connectionId: string, dbName: string, filePath: string): string =>
|
||||
`external-sql-tab:${String(connectionId || '').trim()}:${String(dbName || '').trim()}:${normalizeExternalSQLPath(filePath)}`;
|
||||
|
||||
const buildExternalSQLNodeKey = (type: ExternalSQLNodeType, base: string): string =>
|
||||
`${type}:${normalizeExternalSQLPath(base)}`;
|
||||
|
||||
const mapExternalSQLTreeEntries = (
|
||||
entries: ExternalSQLTreeEntry[],
|
||||
context: { connectionId: string; dbName: string; dbNodeKey: string; directoryId: string },
|
||||
): ExternalSQLTreeNode[] => entries.map((entry) => {
|
||||
const entryPath = normalizeExternalSQLPath(entry.path);
|
||||
if (entry.isDir) {
|
||||
const children = mapExternalSQLTreeEntries(entry.children || [], context);
|
||||
return {
|
||||
title: entry.name,
|
||||
key: buildExternalSQLNodeKey('external-sql-folder', entryPath),
|
||||
type: 'external-sql-folder',
|
||||
isLeaf: children.length === 0,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
dataRef: {
|
||||
connectionId: context.connectionId,
|
||||
dbName: context.dbName,
|
||||
dbNodeKey: context.dbNodeKey,
|
||||
directoryId: context.directoryId,
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: entry.name,
|
||||
key: buildExternalSQLNodeKey('external-sql-file', entryPath),
|
||||
type: 'external-sql-file',
|
||||
isLeaf: true,
|
||||
dataRef: {
|
||||
connectionId: context.connectionId,
|
||||
dbName: context.dbName,
|
||||
dbNodeKey: context.dbNodeKey,
|
||||
directoryId: context.directoryId,
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const buildExternalSQLRootNode = ({
|
||||
dbNodeKey,
|
||||
connectionId,
|
||||
dbName,
|
||||
directories,
|
||||
directoryTrees,
|
||||
}: BuildExternalSQLRootNodeParams): ExternalSQLTreeNode => {
|
||||
const sortedDirectories = [...directories].sort((left, right) =>
|
||||
resolveDirectoryDisplayName(left).toLowerCase().localeCompare(resolveDirectoryDisplayName(right).toLowerCase()),
|
||||
);
|
||||
|
||||
const children = sortedDirectories.map((directory) => {
|
||||
const directoryChildren = mapExternalSQLTreeEntries(directoryTrees[directory.id] || [], {
|
||||
connectionId,
|
||||
dbName,
|
||||
dbNodeKey,
|
||||
directoryId: directory.id,
|
||||
});
|
||||
return {
|
||||
title: resolveDirectoryDisplayName(directory),
|
||||
key: buildExternalSQLNodeKey('external-sql-directory', directory.id),
|
||||
type: 'external-sql-directory' as const,
|
||||
isLeaf: directoryChildren.length === 0,
|
||||
children: directoryChildren.length > 0 ? directoryChildren : undefined,
|
||||
dataRef: {
|
||||
...directory,
|
||||
connectionId,
|
||||
dbName,
|
||||
dbNodeKey,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
title: children.length > 0 ? `外部 SQL 文件 (${children.length})` : '外部 SQL 文件',
|
||||
key: `${dbNodeKey}-external-sql`,
|
||||
type: 'external-sql-root',
|
||||
isLeaf: children.length === 0,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
dataRef: {
|
||||
connectionId,
|
||||
dbName,
|
||||
dbNodeKey,
|
||||
},
|
||||
};
|
||||
};
|
||||
6
frontend/wailsjs/go/app/App.d.ts
vendored
6
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -149,8 +149,12 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise<connection.Quer
|
||||
|
||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ListSQLDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ReadSQLFile(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:any):Promise<connection.QueryResult>;
|
||||
@@ -219,6 +223,8 @@ export function RetrySecurityUpdateCurrentRound(arg1:app.RetrySecurityUpdateRequ
|
||||
|
||||
export function SaveConnection(arg1:connection.SavedConnectionInput):Promise<connection.SavedConnectionView>;
|
||||
|
||||
export function SelectSQLDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
|
||||
|
||||
export function SelectDataRootDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -290,10 +290,18 @@ export function OpenSQLFile() {
|
||||
return window['go']['app']['App']['OpenSQLFile']();
|
||||
}
|
||||
|
||||
export function ListSQLDirectory(arg1) {
|
||||
return window['go']['app']['App']['ListSQLDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function PreviewImportFile(arg1) {
|
||||
return window['go']['app']['App']['PreviewImportFile'](arg1);
|
||||
}
|
||||
|
||||
export function ReadSQLFile(arg1) {
|
||||
return window['go']['app']['App']['ReadSQLFile'](arg1);
|
||||
}
|
||||
|
||||
export function RedisConnect(arg1) {
|
||||
return window['go']['app']['App']['RedisConnect'](arg1);
|
||||
}
|
||||
@@ -430,6 +438,10 @@ export function SaveConnection(arg1) {
|
||||
return window['go']['app']['App']['SaveConnection'](arg1);
|
||||
}
|
||||
|
||||
export function SelectSQLDirectory(arg1) {
|
||||
return window['go']['app']['App']['SelectSQLDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function SaveGlobalProxy(arg1) {
|
||||
return window['go']['app']['App']['SaveGlobalProxy'](arg1);
|
||||
}
|
||||
|
||||
@@ -29,9 +29,117 @@ import (
|
||||
|
||||
const minExportQueryTimeout = 5 * time.Minute
|
||||
const minClickHouseExportQueryTimeout = 2 * time.Hour
|
||||
const maxSQLFileSizeBytes int64 = 50 * 1024 * 1024
|
||||
|
||||
var mysqlCreateViewPrefixPattern = regexp.MustCompile(`(?is)^\s*create\s+(?:algorithm\s*=\s*\w+\s+)?(?:definer\s*=\s*(?:` + "`[^`]+`" + `|\S+)\s*@\s*(?:` + "`[^`]+`" + `|\S+)\s+)?(?:sql\s+security\s+(?:definer|invoker)\s+)?view\s+`)
|
||||
|
||||
type SQLDirectoryEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Children []SQLDirectoryEntry `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeDirectoryDialogPath(currentDir string) string {
|
||||
defaultDir := strings.TrimSpace(currentDir)
|
||||
if defaultDir == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
defaultDir = home
|
||||
}
|
||||
}
|
||||
if filepath.Ext(defaultDir) != "" {
|
||||
defaultDir = filepath.Dir(defaultDir)
|
||||
}
|
||||
if defaultDir != "" && !filepath.IsAbs(defaultDir) {
|
||||
if abs, err := filepath.Abs(defaultDir); err == nil {
|
||||
defaultDir = abs
|
||||
}
|
||||
}
|
||||
return defaultDir
|
||||
}
|
||||
|
||||
func readSQLFileByPath(filePath string) connection.QueryResult {
|
||||
selection := strings.TrimSpace(filePath)
|
||||
if selection == "" {
|
||||
return connection.QueryResult{Success: false, Message: "文件路径不能为空"}
|
||||
}
|
||||
if abs, err := filepath.Abs(selection); err == nil {
|
||||
selection = abs
|
||||
}
|
||||
|
||||
fi, err := os.Stat(selection)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err)}
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return connection.QueryResult{Success: false, Message: "所选路径不是 SQL 文件"}
|
||||
}
|
||||
|
||||
if fi.Size() > maxSQLFileSizeBytes {
|
||||
sizeMB := float64(fi.Size()) / (1024 * 1024)
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"isLargeFile": true,
|
||||
"filePath": selection,
|
||||
"fileSize": fi.Size(),
|
||||
"fileSizeMB": fmt.Sprintf("%.1f", sizeMB),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(selection)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Data: string(content)}
|
||||
}
|
||||
|
||||
func buildSQLDirectoryEntries(directory string) ([]SQLDirectoryEntry, error) {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]SQLDirectoryEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(directory, entry.Name())
|
||||
if entry.IsDir() {
|
||||
children, childErr := buildSQLDirectoryEntries(entryPath)
|
||||
if childErr != nil {
|
||||
return nil, childErr
|
||||
}
|
||||
if len(children) == 0 {
|
||||
continue
|
||||
}
|
||||
result = append(result, SQLDirectoryEntry{
|
||||
Name: entry.Name(),
|
||||
Path: entryPath,
|
||||
IsDir: true,
|
||||
Children: children,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(entry.Name()), ".sql") {
|
||||
continue
|
||||
}
|
||||
result = append(result, SQLDirectoryEntry{
|
||||
Name: entry.Name(),
|
||||
Path: entryPath,
|
||||
IsDir: false,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].IsDir != result[j].IsDir {
|
||||
return result[i].IsDir
|
||||
}
|
||||
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) OpenSQLFile() connection.QueryResult {
|
||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select SQL File",
|
||||
@@ -55,33 +163,56 @@ func (a *App) OpenSQLFile() connection.QueryResult {
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
const maxSQLFileSize int64 = 50 * 1024 * 1024 // 50MB
|
||||
fi, err := os.Stat(selection)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err)}
|
||||
}
|
||||
return readSQLFileByPath(selection)
|
||||
}
|
||||
|
||||
// 大文件:只返回文件路径和大小,不读取内容
|
||||
if fi.Size() > maxSQLFileSize {
|
||||
sizeMB := float64(fi.Size()) / (1024 * 1024)
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"isLargeFile": true,
|
||||
"filePath": selection,
|
||||
"fileSize": fi.Size(),
|
||||
"fileSizeMB": fmt.Sprintf("%.1f", sizeMB),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(selection)
|
||||
func (a *App) SelectSQLDirectory(currentDir string) connection.QueryResult {
|
||||
selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "选择 SQL 目录",
|
||||
DefaultDirectory: normalizeDirectoryDialogPath(currentDir),
|
||||
})
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
if abs, err := filepath.Abs(selection); err == nil {
|
||||
selection = abs
|
||||
}
|
||||
name := filepath.Base(selection)
|
||||
if name == "." || name == string(filepath.Separator) {
|
||||
name = selection
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection, "name": name}}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: true, Data: string(content)}
|
||||
func (a *App) ListSQLDirectory(directory string) connection.QueryResult {
|
||||
target := strings.TrimSpace(directory)
|
||||
if target == "" {
|
||||
return connection.QueryResult{Success: false, Message: "目录路径不能为空"}
|
||||
}
|
||||
if abs, err := filepath.Abs(target); err == nil {
|
||||
target = abs
|
||||
}
|
||||
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return connection.QueryResult{Success: false, Message: "所选路径不是目录"}
|
||||
}
|
||||
|
||||
entries, err := buildSQLDirectoryEntries(target)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: entries}
|
||||
}
|
||||
|
||||
func (a *App) ReadSQLFile(filePath string) connection.QueryResult {
|
||||
return readSQLFileByPath(filePath)
|
||||
}
|
||||
|
||||
// ExecuteSQLFile 在后端流式读取并执行大 SQL 文件,通过事件推送进度。
|
||||
|
||||
73
internal/app/methods_file_sql_directory_test.go
Normal file
73
internal/app/methods_file_sql_directory_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildSQLDirectoryEntriesKeepsOnlySQLFilesAndNestedFolders(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
nestedDir := filepath.Join(root, "nested")
|
||||
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "z-last.sql"), []byte("select 1;"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile sql returned error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "ignore.txt"), []byte("skip"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile txt returned error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nestedDir, "inner.SQL"), []byte("select 2;"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile nested sql returned error: %v", err)
|
||||
}
|
||||
|
||||
entries, err := buildSQLDirectoryEntries(root)
|
||||
if err != nil {
|
||||
t.Fatalf("buildSQLDirectoryEntries returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected one folder and one sql file, got %d entries", len(entries))
|
||||
}
|
||||
if !entries[0].IsDir || entries[0].Name != "nested" {
|
||||
t.Fatalf("expected nested directory first, got %#v", entries[0])
|
||||
}
|
||||
if len(entries[0].Children) != 1 || entries[0].Children[0].Name != "inner.SQL" {
|
||||
t.Fatalf("expected nested sql child, got %#v", entries[0].Children)
|
||||
}
|
||||
if entries[1].IsDir || entries[1].Name != "z-last.sql" {
|
||||
t.Fatalf("expected top-level sql file second, got %#v", entries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSQLFileByPathReturnsLargeFileMetadata(t *testing.T) {
|
||||
filePath := filepath.Join(t.TempDir(), "big.sql")
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if err := file.Truncate(maxSQLFileSizeBytes + 1024); err != nil {
|
||||
file.Close()
|
||||
t.Fatalf("Truncate returned error: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
|
||||
result := readSQLFileByPath(filePath)
|
||||
if !result.Success {
|
||||
t.Fatalf("expected large sql file read to succeed, got %#v", result)
|
||||
}
|
||||
|
||||
data, ok := result.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected metadata map, got %#v", result.Data)
|
||||
}
|
||||
if data["isLargeFile"] != true {
|
||||
t.Fatalf("expected isLargeFile true, got %#v", data["isLargeFile"])
|
||||
}
|
||||
if data["filePath"] != filePath {
|
||||
t.Fatalf("expected filePath %q, got %#v", filePath, data["filePath"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user