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>;

View File

@@ -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) => {

View File

@@ -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,
},
]);
});
});

View File

@@ -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,

View File

@@ -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;

View 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);
});
});

View 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,
},
};
};

View File

@@ -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>;

View File

@@ -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);
}

View File

@@ -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 文件,通过事件推送进度。

View 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"])
}
}