From 63db9fecb3fe6d2a2bad831730ccdbb0cc6c2315 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 31 May 2026 22:32:48 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query-editor):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=9F=A5=E8=AF=A2=E9=87=8D=E5=91=BD=E5=90=8D=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=B8=8E=E4=BF=9D=E5=AD=98=E5=BF=AB=E6=8D=B7=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持已保存查询重命名并同步当前标签标题 - 新增 SQL 文件导出接口、Wails 绑定和浏览器 mock - 补充 Ctrl/Cmd+S 保存查询与 Ctrl+, 快捷键入口修复 - 覆盖 SQL 编辑器保存、导出和快捷键回归测试 --- frontend/src/App.tool-center.test.ts | 7 +- frontend/src/App.tsx | 4 +- .../QueryEditor.external-sql-save.test.tsx | 179 ++++++++++++- frontend/src/components/QueryEditor.tsx | 252 ++++++++++++++++-- frontend/src/main.tsx | 1 + frontend/src/utils/shortcuts.test.ts | 23 ++ frontend/src/utils/shortcuts.ts | 21 ++ frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/methods_file.go | 86 ++++++ .../app/methods_file_sql_directory_test.go | 39 +++ 11 files changed, 583 insertions(+), 35 deletions(-) diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 60f06cd..e85f16d 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -14,7 +14,7 @@ const getGlobalShortcutCaseBlock = (action: string) => { const afterCase = appSource.slice(start + caseToken.length); const nextCaseIndex = afterCase.search(/\n\s+case '[^']+':/); - const switchEndIndex = afterCase.indexOf("window.addEventListener('keydown', handleGlobalShortcut);"); + const switchEndIndex = afterCase.indexOf("window.addEventListener('keydown', handleGlobalShortcut, true);"); const endIndex = nextCaseIndex >= 0 ? nextCaseIndex : switchEndIndex; expect(endIndex).toBeGreaterThan(-1); @@ -178,6 +178,11 @@ describe('tool center menu entries', () => { expect(appSource).toContain('setTheme, toggleAIPanel, useNativeMacWindowControls'); }); + it('captures global shortcuts before Monaco/editor defaults consume them', () => { + expect(appSource).toContain("window.addEventListener('keydown', handleGlobalShortcut, true);"); + expect(appSource).toContain("window.removeEventListener('keydown', handleGlobalShortcut, true);"); + }); + it('listens for command search query-tab events and routes them through handleNewQuery', () => { expect(appSource).toContain("window.addEventListener('gonavi:create-query-tab', handleCreateQueryTabEvent as EventListener);"); expect(appSource).toContain("window.removeEventListener('gonavi:create-query-tab', handleCreateQueryTabEvent as EventListener);"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3cd99d2..8e275ae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2953,9 +2953,9 @@ function App() { } }; - window.addEventListener('keydown', handleGlobalShortcut); + window.addEventListener('keydown', handleGlobalShortcut, true); return () => { - window.removeEventListener('keydown', handleGlobalShortcut); + window.removeEventListener('keydown', handleGlobalShortcut, true); }; }, [activeShortcutPlatform, handleCreateConnection, handleManualResetWindowZoom, handleNewQuery, handleTitleBarWindowToggle, handleToggleLogPanel, isMacRuntime, shortcutOptions, themeMode, setTheme, toggleAIPanel, useNativeMacWindowControls]); diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 1c0c1cb..835d112 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -42,6 +42,10 @@ const storeState = vi.hoisted(() => ({ mac: { enabled: false, combo: '' }, windows: { enabled: false, combo: '' }, }, + saveQuery: { + mac: { enabled: true, combo: 'Meta+S' }, + windows: { enabled: true, combo: 'Ctrl+S' }, + }, }, activeTabId: 'tab-1', aiPanelVisible: false, @@ -60,6 +64,7 @@ const backendApp = vi.hoisted(() => ({ CancelQuery: vi.fn(), GenerateQueryID: vi.fn(), WriteSQLFile: vi.fn(), + ExportSQLFile: vi.fn(), })); const messageApi = vi.hoisted(() => ({ @@ -218,8 +223,8 @@ vi.mock('@monaco-editor/react', () => ({ editorState.value = String(defaultValue || ''); onMount?.(editorState.editor, { editor: { setTheme: vi.fn() }, - KeyMod: { CtrlCmd: 2048 }, - KeyCode: { KeyQ: 81 }, + KeyMod: { CtrlCmd: 2048, WinCtrl: 256 }, + KeyCode: { KeyQ: 81, KeyS: 83 }, languages: { CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 }, CompletionItemInsertTextRule: { InsertAsSnippet: 1 }, @@ -301,10 +306,24 @@ vi.mock('antd', () => { return { Button, message: messageApi, - Modal: ({ children, open }: any) => (open ?
{children}
: null), + Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? ( +
+ {children} + +
+ ) : null), Input: ({ value, onChange, placeholder }: any) => , Form, - Dropdown: ({ children }: any) => <>{children}, + Dropdown: ({ children, menu }: any) => ( + <> + {children} + {menu?.items?.map((item: any) => ( + item?.type === 'divider' + ? null + : + ))} + + ), Tooltip: ({ children }: any) => <>{children}, Select: () => null, Tabs: ({ activeKey, items }: any) => { @@ -327,6 +346,9 @@ const textContent = (node: any): string => const findButton = (renderer: ReactTestRenderer, text: string) => renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0]; +const findExactButton = (renderer: ReactTestRenderer, text: string) => + renderer.root.findAll((node) => node.type === 'button' && textContent(node) === text)[0]; + const createTab = (overrides: Partial = {}): TabData => ({ id: 'tab-1', title: 'query.sql', @@ -354,6 +376,7 @@ describe('QueryEditor external SQL save', () => { messageApi.warning.mockReset(); backendApp.DBQuery.mockResolvedValue({ success: true, data: [] }); backendApp.WriteSQLFile.mockResolvedValue({ success: true }); + backendApp.ExportSQLFile.mockResolvedValue({ success: true }); backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] }); backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] }); backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] }); @@ -553,7 +576,7 @@ describe('QueryEditor external SQL save', () => { expect(editorState.domNode.style.cursor).toBe('pointer'); const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1); expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint'); - expect(lastDecorationCall?.[1]?.[0]?.options?.hoverMessage?.value).toContain('Ctrl/Cmd + 点击打开该表'); + expect(lastDecorationCall?.[1]?.[0]?.options?.hoverMessage?.value).toContain('Ctrl + 点击打开该表'); expect(lastDecorationCall?.[1]?.[0]?.options?.hoverMessage?.value).toContain('**表** `events`'); await act(async () => { @@ -985,6 +1008,69 @@ describe('QueryEditor external SQL save', () => { expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!'); }); + it('registers Ctrl/Cmd+S to quick-save the active query', async () => { + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + + storeState.savedQueries = [ + { + id: 'saved-1', + name: '常用查询', + sql: 'select 1;', + connectionId: 'conn-1', + dbName: 'main', + createdAt: 100, + }, + ]; + + await act(async () => { + create(); + }); + + const saveAction = editorState.editor.addAction.mock.calls + .map((call: any[]) => call[0]) + .find((action: any) => action?.id === 'gonavi.saveQuery'); + expect(saveAction).toMatchObject({ + label: 'GoNavi: 保存查询', + keybindings: [2048 | 83], + }); + + editorState.value = 'select 5;'; + const event = { + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + key: 's', + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + await act(async () => { + windowListeners.keydown?.forEach((listener) => listener(event)); + }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({ + id: 'saved-1', + name: '常用查询', + sql: 'select 5;', + connectionId: 'conn-1', + dbName: 'main', + createdAt: 100, + })); + expect(messageApi.success).toHaveBeenCalledWith('查询已保存!'); + }); + 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'; @@ -1040,6 +1126,76 @@ describe('QueryEditor external SQL save', () => { })); }); + it('renames saved queries without creating a new saved query id', 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 9;'; + await act(async () => { + findButton(renderer!, '重命名查询').props.onClick(); + }); + await act(async () => { + await findExactButton(renderer!, '重命名').props.onClick(); + }); + + expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({ + id: 'saved-1', + name: '查询', + sql: 'select 9;', + connectionId: 'conn-1', + dbName: 'main', + createdAt: 100, + })); + expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ + title: '查询', + savedQueryId: 'saved-1', + })); + expect(messageApi.success).toHaveBeenCalledWith('查询已重命名!'); + }); + + it('exports the current editor SQL without changing saved query state', 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 10;'; + await act(async () => { + await findButton(renderer!, '导出 SQL 文件').props.onClick(); + }); + + expect(backendApp.ExportSQLFile).toHaveBeenCalledWith('常用查询', 'select 10;'); + expect(storeState.saveQuery).not.toHaveBeenCalled(); + expect(storeState.addTab).not.toHaveBeenCalledWith(expect.objectContaining({ + query: 'select 10;', + })); + expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已导出!'); + }); + it('automatically appends hidden primary key locator columns for editable query results', async () => { storeState.connections[0].config.type = 'oracle'; storeState.connections[0].config.database = 'ORCLPDB1'; @@ -1572,8 +1728,9 @@ describe('QueryEditor external SQL save', () => { expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1 as a'); expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b'); expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a'); - expect(textContent(renderer!.toJSON())).toContain('结果 1 (1)'); - expect(textContent(renderer!.toJSON())).toContain('结果 2 (1)'); + expect(textContent(renderer!.toJSON())).toContain('结果 1'); + expect(textContent(renderer!.toJSON())).toContain('(1)'); + expect(textContent(renderer!.toJSON())).toContain('结果 2'); }); it('replaces the current result when rerunning the same cursor SQL', async () => { @@ -1626,8 +1783,8 @@ describe('QueryEditor external SQL save', () => { }); const tabLabels = renderer!.root.findAll((node) => textContent(node).includes('结果 ')); - expect(textContent(renderer!.toJSON())).toContain('结果 1 (1)'); - expect(textContent(renderer!.toJSON())).not.toContain('结果 2 (1)'); + expect(textContent(renderer!.toJSON())).toContain('结果 1'); + expect(textContent(renderer!.toJSON())).not.toContain('结果 2'); expect(tabLabels.length).toBeGreaterThan(0); expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 10 })])); expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); @@ -1698,8 +1855,8 @@ describe('QueryEditor external SQL save', () => { expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b'); expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a'); expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 3 as c'); - expect(textContent(renderer!.toJSON())).toContain('结果 1 (1)'); - expect(textContent(renderer!.toJSON())).toContain('结果 2 (1)'); + expect(textContent(renderer!.toJSON())).toContain('结果 1'); + expect(textContent(renderer!.toJSON())).toContain('结果 2'); }); it('runs selected SQL before cursor SQL', async () => { diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index e2ef27b..20e359c 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -6,11 +6,11 @@ import { format } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition, IndexDefinition } from '../types'; import { useStore } from '../store'; -import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App'; +import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile, ExportSQLFile } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb"; -import { getShortcutDisplayLabel, getShortcutPlatform, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts"; +import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts"; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect'; @@ -1383,6 +1383,7 @@ export const resolveQueryEditorNavigationDecorations = ( materializedViews: CompletionViewMeta[] = [], triggers: CompletionTriggerMeta[] = [], routines: CompletionRoutineMeta[] = [], + shortcutModifierLabel = 'Ctrl/Cmd', ): Array<{ startColumn: number; endColumn: number; hoverMessage: string }> => { const text = String(lineContent || ''); if (!text) return []; @@ -1405,23 +1406,23 @@ export const resolveQueryEditorNavigationDecorations = ( const hoverMessage = (() => { if (navigationTarget.type === 'database') { - return 'Ctrl/Cmd + 点击切换到该数据库'; + return `${shortcutModifierLabel} + 点击切换到该数据库`; } if (navigationTarget.type === 'table') { - return 'Ctrl/Cmd + 点击打开该表'; + return `${shortcutModifierLabel} + 点击打开该表`; } if (navigationTarget.type === 'view') { - return 'Ctrl/Cmd + 点击打开该视图'; + return `${shortcutModifierLabel} + 点击打开该视图`; } if (navigationTarget.type === 'materialized-view') { - return 'Ctrl/Cmd + 点击打开该物化视图'; + return `${shortcutModifierLabel} + 点击打开该物化视图`; } if (navigationTarget.type === 'trigger') { - return 'Ctrl/Cmd + 点击打开该触发器'; + return `${shortcutModifierLabel} + 点击打开该触发器`; } return navigationTarget.routineType === 'PROCEDURE' - ? 'Ctrl/Cmd + 点击打开该存储过程' - : 'Ctrl/Cmd + 点击打开该函数'; + ? `${shortcutModifierLabel} + 点击打开该存储过程` + : `${shortcutModifierLabel} + 点击打开该函数`; })(); return [{ @@ -1456,6 +1457,10 @@ const dispatchQueryEditorSidebarLocate = (detail: Record) => { })); }; +const resolveEventTargetNode = (target: EventTarget | null): Node | null => ( + typeof Node !== 'undefined' && target instanceof Node ? target : null +); + const clearQueryEditorLinkDecorations = ( editor: any, decorationIdsRef: React.MutableRefObject, @@ -1644,6 +1649,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const runSeqRef = useRef(0); const currentQueryIdRef = useRef(''); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); + const [saveModalMode, setSaveModalMode] = useState<'save' | 'rename'>('save'); const [saveForm] = Form.useForm(); // Database Selection @@ -1657,6 +1663,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const monacoRef = useRef(null); const runQueryActionRef = useRef(null); const selectCurrentStatementActionRef = useRef(null); + const saveQueryActionRef = useRef(null); const lastExternalQueryRef = useRef(getTabQueryValue(tab)); const lastEditorCursorPositionRef = useRef(null); const lastHoverTargetPositionRef = useRef<{ lineNumber: number; column: number } | null>(null); @@ -1710,6 +1717,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc () => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); + const saveQueryShortcutBinding = useMemo( + () => resolveShortcutBinding(shortcutOptions, 'saveQuery', activeShortcutPlatform), + [activeShortcutPlatform, shortcutOptions], + ); + const primaryShortcutModifierLabel = useMemo( + () => getShortcutPrimaryModifierDisplayLabel(activeShortcutPlatform), + [activeShortcutPlatform], + ); const autoFetchVisible = useAutoFetchVisibility(); const currentSavedQuery = useMemo(() => { @@ -2255,6 +2270,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc materializedViewsRef.current, triggersRef.current, routinesRef.current, + primaryShortcutModifierLabel, ); if (decorations.length === 0) { clearQueryEditorLinkDecorations(editor, linkDecorationIdsRef); @@ -2635,6 +2651,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } + const saveBinding = saveQueryShortcutBinding; + if (saveBinding?.enabled && saveBinding.combo) { + const keyBinding = comboToMonacoKeyBinding( + saveBinding.combo, monaco.KeyMod, monaco.KeyCode + ); + if (keyBinding) { + saveQueryActionRef.current = editor.addAction({ + id: 'gonavi.saveQuery', + label: 'GoNavi: 保存查询', + keybindings: [keyBinding.keyMod | keyBinding.keyCode], + run: () => { + window.dispatchEvent(new CustomEvent('gonavi:save-active-query')); + }, + }); + } + } + // HMR 重载时释放旧注册避免补全项重复 if (!sqlCompletionRegistered) { sqlCompletionRegistered = true; @@ -3942,7 +3975,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return; } - const targetNode = event.target instanceof Node ? event.target : null; + const targetNode = resolveEventTargetNode(event.target); const editorHasFocus = !!editor.hasTextFocus?.(); const inEditorPane = !!(targetNode && editorPaneRef.current?.contains(targetNode)); const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); @@ -4061,6 +4094,39 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }, [selectCurrentStatementShortcutBinding, handleSelectCurrentStatement]); + useEffect(() => { + if (saveQueryActionRef.current) { + saveQueryActionRef.current.dispose(); + saveQueryActionRef.current = null; + } + + const editor = editorRef.current; + const monaco = monacoRef.current; + if (!editor || !monaco) return; + + const binding = saveQueryShortcutBinding; + if (!binding?.enabled || !binding.combo) return; + + const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); + if (keyBinding) { + saveQueryActionRef.current = editor.addAction({ + id: 'gonavi.saveQuery', + label: 'GoNavi: 保存查询', + keybindings: [keyBinding.keyMod | keyBinding.keyCode], + run: () => { + window.dispatchEvent(new CustomEvent('gonavi:save-active-query')); + }, + }); + } + + return () => { + if (saveQueryActionRef.current) { + saveQueryActionRef.current.dispose(); + saveQueryActionRef.current = null; + } + }; + }, [saveQueryShortcutBinding]); + useEffect(() => { const handleRunActiveQuery = () => { if (!isActive) { @@ -4192,6 +4258,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return saved; }; + const openSaveQueryModal = (mode: 'save' | 'rename') => { + setSaveModalMode(mode); + saveForm.setFieldsValue({ name: currentSavedQuery?.name || resolveDefaultQueryName() }); + setIsSaveModalOpen(true); + }; + const handleQuickSave = async () => { const filePath = String(tab.filePath || '').trim(); if (filePath) { @@ -4221,8 +4293,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const fallbackSavedId = String(tab.savedQueryId || '').trim(); const saveId = existed?.id || fallbackSavedId || ''; if (!saveId) { - saveForm.setFieldsValue({ name: resolveDefaultQueryName() }); - setIsSaveModalOpen(true); + openSaveQueryModal('save'); return; } const saveName = existed?.name || resolveDefaultQueryName(); @@ -4230,6 +4301,93 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc message.success('查询已保存!'); }; + const handleRenameQuery = () => { + const existed = currentSavedQuery || null; + const fallbackSavedId = String(tab.savedQueryId || '').trim(); + if (!existed && !fallbackSavedId) { + message.warning('请先保存查询后再重命名'); + openSaveQueryModal('save'); + return; + } + openSaveQueryModal('rename'); + }; + + const handleExportSQLFile = async () => { + try { + const res = await ExportSQLFile(currentSavedQuery?.name || resolveDefaultQueryName(), getCurrentQuery()); + if (!res.success) { + if ((res.message || '') !== '已取消') { + message.error('导出 SQL 文件失败: ' + (res.message || '未知错误')); + } + return; + } + message.success('SQL 文件已导出!'); + } catch (error) { + message.error('导出 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error))); + } + }; + + const saveMoreMenuItems: MenuProps['items'] = [ + { + key: 'rename-query', + label: '重命名查询', + disabled: !!tab.filePath, + onClick: handleRenameQuery, + }, + { + key: 'export-sql-file', + label: '导出 SQL 文件', + onClick: () => void handleExportSQLFile(), + }, + ]; + + useEffect(() => { + const binding = saveQueryShortcutBinding; + if (!binding?.enabled || !binding.combo) { + return; + } + + const handleSaveShortcut = (event: KeyboardEvent) => { + if (!isActive) { + return; + } + if (!isShortcutMatch(event, binding.combo)) { + return; + } + + const editor = editorRef.current; + const targetNode = resolveEventTargetNode(event.target); + const editorHasFocus = !!editor?.hasTextFocus?.(); + const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); + if (!editorHasFocus && !inQueryEditor) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + void handleQuickSave(); + }; + + window.addEventListener('keydown', handleSaveShortcut, true); + return () => { + window.removeEventListener('keydown', handleSaveShortcut, true); + }; + }, [isActive, saveQueryShortcutBinding, handleQuickSave]); + + useEffect(() => { + const handleSaveActiveQuery = () => { + if (!isActive) { + return; + } + void handleQuickSave(); + }; + + window.addEventListener('gonavi:save-active-query', handleSaveActiveQuery as EventListener); + return () => { + window.removeEventListener('gonavi:save-active-query', handleSaveActiveQuery as EventListener); + }; + }, [isActive, handleQuickSave]); + const handleSave = async () => { try { const values = await saveForm.validateFields(); @@ -4241,7 +4399,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc name: String(values.name || '').trim() || '未命名查询', createdAt: existed?.createdAt, }); - message.success('查询已保存!'); + message.success(saveModalMode === 'rename' ? '查询已重命名!' : '查询已保存!'); setIsSaveModalOpen(false); } catch (e) { } @@ -4276,6 +4434,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc flex: 0 0 auto; margin: 0; } + .query-result-tabs .ant-tabs-nav-list { + align-items: stretch; + } + .query-result-tabs .ant-tabs-tab { + min-height: 34px; + padding: 4px 10px !important; + } + .query-result-tabs .ant-tabs-tab-btn { + max-width: 100%; + } .query-result-tabs .ant-tabs-content-holder { flex: 1 1 auto; overflow: hidden; @@ -4306,6 +4474,35 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc .query-result-tabs .ant-tabs-ink-bar { transition: none !important; } + .query-result-tab-label { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; + line-height: 1.1; + } + .query-result-tab-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .query-result-tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 999px; + color: #999; + cursor: pointer; + flex: 0 0 auto; + } + .query-result-tab-close:hover { + background: rgba(0, 0, 0, 0.06); + color: #666; + } `}
@@ -4360,9 +4557,22 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc )} - + + + + + + + + @@ -4426,9 +4636,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc items={resultSets.map((rs, idx) => ({ key: rs.key, label: ( -
+
- {(() => { + {(() => { const isAffected = rs.columns.length === 1 && rs.columns[0] === 'affectedRows'; if (isAffected) return `结果 ${idx + 1} ✓`; return `结果 ${idx + 1}${Array.isArray(rs.rows) ? ` (${rs.rows.length})` : ''}`; @@ -4436,12 +4646,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc { e.preventDefault(); e.stopPropagation(); handleCloseResult(rs.key); }} - style={{ display: 'inline-flex', alignItems: 'center', color: '#999', cursor: 'pointer' }} > @@ -4527,11 +4737,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
setIsSaveModalOpen(false)} - okText="确认" + okText={saveModalMode === 'rename' ? '重命名' : '保存'} cancelText="取消" >
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7736ef2..7cd2603 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -216,6 +216,7 @@ if (typeof window !== 'undefined' && !(window as any).go) { ListSQLDirectory: async () => ({ success: true, data: [] }), ReadSQLFile: async () => ({ success: false, message: '已取消' }), WriteSQLFile: async (_filePath: string, _content: string) => ({ success: true }), + ExportSQLFile: async (_defaultName: string, _content: string) => ({ success: false, message: '浏览器 mock 不支持 SQL 文件导出' }), InstallUpdateAndRestart: async () => ({ success: false }), ImportConfigFile: async () => ({ success: false, message: '已取消' }), ImportConnectionsPayload: async (raw: string, _password?: string) => { diff --git a/frontend/src/utils/shortcuts.test.ts b/frontend/src/utils/shortcuts.test.ts index 787ec67..2dac64b 100644 --- a/frontend/src/utils/shortcuts.test.ts +++ b/frontend/src/utils/shortcuts.test.ts @@ -8,7 +8,9 @@ import { normalizeShortcutCombo, RESERVED_SHORTCUTS, comboToMonacoKeyBinding, + getPrimaryShortcutDisplayLabel, getShortcutDisplayLabel, + getShortcutPrimaryModifierDisplayLabel, resolveShortcutBinding, resolveShortcutDisplay, sanitizeShortcutOptions, @@ -133,6 +135,18 @@ describe('shortcut defaults', () => { }); }); + it('registers save query as a query editor shortcut', () => { + expect(DEFAULT_SHORTCUT_OPTIONS.saveQuery).toEqual({ + mac: { combo: 'Meta+S', enabled: true }, + windows: { combo: 'Ctrl+S', enabled: true }, + }); + expect(SHORTCUT_ACTION_META.saveQuery).toMatchObject({ + label: '保存查询', + scope: 'queryEditor', + allowInEditable: true, + }); + }); + // Windows 任务栏恢复后字体异常变大的兜底入口(方案 3)。 // 自动 fix 路径(9848b8b2)刻意不再 toggle 以避免可见动画,由该快捷键给用户主动触发的修复入口。 it('registers reset window zoom shortcut with default Ctrl+Shift+0', () => { @@ -198,6 +212,7 @@ describe('shortcut defaults', () => { expect(options.newQueryTab.mac).toEqual({ combo: 'Meta+Y', enabled: false }); expect(options.newQueryTab.windows).toEqual({ combo: 'Ctrl+Q', enabled: true }); + expect(options.saveQuery.windows).toEqual({ combo: 'Ctrl+S', enabled: true }); expect(options.sendAIChatMessage.windows).toEqual({ combo: 'Enter', enabled: true }); }); @@ -216,6 +231,14 @@ describe('shortcut defaults', () => { expect(getShortcutDisplayLabel('Meta+N', 'mac')).toBe('⌘N'); expect(getShortcutDisplayLabel('Meta+Shift+H', 'mac')).toBe('⌘⇧H'); expect(getShortcutDisplayLabel('Ctrl+Meta+F', 'mac')).toBe('⌃⌘F'); + expect(getShortcutDisplayLabel('Meta+S', 'mac')).toBe('⌘S'); + expect(getShortcutDisplayLabel('Ctrl+S', 'windows')).toBe('Ctrl+S'); + expect(getShortcutPrimaryModifierDisplayLabel('mac')).toBe('⌘'); + expect(getShortcutPrimaryModifierDisplayLabel('windows')).toBe('Ctrl'); + expect(getPrimaryShortcutDisplayLabel('C', 'mac')).toBe('⌘C'); + expect(getPrimaryShortcutDisplayLabel('C', 'windows')).toBe('Ctrl+C'); + expect(getPrimaryShortcutDisplayLabel('Enter', 'mac')).toBe('⌘↵'); + expect(getPrimaryShortcutDisplayLabel('Enter', 'windows')).toBe('Ctrl+Enter'); expect(resolveShortcutDisplay(options, 'newQueryTab', 'windows')).toBe('Ctrl+Q'); }); }); diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts index 07c98d5..cddd846 100644 --- a/frontend/src/utils/shortcuts.ts +++ b/frontend/src/utils/shortcuts.ts @@ -3,6 +3,7 @@ import type { KeyboardEvent as ReactKeyboardEvent } from 'react'; export type ShortcutAction = | 'runQuery' | 'selectCurrentStatement' + | 'saveQuery' | 'sendAIChatMessage' | 'focusSidebarSearch' | 'newQueryTab' @@ -87,6 +88,7 @@ const KEY_ALIASES: Record = { export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [ 'runQuery', 'selectCurrentStatement', + 'saveQuery', 'sendAIChatMessage', 'focusSidebarSearch', 'newQueryTab', @@ -109,6 +111,12 @@ export const SHORTCUT_ACTION_META: Record = description: '在查询编辑器中选中光标所在 SQL 语句', scope: 'queryEditor', }, + saveQuery: { + label: '保存查询', + description: '保存当前查询页;未命名查询会打开保存弹窗', + scope: 'queryEditor', + allowInEditable: true, + }, sendAIChatMessage: { label: 'AI 聊天发送', description: '在 AI 输入框中发送当前消息,Shift+Enter 始终换行', @@ -170,6 +178,10 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = { mac: { combo: 'Meta+E', enabled: true }, windows: { combo: 'Ctrl+E', enabled: true }, }, + saveQuery: { + mac: { combo: 'Meta+S', enabled: true }, + windows: { combo: 'Ctrl+S', enabled: true }, + }, sendAIChatMessage: { mac: { combo: 'Enter', enabled: true }, windows: { combo: 'Enter', enabled: true }, @@ -466,6 +478,15 @@ export const getShortcutDisplayLabel = ( .join(''); }; +export const getShortcutPrimaryModifierDisplayLabel = ( + platform: ShortcutPlatform, +): string => getShortcutDisplayLabel(platform === 'mac' ? 'Meta' : 'Ctrl', platform); + +export const getPrimaryShortcutDisplayLabel = ( + key: string, + platform: ShortcutPlatform, +): string => getShortcutDisplayLabel(`${platform === 'mac' ? 'Meta' : 'Ctrl'}+${key}`, platform); + export const resolveShortcutDisplay = ( options: Partial | null | undefined, action: ShortcutAction, diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index e51bfd3..207e81b 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -90,6 +90,8 @@ export function ExportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,a export function ExportQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:string):Promise; +export function ExportSQLFile(arg1:string,arg2:string):Promise; + export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 5befaf7..6f6c076 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -170,6 +170,10 @@ export function ExportQuery(arg1, arg2, arg3, arg4, arg5) { return window['go']['app']['App']['ExportQuery'](arg1, arg2, arg3, arg4, arg5); } +export function ExportSQLFile(arg1, arg2) { + return window['go']['app']['App']['ExportSQLFile'](arg1, arg2); +} + export function ExportTable(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4); } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index e0f79c2..35acb44 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -163,6 +163,67 @@ func writeSQLFileByPath(filePath string, content string) connection.QueryResult return connection.QueryResult{Success: true, Data: map[string]interface{}{"filePath": target}} } +func normalizeSQLExportDefaultFilename(rawName string) string { + name := strings.TrimSpace(rawName) + if name == "" { + name = "query" + } + if idx := strings.LastIndexAny(name, `/\`); idx >= 0 { + name = name[idx+1:] + } + if name == "." || name == string(filepath.Separator) { + name = "query" + } + name = strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", + ).Replace(strings.TrimSpace(name)) + if name == "" { + name = "query" + } + if !strings.EqualFold(filepath.Ext(name), ".sql") { + name += ".sql" + } + return name +} + +func normalizeSQLExportTargetPath(filePath string) string { + target := strings.TrimSpace(filePath) + if target == "" { + return "" + } + if !strings.EqualFold(filepath.Ext(target), ".sql") { + target += ".sql" + } + if abs, err := filepath.Abs(target); err == nil { + target = abs + } + return target +} + +func writeExportedSQLFileByPath(filePath string, content string) connection.QueryResult { + target := normalizeSQLExportTargetPath(filePath) + if target == "" { + return connection.QueryResult{Success: false, Message: "文件路径不能为空"} + } + if info, err := os.Stat(target); err == nil && info.IsDir() { + return connection.QueryResult{Success: false, Message: "所选路径不是 SQL 文件"} + } else if err != nil && !os.IsNotExist(err) { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err)} + } + if err := os.WriteFile(target, []byte(content), 0o644); 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 { @@ -286,6 +347,31 @@ func (a *App) WriteSQLFile(filePath string, content string) connection.QueryResu return writeSQLFileByPath(filePath, content) } +func (a *App) ExportSQLFile(defaultName string, content string) connection.QueryResult { + filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ + Title: "导出 SQL 文件", + DefaultFilename: normalizeSQLExportDefaultFilename(defaultName), + Filters: []runtime.FileFilter{ + { + DisplayName: "SQL Files (*.sql)", + Pattern: "*.sql", + }, + { + DisplayName: "All Files (*.*)", + Pattern: "*.*", + }, + }, + }) + if err != nil || strings.TrimSpace(filename) == "" { + return connection.QueryResult{Success: false, Message: "已取消"} + } + result := writeExportedSQLFileByPath(filename, content) + if result.Success { + result.Message = "SQL 文件已导出" + } + return result +} + func normalizeSQLFileExecutionOptions(options sqlFileExecutionOptions) sqlFileExecutionOptions { if options.BatchMaxStatements <= 0 { options.BatchMaxStatements = sqlFileBatchMaxStatements diff --git a/internal/app/methods_file_sql_directory_test.go b/internal/app/methods_file_sql_directory_test.go index c5a4491..49d28e9 100644 --- a/internal/app/methods_file_sql_directory_test.go +++ b/internal/app/methods_file_sql_directory_test.go @@ -75,6 +75,45 @@ func TestWriteSQLFileByPathRejectsEmptyPath(t *testing.T) { } } +func TestNormalizeSQLExportDefaultFilename(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "blank", raw: " ", want: "query.sql"}, + {name: "appends extension", raw: "daily report", want: "daily report.sql"}, + {name: "keeps sql extension", raw: "report.SQL", want: "report.SQL"}, + {name: "uses base name", raw: filepath.Join("folder", "report.sql"), want: "report.sql"}, + {name: "replaces invalid chars", raw: `a:b*c?d"eg|h`, want: "a_b_c_d_e_f_g_h.sql"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeSQLExportDefaultFilename(tt.raw); got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestWriteExportedSQLFileByPathCreatesNewSQLFile(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "exported") + + result := writeExportedSQLFileByPath(filePath, "select 42;\n") + if !result.Success { + t.Fatalf("expected sql export to succeed, got %#v", result) + } + + data, err := os.ReadFile(filePath + ".sql") + if err != nil { + t.Fatalf("ReadFile returned error: %v", err) + } + if string(data) != "select 42;\n" { + t.Fatalf("expected exported sql content, got %q", string(data)) + } +} + func TestReadSQLFileByPathReturnsLargeFileMetadata(t *testing.T) { filePath := filepath.Join(t.TempDir(), "big.sql") file, err := os.Create(filePath)