diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index ce15041..34f3cd0 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -36,9 +36,9 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
} from '@ant-design/icons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
- import { SavedConnection } from '../types';
+ import { SavedConnection, ExternalSQLTreeEntry } from '../types';
import { getDbIcon } from './DatabaseIcons';
- import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
+ import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
@@ -49,6 +49,7 @@ import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
+import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
const { Search } = Input;
@@ -59,7 +60,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
- type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag';
+ type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag';
}
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
@@ -118,7 +119,10 @@ const normalizeMySQLViewDDLForEditing = (viewName: string, rawDefinition: unknow
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
const connections = useStore(state => state.connections);
const savedQueries = useStore(state => state.savedQueries);
+ const externalSQLDirectories = useStore(state => state.externalSQLDirectories);
const deleteQuery = useStore(state => state.deleteQuery);
+ const saveExternalSQLDirectory = useStore(state => state.saveExternalSQLDirectory);
+ const deleteExternalSQLDirectory = useStore(state => state.deleteExternalSQLDirectory);
const addConnection = useStore(state => state.addConnection);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
@@ -324,25 +328,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return;
}
- // Refresh queries for expanded databases
- const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
- for (const node of nodes) {
- if (node.key === k) return node;
- if (node.children) {
- const res = findNode(node.children, k);
- if (res) return res;
- }
- }
- return null;
- };
-
expandedKeys.forEach(key => {
- const node = findNode(treeData, key);
+ const node = findTreeNodeByKey(treeData, key);
if (node && node.type === 'database') {
loadTables(node);
}
});
- }, [autoFetchVisible, savedQueries]);
+ }, [autoFetchVisible, externalSQLDirectories, savedQueries]);
useEffect(() => {
setTreeData((prev) => {
@@ -431,6 +423,68 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
+ const findTreeNodeByKey = (nodes: TreeNode[], targetKey: React.Key): TreeNode | null => {
+ for (const node of nodes) {
+ if (node.key === targetKey) {
+ return node;
+ }
+ if (node.children) {
+ const child = findTreeNodeByKey(node.children, targetKey);
+ if (child) {
+ return child;
+ }
+ }
+ }
+ return null;
+ };
+
+ const decorateExternalSQLTreeNode = (node: ExternalSQLTreeNode): TreeNode => {
+ const icon = (() => {
+ switch (node.type) {
+ case 'external-sql-root':
+ return ;
+ case 'external-sql-directory':
+ return ;
+ case 'external-sql-folder':
+ return ;
+ default:
+ return ;
+ }
+ })();
+
+ return {
+ ...node,
+ icon,
+ children: node.children?.map((child) => decorateExternalSQLTreeNode(child)),
+ };
+ };
+
+ const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => {
+ if (!node) return null;
+ if (node.type === 'database') {
+ return {
+ connectionId: String(node?.dataRef?.id || '').trim(),
+ dbName: String(node?.dataRef?.dbName || '').trim(),
+ dbNodeKey: String(node.key || '').trim(),
+ };
+ }
+
+ if (
+ node.type === 'external-sql-root'
+ || node.type === 'external-sql-directory'
+ || node.type === 'external-sql-folder'
+ || node.type === 'external-sql-file'
+ ) {
+ return {
+ connectionId: String(node?.dataRef?.connectionId || '').trim(),
+ dbName: String(node?.dataRef?.dbName || '').trim(),
+ dbNodeKey: String(node?.dataRef?.dbNodeKey || '').trim(),
+ };
+ }
+
+ return null;
+ };
+
const SIDEBAR_SCHEMA_DB_TYPES = new Set([
'postgres',
'kingbase',
@@ -997,6 +1051,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
loadingNodesRef.current.add(loadKey);
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
+ const dbExternalSQLDirectories = useStore.getState().externalSQLDirectories.filter(directory => directory.connectionId === conn.id && directory.dbName === dbName);
const queriesNode: TreeNode = {
title: '已存查询',
@@ -1038,11 +1093,38 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
});
- const [viewsResult, triggersResult, routinesResult] = await Promise.all([
- loadViews(conn, conn.dbName),
- loadDatabaseTriggers(conn, conn.dbName),
- loadFunctions(conn, conn.dbName),
- ]);
+ const [viewsResult, triggersResult, routinesResult] = await Promise.all([
+ loadViews(conn, conn.dbName),
+ loadDatabaseTriggers(conn, conn.dbName),
+ loadFunctions(conn, conn.dbName),
+ ]);
+ const externalSQLDirectoryResults = await Promise.all(
+ dbExternalSQLDirectories.map(async (directory) => {
+ const directoryRes = await ListSQLDirectory(directory.path);
+ if (!directoryRes.success) {
+ message.warning({
+ key: `external-sql-${directory.id}`,
+ content: `SQL 目录读取失败: ${directory.name} (${directoryRes.message})`,
+ });
+ return { id: directory.id, entries: [] as ExternalSQLTreeEntry[] };
+ }
+ return {
+ id: directory.id,
+ entries: Array.isArray(directoryRes.data) ? directoryRes.data as ExternalSQLTreeEntry[] : [],
+ };
+ }),
+ );
+ const externalSQLTrees = externalSQLDirectoryResults.reduce>((accumulator, item) => {
+ accumulator[item.id] = item.entries;
+ return accumulator;
+ }, {});
+ const externalSQLRootNode = decorateExternalSQLTreeNode(buildExternalSQLRootNode({
+ dbNodeKey: String(key),
+ connectionId: String(conn.id),
+ dbName: String(conn.dbName),
+ directories: dbExternalSQLDirectories,
+ directoryTrees: externalSQLTrees,
+ }));
const viewRows: string[] = Array.isArray(viewsResult.views) ? viewsResult.views : [];
const triggerRows: any[] = Array.isArray(triggersResult.triggers) ? triggersResult.triggers : [];
@@ -1258,7 +1340,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
});
- setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...schemaNodes]));
+ setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...schemaNodes]));
} else {
const groupedNodes: TreeNode[] = [
buildObjectGroup(key as string, 'tables', '表', , tableEntries.map(buildTableNode)),
@@ -1267,7 +1349,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
buildObjectGroup(key as string, 'triggers', '触发器', , triggerEntries.map(buildTriggerNode)),
];
- setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...groupedNodes]));
+ setTreeData(origin => updateTreeData(origin, key, [queriesNode, externalSQLRootNode, ...groupedNodes]));
}
} else {
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
@@ -1383,6 +1465,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
+ } else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') {
+ setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
} else if (type === 'redis-db') {
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
}
@@ -1425,6 +1509,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
+ else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'redis-db') setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
if (node.type === 'table') {
@@ -1463,6 +1548,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
savedQueryId: q.id,
});
return;
+ } else if (node.type === 'external-sql-file') {
+ void openExternalSQLFile(node);
+ return;
} else if (node.type === 'redis-db') {
const { id, redisDB } = node.dataRef;
addTab({
@@ -2139,6 +2227,119 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
+ const refreshDatabaseNode = async (dbNodeKey: string) => {
+ if (!dbNodeKey) {
+ return;
+ }
+ const dbNode = findTreeNodeByKey(treeData, dbNodeKey);
+ if (dbNode && dbNode.type === 'database') {
+ await loadTables(dbNode);
+ }
+ };
+
+ const openExternalSQLFile = async (fileNode: any) => {
+ const connectionId = String(fileNode?.dataRef?.connectionId || '').trim();
+ const dbName = String(fileNode?.dataRef?.dbName || '').trim();
+ const filePath = String(fileNode?.dataRef?.path || '').trim();
+ const fileName = String(fileNode?.dataRef?.name || fileNode?.title || 'SQL文件').trim() || 'SQL文件';
+ if (!connectionId || !dbName || !filePath) {
+ message.error('SQL 文件上下文不完整,无法打开');
+ return;
+ }
+
+ const res = await ReadSQLFile(filePath);
+ if (!res.success) {
+ if (res.message !== '已取消') {
+ message.error('读取 SQL 文件失败: ' + res.message);
+ }
+ return;
+ }
+
+ const data = res.data;
+ if (data && typeof data === 'object' && data.isLargeFile) {
+ const conn = connections.find((item) => item.id === connectionId);
+ if (!conn) {
+ message.error('未找到对应的连接配置');
+ return;
+ }
+ startSQLFileExecution(conn.config, dbName, data.filePath, data.fileSizeMB);
+ return;
+ }
+
+ addTab({
+ id: buildExternalSQLTabId(connectionId, dbName, filePath),
+ title: fileName,
+ type: 'query',
+ connectionId,
+ dbName,
+ query: String(data || ''),
+ });
+ };
+
+ const handleAddExternalSQLDirectory = async (node: any) => {
+ const context = getNodeDatabaseContext(node);
+ if (!context?.connectionId || !context?.dbName || !context?.dbNodeKey) {
+ message.warning('请在具体数据库下添加外部 SQL 目录');
+ return;
+ }
+
+ const currentDirectory = externalSQLDirectories.find((item) =>
+ item.connectionId === context.connectionId && item.dbName === context.dbName,
+ )?.path || '';
+ const selection = await SelectSQLDirectory(currentDirectory);
+ if (!selection.success) {
+ if (selection.message !== '已取消') {
+ message.error('选择 SQL 目录失败: ' + selection.message);
+ }
+ return;
+ }
+
+ const payload = (selection.data && typeof selection.data === 'object') ? selection.data as Record : {};
+ const path = String(payload.path || '').trim();
+ const name = String(payload.name || '').trim();
+ if (!path) {
+ message.error('未获取到有效的 SQL 目录路径');
+ return;
+ }
+
+ const directoryId = buildExternalSQLDirectoryId(context.connectionId, context.dbName, path);
+ saveExternalSQLDirectory({
+ id: directoryId,
+ name: name || path.split(/[\\/]/).filter(Boolean).pop() || 'SQL目录',
+ path,
+ connectionId: context.connectionId,
+ dbName: context.dbName,
+ createdAt: Date.now(),
+ });
+
+ setExpandedKeys((prev) => Array.from(new Set([...prev, context.dbNodeKey, `${context.dbNodeKey}-external-sql`])));
+ setAutoExpandParent(false);
+ await refreshDatabaseNode(context.dbNodeKey);
+ message.success('外部 SQL 目录已添加');
+ };
+
+ const handleRemoveExternalSQLDirectory = async (node: any) => {
+ const directoryId = String(node?.dataRef?.id || '').trim();
+ const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim();
+ if (!directoryId) {
+ message.error('未找到可移除的 SQL 目录');
+ return;
+ }
+ deleteExternalSQLDirectory(directoryId);
+ await refreshDatabaseNode(dbNodeKey);
+ message.success('外部 SQL 目录已移除');
+ };
+
+ const handleRefreshExternalSQLDirectory = async (node: any) => {
+ const dbNodeKey = String(node?.dataRef?.dbNodeKey || '').trim();
+ if (!dbNodeKey) {
+ message.warning('当前目录缺少数据库上下文,无法刷新');
+ return;
+ }
+ await refreshDatabaseNode(dbNodeKey);
+ message.success('外部 SQL 目录已刷新');
+ };
+
const handleCreateDatabase = async () => {
try {
const values = await createDbForm.validateFields();
@@ -3690,6 +3891,55 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
];
}
+ if (node.type === 'external-sql-root') {
+ return [
+ {
+ key: 'add-external-sql-directory',
+ label: '添加 SQL 目录',
+ icon: ,
+ onClick: () => {
+ void handleAddExternalSQLDirectory(node);
+ }
+ }
+ ];
+ }
+
+ if (node.type === 'external-sql-directory') {
+ return [
+ {
+ key: 'refresh-external-sql-directory',
+ label: '刷新目录',
+ icon: ,
+ onClick: () => {
+ void handleRefreshExternalSQLDirectory(node);
+ }
+ },
+ { type: 'divider' },
+ {
+ key: 'remove-external-sql-directory',
+ label: '移除目录',
+ icon: ,
+ danger: true,
+ onClick: () => {
+ void handleRemoveExternalSQLDirectory(node);
+ }
+ }
+ ];
+ }
+
+ if (node.type === 'external-sql-file') {
+ return [
+ {
+ key: 'open-external-sql-file',
+ label: '打开 SQL 文件',
+ icon: ,
+ onClick: () => {
+ void openExternalSQLFile(node);
+ }
+ }
+ ];
+ }
+
return [];
};
@@ -3715,6 +3965,33 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hoverTitle = rawTableName;
}
}
+ } else if (node.type === 'external-sql-directory' || node.type === 'external-sql-folder' || node.type === 'external-sql-file') {
+ hoverTitle = String(node?.dataRef?.path || displayTitle);
+ }
+
+ if (node.type === 'external-sql-root') {
+ return (
+
+
+ {statusBadge}
+ {displayTitle}
+
+ }
+ onClick={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ void handleAddExternalSQLDirectory(node);
+ }}
+ style={{ paddingInline: 4, height: 20 }}
+ />
+
+ );
}
return {statusBadge}{displayTitle};
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 3c9e72e..c3844e4 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -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) => {
diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts
index 677a287..f7b093f 100644
--- a/frontend/src/store.test.ts
+++ b/frontend/src/store.test.ts
@@ -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,
+ },
+ ]);
+ });
});
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index c71863b..9944775 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -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) => 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;
+ 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()(
activeTabId: null,
activeContext: null,
savedQueries: [],
+ externalSQLDirectories: [],
theme: 'light',
appearance: { ...DEFAULT_APPEARANCE },
uiScale: DEFAULT_UI_SCALE,
@@ -1023,6 +1051,43 @@ export const useStore = create()(
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()(
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()(
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()(
const partialState: Partial = {
connectionTags: state.connectionTags,
savedQueries: state.savedQueries,
+ externalSQLDirectories: state.externalSQLDirectories,
theme: state.theme,
appearance: state.appearance,
uiScale: state.uiScale,
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index d753408..9f575b0 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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;
diff --git a/frontend/src/utils/externalSqlTree.test.ts b/frontend/src/utils/externalSqlTree.test.ts
new file mode 100644
index 0000000..4586945
--- /dev/null
+++ b/frontend/src/utils/externalSqlTree.test.ts
@@ -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 = {
+ '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);
+ });
+});
diff --git a/frontend/src/utils/externalSqlTree.ts b/frontend/src/utils/externalSqlTree.ts
new file mode 100644
index 0000000..6ba2601
--- /dev/null
+++ b/frontend/src/utils/externalSqlTree.ts
@@ -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;
+}
+
+type BuildExternalSQLRootNodeParams = {
+ dbNodeKey: string;
+ connectionId: string;
+ dbName: string;
+ directories: ExternalSQLDirectory[];
+ directoryTrees: Record;
+};
+
+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,
+ },
+ };
+};
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts
index c5ec5eb..30f7e6b 100755
--- a/frontend/wailsjs/go/app/App.d.ts
+++ b/frontend/wailsjs/go/app/App.d.ts
@@ -149,8 +149,12 @@ export function OpenDriverDownloadDirectory(arg1:string):Promise;
+export function ListSQLDirectory(arg1:string):Promise;
+
export function PreviewImportFile(arg1:string):Promise;
+export function ReadSQLFile(arg1:string):Promise;
+
export function RedisConnect(arg1:connection.ConnectionConfig):Promise;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:any):Promise;
@@ -219,6 +223,8 @@ export function RetrySecurityUpdateCurrentRound(arg1:app.RetrySecurityUpdateRequ
export function SaveConnection(arg1:connection.SavedConnectionInput):Promise;
+export function SelectSQLDirectory(arg1:string):Promise;
+
export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise;
export function SelectDataRootDirectory(arg1:string):Promise;
diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js
index be83410..e610187 100755
--- a/frontend/wailsjs/go/app/App.js
+++ b/frontend/wailsjs/go/app/App.js
@@ -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);
}
diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go
index 02e13e4..e6d9ca6 100644
--- a/internal/app/methods_file.go
+++ b/internal/app/methods_file.go
@@ -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 文件,通过事件推送进度。
diff --git a/internal/app/methods_file_sql_directory_test.go b/internal/app/methods_file_sql_directory_test.go
new file mode 100644
index 0000000..436defb
--- /dev/null
+++ b/internal/app/methods_file_sql_directory_test.go
@@ -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"])
+ }
+}