diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
new file mode 100644
index 0000000..711ea7c
--- /dev/null
+++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
@@ -0,0 +1,279 @@
+import React from 'react';
+import { act, create, type ReactTestRenderer } from 'react-test-renderer';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { SavedQuery, TabData } from '../types';
+import QueryEditor from './QueryEditor';
+
+const storeState = vi.hoisted(() => ({
+ connections: [
+ {
+ id: 'conn-1',
+ name: 'local',
+ config: {
+ type: 'mysql',
+ host: '127.0.0.1',
+ port: 3306,
+ user: 'root',
+ password: '',
+ database: 'main',
+ },
+ },
+ ],
+ addSqlLog: vi.fn(),
+ addTab: vi.fn(),
+ savedQueries: [] as SavedQuery[],
+ saveQuery: vi.fn(),
+ theme: 'light',
+ sqlFormatOptions: { keywordCase: 'upper' as const },
+ setSqlFormatOptions: vi.fn(),
+ queryOptions: { maxRows: 5000 },
+ setQueryOptions: vi.fn(),
+ shortcutOptions: {
+ runQuery: { enabled: false, combo: '' },
+ },
+ activeTabId: 'tab-1',
+ aiPanelVisible: false,
+ setAIPanelVisible: vi.fn(),
+}));
+
+const backendApp = vi.hoisted(() => ({
+ DBQueryWithCancel: vi.fn(),
+ DBQueryMulti: vi.fn(),
+ DBGetTables: vi.fn(),
+ DBGetAllColumns: vi.fn(),
+ DBGetDatabases: vi.fn(),
+ DBGetColumns: vi.fn(),
+ CancelQuery: vi.fn(),
+ GenerateQueryID: vi.fn(),
+ WriteSQLFile: vi.fn(),
+}));
+
+const messageApi = vi.hoisted(() => ({
+ error: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn(),
+}));
+
+const editorState = vi.hoisted(() => {
+ const state = {
+ value: '',
+ editor: null as any,
+ };
+ state.editor = {
+ getValue: vi.fn(() => state.value),
+ setValue: vi.fn((value: string) => {
+ state.value = value;
+ }),
+ getModel: vi.fn(() => ({
+ getValue: () => state.value,
+ setValue: (value: string) => {
+ state.value = value;
+ },
+ getValueInRange: () => '',
+ getLineContent: () => '',
+ getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }),
+ })),
+ getSelection: vi.fn(() => null),
+ addAction: vi.fn(),
+ onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
+ hasTextFocus: vi.fn(() => true),
+ };
+ return state;
+});
+
+vi.mock('../store', () => {
+ const useStore = Object.assign(
+ (selector: (state: typeof storeState) => any) => selector(storeState),
+ { getState: () => storeState },
+ );
+ return { useStore };
+});
+
+vi.mock('../../wailsjs/go/app/App', () => backendApp);
+
+vi.mock('../utils/autoFetchVisibility', () => ({
+ useAutoFetchVisibility: () => false,
+}));
+
+vi.mock('@monaco-editor/react', () => ({
+ default: ({ defaultValue, onMount }: any) => {
+ React.useEffect(() => {
+ editorState.value = String(defaultValue || '');
+ onMount?.(editorState.editor, {
+ editor: { setTheme: vi.fn() },
+ languages: {
+ CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
+ registerCompletionItemProvider: vi.fn(),
+ },
+ });
+ }, []);
+ return ;
+ },
+}));
+
+vi.mock('./DataGrid', () => ({
+ default: () => null,
+ GONAVI_ROW_KEY: '__gonavi_row_key__',
+}));
+
+vi.mock('@ant-design/icons', () => {
+ const Icon = () => ;
+ return {
+ PlayCircleOutlined: Icon,
+ SaveOutlined: Icon,
+ FormatPainterOutlined: Icon,
+ SettingOutlined: Icon,
+ CloseOutlined: Icon,
+ StopOutlined: Icon,
+ RobotOutlined: Icon,
+ };
+});
+
+vi.mock('antd', () => {
+ const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
+
+ );
+ Button.Group = ({ children }: any) =>
{children}
;
+
+ const Form: any = ({ children }: any) => ;
+ Form.Item = ({ children }: any) => <>{children}>;
+ Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
+
+ return {
+ Button,
+ message: messageApi,
+ Modal: ({ children, open }: any) => (open ? : null),
+ Input: ({ value, onChange, placeholder }: any) => ,
+ Form,
+ Dropdown: ({ children }: any) => <>{children}>,
+ Tooltip: ({ children }: any) => <>{children}>,
+ Select: () => null,
+ Tabs: () => null,
+ };
+});
+
+const textContent = (node: any): string =>
+ (node.children || [])
+ .map((item: any) => (typeof item === 'string' ? item : textContent(item)))
+ .join('');
+
+const findButton = (renderer: ReactTestRenderer, text: string) =>
+ renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
+
+const createTab = (overrides: Partial = {}): TabData => ({
+ id: 'tab-1',
+ title: 'query.sql',
+ type: 'query',
+ connectionId: 'conn-1',
+ dbName: 'main',
+ query: 'select 1;',
+ ...overrides,
+});
+
+describe('QueryEditor external SQL save', () => {
+ beforeEach(() => {
+ vi.stubGlobal('window', {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ });
+ storeState.addTab.mockReset();
+ storeState.saveQuery.mockReset();
+ storeState.savedQueries = [];
+ storeState.activeTabId = 'tab-1';
+ messageApi.success.mockReset();
+ messageApi.error.mockReset();
+ backendApp.WriteSQLFile.mockResolvedValue({ success: true });
+ editorState.value = '';
+ editorState.editor.getValue.mockClear();
+ editorState.editor.setValue.mockClear();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ });
+
+ it('writes external SQL file tabs back to disk without creating saved queries', async () => {
+ let renderer: ReactTestRenderer;
+ const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
+
+ await act(async () => {
+ renderer = create();
+ });
+
+ editorState.value = 'select 2;';
+
+ await act(async () => {
+ await findButton(renderer!, '保存').props.onClick();
+ });
+
+ expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 2;');
+ expect(storeState.saveQuery).not.toHaveBeenCalled();
+ expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
+ filePath,
+ query: 'select 2;',
+ savedQueryId: undefined,
+ }));
+ expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!');
+ });
+
+ it('does not create saved queries when external SQL file writes fail', async () => {
+ let renderer: ReactTestRenderer;
+ const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
+ backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
+
+ await act(async () => {
+ renderer = create();
+ });
+
+ editorState.value = 'select 4;';
+
+ await act(async () => {
+ await findButton(renderer!, '保存').props.onClick();
+ });
+
+ expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 4;');
+ expect(storeState.saveQuery).not.toHaveBeenCalled();
+ expect(storeState.addTab).not.toHaveBeenCalled();
+ expect(messageApi.error).toHaveBeenCalledWith('保存 SQL 文件失败: 磁盘只读');
+ });
+
+ it('keeps saved query quick-save behavior for non-file tabs', async () => {
+ storeState.savedQueries = [
+ {
+ id: 'saved-1',
+ name: '常用查询',
+ sql: 'select 1;',
+ connectionId: 'conn-1',
+ dbName: 'main',
+ createdAt: 100,
+ },
+ ];
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+
+ editorState.value = 'select 3;';
+
+ await act(async () => {
+ findButton(renderer!, '保存').props.onClick();
+ });
+
+ expect(backendApp.WriteSQLFile).not.toHaveBeenCalled();
+ expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
+ id: 'saved-1',
+ name: '常用查询',
+ sql: 'select 3;',
+ connectionId: 'conn-1',
+ dbName: 'main',
+ createdAt: 100,
+ }));
+ });
+});
diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx
index 3cc8fcd..ac7a4f4 100644
--- a/frontend/src/components/QueryEditor.tsx
+++ b/frontend/src/components/QueryEditor.tsx
@@ -6,7 +6,7 @@ import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
-import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App';
+import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
@@ -2204,7 +2204,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return saved;
};
- const handleQuickSave = () => {
+ const handleQuickSave = async () => {
+ const filePath = String(tab.filePath || '').trim();
+ if (filePath) {
+ const sql = getCurrentQuery();
+ try {
+ const res = await WriteSQLFile(filePath, sql);
+ if (!res.success) {
+ message.error('保存 SQL 文件失败: ' + (res.message || '未知错误'));
+ return;
+ }
+ addTab({
+ ...tab,
+ query: sql,
+ connectionId: currentConnectionId,
+ dbName: currentDb || tab.dbName || '',
+ filePath,
+ savedQueryId: undefined,
+ });
+ message.success('SQL 文件已保存!');
+ } catch (error) {
+ message.error('保存 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error)));
+ }
+ return;
+ }
+
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
const saveId = existed?.id || fallbackSavedId || '';
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 81edd57..f1df514 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -2411,6 +2411,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
connectionId,
dbName,
query: String(data || ''),
+ filePath,
});
};
@@ -4211,6 +4212,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
size="small"
type="text"
icon={}
+ title="添加外部 SQL 目录"
+ aria-label="添加外部 SQL 目录"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index c3844e4..4afe216 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -134,6 +134,7 @@ if (typeof window !== 'undefined' && !(window as any).go) {
SelectSQLDirectory: async (currentPath: string) => ({ success: false, message: currentPath ? '已取消' : '已取消' }),
ListSQLDirectory: async () => ({ success: true, data: [] }),
ReadSQLFile: async () => ({ success: false, message: '已取消' }),
+ WriteSQLFile: async (_filePath: string, _content: string) => ({ success: true }),
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
ImportConnectionsPayload: async (raw: string, _password?: string) => {
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index ac28578..9a865ba 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -407,6 +407,7 @@ export interface TabData {
dbName?: string;
tableName?: string;
query?: string;
+ filePath?: string;
initialTab?: string;
readOnly?: boolean;
providerMode?: "jmx" | "endpoint" | "agent";
diff --git a/frontend/src/utils/externalSqlTree.test.ts b/frontend/src/utils/externalSqlTree.test.ts
index 4586945..f41d2a4 100644
--- a/frontend/src/utils/externalSqlTree.test.ts
+++ b/frontend/src/utils/externalSqlTree.test.ts
@@ -41,6 +41,7 @@ describe('externalSqlTree helpers', () => {
});
expect(node.type).toBe('external-sql-root');
+ expect(node.title).toBe('外部 SQL 目录 (1)');
expect(node.children).toHaveLength(1);
expect(node.children?.[0]).toMatchObject({
title: 'scripts',
diff --git a/frontend/src/utils/externalSqlTree.ts b/frontend/src/utils/externalSqlTree.ts
index 6ba2601..380ad23 100644
--- a/frontend/src/utils/externalSqlTree.ts
+++ b/frontend/src/utils/externalSqlTree.ts
@@ -117,7 +117,7 @@ export const buildExternalSQLRootNode = ({
});
return {
- title: children.length > 0 ? `外部 SQL 文件 (${children.length})` : '外部 SQL 文件',
+ title: children.length > 0 ? `外部 SQL 目录 (${children.length})` : '外部 SQL 目录',
key: `${dbNodeKey}-external-sql`,
type: 'external-sql-root',
isLeaf: children.length === 0,
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts
index a2e8813..5de3eeb 100755
--- a/frontend/wailsjs/go/app/App.d.ts
+++ b/frontend/wailsjs/go/app/App.d.ts
@@ -279,3 +279,5 @@ export function TestConnection(arg1:connection.ConnectionConfig):Promise;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise;
+
+export function WriteSQLFile(arg1:string,arg2:string):Promise;
diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js
index d00aad1..48a06b6 100755
--- a/frontend/wailsjs/go/app/App.js
+++ b/frontend/wailsjs/go/app/App.js
@@ -549,3 +549,7 @@ export function TestJVMConnection(arg1) {
export function TruncateTables(arg1, arg2, arg3) {
return window['go']['app']['App']['TruncateTables'](arg1, arg2, arg3);
}
+
+export function WriteSQLFile(arg1, arg2) {
+ return window['go']['app']['App']['WriteSQLFile'](arg1, arg2);
+}
diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go
index 03d4817..5b04ec6 100644
--- a/internal/app/methods_file.go
+++ b/internal/app/methods_file.go
@@ -96,6 +96,29 @@ func readSQLFileByPath(filePath string) connection.QueryResult {
return connection.QueryResult{Success: true, Data: string(content)}
}
+func writeSQLFileByPath(filePath string, content string) connection.QueryResult {
+ target := strings.TrimSpace(filePath)
+ 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: fmt.Sprintf("无法读取文件信息: %v", err)}
+ }
+ if info.IsDir() {
+ return connection.QueryResult{Success: false, Message: "所选路径不是 SQL 文件"}
+ }
+
+ if err := os.WriteFile(target, []byte(content), info.Mode().Perm()); err != nil {
+ return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法写入 SQL 文件: %v", err)}
+ }
+ return connection.QueryResult{Success: true, Data: map[string]interface{}{"filePath": target}}
+}
+
func buildSQLDirectoryEntries(directory string) ([]SQLDirectoryEntry, error) {
entries, err := os.ReadDir(directory)
if err != nil {
@@ -215,6 +238,10 @@ func (a *App) ReadSQLFile(filePath string) connection.QueryResult {
return readSQLFileByPath(filePath)
}
+func (a *App) WriteSQLFile(filePath string, content string) connection.QueryResult {
+ return writeSQLFileByPath(filePath, content)
+}
+
// ExecuteSQLFile 在后端流式读取并执行大 SQL 文件,通过事件推送进度。
// 前端通过 EventsOn("sqlfile:progress", ...) 监听进度。
func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string, filePath string, jobID string) connection.QueryResult {
diff --git a/internal/app/methods_file_sql_directory_test.go b/internal/app/methods_file_sql_directory_test.go
index 436defb..c5a4491 100644
--- a/internal/app/methods_file_sql_directory_test.go
+++ b/internal/app/methods_file_sql_directory_test.go
@@ -41,6 +41,40 @@ func TestBuildSQLDirectoryEntriesKeepsOnlySQLFilesAndNestedFolders(t *testing.T)
}
}
+func TestWriteSQLFileByPathOverwritesExistingSQLFile(t *testing.T) {
+ filePath := filepath.Join(t.TempDir(), "report.sql")
+ if err := os.WriteFile(filePath, []byte("select 1;"), 0o644); err != nil {
+ t.Fatalf("WriteFile returned error: %v", err)
+ }
+
+ result := writeSQLFileByPath(filePath, "select 2;\n")
+ if !result.Success {
+ t.Fatalf("expected sql file write to succeed, got %#v", result)
+ }
+
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ t.Fatalf("ReadFile returned error: %v", err)
+ }
+ if string(content) != "select 2;\n" {
+ t.Fatalf("expected file content to be overwritten, got %q", string(content))
+ }
+}
+
+func TestWriteSQLFileByPathRejectsDirectories(t *testing.T) {
+ result := writeSQLFileByPath(t.TempDir(), "select 1;")
+ if result.Success {
+ t.Fatalf("expected directory write to fail, got %#v", result)
+ }
+}
+
+func TestWriteSQLFileByPathRejectsEmptyPath(t *testing.T) {
+ result := writeSQLFileByPath(" ", "select 1;")
+ if result.Success {
+ t.Fatalf("expected empty path write to fail, got %#v", result)
+ }
+}
+
func TestReadSQLFileByPathReturnsLargeFileMetadata(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "big.sql")
file, err := os.Create(filePath)