diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index ce220c94..5377d615 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -307,8 +307,8 @@ vi.mock('@monaco-editor/react', () => ({ editorState.latestOnChange = onChange; onMount?.(editorState.editor, { editor: { setTheme: vi.fn() }, - KeyMod: { CtrlCmd: 2048, WinCtrl: 256 }, - KeyCode: { KeyM: 77, KeyQ: 81, KeyS: 83 }, + KeyMod: { CtrlCmd: 2048, WinCtrl: 256, Alt: 512, Shift: 1024 }, + KeyCode: { KeyF: 70, KeyM: 77, KeyQ: 81, KeyS: 83 }, languages: { CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 }, CompletionItemInsertTextRule: { InsertAsSnippet: 1 }, @@ -2484,6 +2484,25 @@ describe('QueryEditor external SQL save', () => { }); }); + it('registers a configurable Monaco shortcut action for SQL formatting', async () => { + await act(async () => { + create(); + }); + + const formatAction = findEditorAction('gonavi.formatSql'); + expect(formatAction).toMatchObject({ + id: 'gonavi.formatSql', + label: 'GoNavi: 美化 SQL', + keybindings: [512 | 1024 | 70], + }); + + formatAction.run(); + + expect(window.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'gonavi:format-active-query' }), + ); + }); + it('restores the last pre-beautify SQL snapshot after reopening a query tab', async () => { let renderer!: ReactTestRenderer; const originalSql = 'select * from users where id=1'; diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index 5009a202..9d800655 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -301,8 +301,8 @@ vi.mock('@monaco-editor/react', () => ({ editorState.latestOnChange = onChange; onMount?.(editorState.editor, { editor: { setTheme: vi.fn() }, - KeyMod: { CtrlCmd: 2048, WinCtrl: 256 }, - KeyCode: { KeyM: 77, KeyQ: 81, KeyS: 83 }, + KeyMod: { CtrlCmd: 2048, WinCtrl: 256, Alt: 512, Shift: 1024 }, + KeyCode: { KeyF: 70, KeyM: 77, KeyQ: 81, KeyS: 83 }, languages: { CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 }, CompletionItemInsertTextRule: { InsertAsSnippet: 1 }, @@ -2968,6 +2968,8 @@ describe('QueryEditor external SQL save', () => { await act(async () => { renderer = create(); }); + vi.mocked(window.requestAnimationFrame).mockClear(); + frameCallbacks.length = 0; const resizer = renderer.root.find((node) => node.props?.title === '拖动调整高度'); await act(async () => { diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index d3bebf66..9573eb12 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -726,6 +726,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const runQueryActionRef = useRef(null); const selectCurrentStatementActionRef = useRef(null); const saveQueryActionRef = useRef(null); + const formatSqlActionRef = useRef(null); const aiContextMenuActionDisposablesRef = useRef([]); const toggleQueryResultsPanelActionRef = useRef(null); const lastExternalQueryRef = useRef(getTabQueryValue(tab)); @@ -856,6 +857,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc () => resolveShortcutBinding(shortcutOptions, 'saveQuery', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); + const formatSqlShortcutBinding = useMemo( + () => resolveShortcutBinding(shortcutOptions, 'formatSql', activeShortcutPlatform), + [activeShortcutPlatform, shortcutOptions], + ); const toggleQueryResultsPanelShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'toggleQueryResultsPanel', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], @@ -2700,6 +2705,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } + const formatBinding = formatSqlShortcutBinding; + if (formatBinding?.enabled && formatBinding.combo) { + const keyBinding = comboToMonacoKeyBinding( + formatBinding.combo, monaco.KeyMod, monaco.KeyCode + ); + if (keyBinding) { + formatSqlActionRef.current = editor.addAction({ + id: 'gonavi.formatSql', + label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.formatSql.label'), + keybindings: [keyBinding.keyMod | keyBinding.keyCode], + run: () => { + window.dispatchEvent(new CustomEvent('gonavi:format-active-query')); + }, + }); + } + } + // 注册 / 斜杠命令 AI 快捷补全 refreshQueryEditorSlashCommandDefs(); const toggleResultsBinding = toggleQueryResultsPanelShortcutBinding; @@ -3602,6 +3624,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } }; + const handleFormatRef = useRef(handleFormat); + useEffect(() => { + handleFormatRef.current = handleFormat; + }); + + useEffect(() => { + const handleFormatActiveQuery = () => { + if (!isActive) { + return; + } + handleFormatRef.current(); + }; + + window.addEventListener('gonavi:format-active-query', handleFormatActiveQuery as EventListener); + return () => { + window.removeEventListener('gonavi:format-active-query', handleFormatActiveQuery as EventListener); + }; + }, [isActive]); + const handleRestoreLastFormat = () => { const previousQuery = tab.formatRestoreSnapshot?.query; if (!previousQuery) { @@ -4934,6 +4975,39 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }, [languagePreference, saveQueryShortcutBinding]); + useEffect(() => { + if (formatSqlActionRef.current) { + formatSqlActionRef.current.dispose(); + formatSqlActionRef.current = null; + } + + const editor = editorRef.current; + const monaco = monacoRef.current; + if (!editor || !monaco) return; + + const binding = formatSqlShortcutBinding; + if (!binding?.enabled || !binding.combo) return; + + const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); + if (keyBinding) { + formatSqlActionRef.current = editor.addAction({ + id: 'gonavi.formatSql', + label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.formatSql.label'), + keybindings: [keyBinding.keyMod | keyBinding.keyCode], + run: () => { + window.dispatchEvent(new CustomEvent('gonavi:format-active-query')); + }, + }); + } + + return () => { + if (formatSqlActionRef.current) { + formatSqlActionRef.current.dispose(); + formatSqlActionRef.current = null; + } + }; + }, [languagePreference, formatSqlShortcutBinding]); + useEffect(() => { const editor = editorRef.current; if (!editor) return; @@ -5274,6 +5348,39 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }, [isActive, saveQueryShortcutBinding, handleQuickSave]); + useEffect(() => { + const binding = formatSqlShortcutBinding; + if (!binding?.enabled || !binding.combo) { + return; + } + + const handleFormatShortcut = (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 && !isDocumentLevelShortcutTarget(targetNode)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + handleFormatRef.current(); + }; + + window.addEventListener('keydown', handleFormatShortcut, true); + return () => { + window.removeEventListener('keydown', handleFormatShortcut, true); + }; + }, [isActive, formatSqlShortcutBinding]); + useEffect(() => { const binding = toggleQueryResultsPanelShortcutBinding; if (!binding?.enabled || !binding.combo) { @@ -5451,6 +5558,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc pendingTransactionToolbar={pendingSqlTransaction ? sqlEditorTransactionToolbar : null} runQueryShortcutBinding={runQueryShortcutBinding} saveQueryShortcutBinding={saveQueryShortcutBinding} + formatSqlShortcutBinding={formatSqlShortcutBinding} toggleQueryResultsPanelShortcutBinding={toggleQueryResultsPanelShortcutBinding} activeShortcutPlatform={activeShortcutPlatform} isResultPanelVisible={isResultPanelVisible} diff --git a/frontend/src/components/QueryEditorToolbar.i18n.test.ts b/frontend/src/components/QueryEditorToolbar.i18n.test.ts index d791fec7..fb42d57c 100644 --- a/frontend/src/components/QueryEditorToolbar.i18n.test.ts +++ b/frontend/src/components/QueryEditorToolbar.i18n.test.ts @@ -44,6 +44,7 @@ const requiredKeys = [ 'query_editor.action.more', 'query_editor.action.format', 'query_editor.action.format_sql', + 'query_editor.action.format_sql_with_shortcut', 'query_editor.action.ai_generate_sql_menu', 'query_editor.action.ai_explain_sql_menu', 'query_editor.action.ai_optimize_sql_menu', diff --git a/frontend/src/components/QueryEditorToolbar.tsx b/frontend/src/components/QueryEditorToolbar.tsx index 5587a529..9c7e0328 100644 --- a/frontend/src/components/QueryEditorToolbar.tsx +++ b/frontend/src/components/QueryEditorToolbar.tsx @@ -35,6 +35,7 @@ type QueryEditorToolbarProps = { pendingTransactionToolbar: React.ReactNode; runQueryShortcutBinding: ShortcutPlatformBinding; saveQueryShortcutBinding: ShortcutPlatformBinding; + formatSqlShortcutBinding: ShortcutPlatformBinding; toggleQueryResultsPanelShortcutBinding: ShortcutPlatformBinding; activeShortcutPlatform: ShortcutPlatform; isResultPanelVisible: boolean; @@ -95,6 +96,7 @@ const QueryEditorToolbar: React.FC = ({ pendingTransactionToolbar, runQueryShortcutBinding, saveQueryShortcutBinding, + formatSqlShortcutBinding, toggleQueryResultsPanelShortcutBinding, activeShortcutPlatform, isResultPanelVisible, @@ -150,6 +152,15 @@ const QueryEditorToolbar: React.FC = ({ : isResultPanelVisible ? t("query_editor.action.hide_results_panel") : t("query_editor.action.show_results_panel"); + const formatSqlTitle = + formatSqlShortcutBinding.enabled && formatSqlShortcutBinding.combo + ? t("query_editor.action.format_sql_with_shortcut", { + shortcut: getShortcutDisplayLabel( + formatSqlShortcutBinding.combo, + activeShortcutPlatform, + ), + }) + : t("query_editor.action.format_sql"); const aiMenuItems: MenuProps["items"] = [ { key: "ai-generate", @@ -360,7 +371,7 @@ const QueryEditorToolbar: React.FC = ({ className={isV2Ui ? "gn-v2-query-toolbar-action-pair" : undefined} style={{ display: "flex", gap: "8px", alignItems: "center" }} > - + diff --git a/frontend/src/utils/shortcuts.test.ts b/frontend/src/utils/shortcuts.test.ts index e66ea8f5..240f1bdb 100644 --- a/frontend/src/utils/shortcuts.test.ts +++ b/frontend/src/utils/shortcuts.test.ts @@ -132,6 +132,7 @@ describe('shortcut localization', () => { try { expect(SHORTCUT_ACTION_META.runQuery.label).toBe('Run SQL'); expect(SHORTCUT_ACTION_META.saveQuery.description).toBe('Save the current query tab; unnamed queries open the save dialog'); + expect(SHORTCUT_ACTION_META.formatSql.label).toBe('Format SQL'); expect(SHORTCUT_ACTION_META.toggleQueryResultsPanel.label).toBe('Toggle Results Panel'); expect(SHORTCUT_ACTION_META.toggleQueryResultsPanel.description).toBe('Show or hide the results area below the query editor'); expect(SHORTCUT_ACTION_META.sendAIChatMessage.description).toContain('Shift+Enter'); @@ -145,6 +146,7 @@ describe('shortcut localization', () => { setCurrentLanguage('zh-CN'); expect(SHORTCUT_ACTION_META.runQuery.label).toBe('执行 SQL'); + expect(SHORTCUT_ACTION_META.formatSql.label).toBe('美化 SQL'); expect(findReservedConflict('Ctrl+S')?.label).toBe('浏览器保存'); } finally { setCurrentLanguage('zh-CN'); @@ -318,6 +320,18 @@ describe('shortcut defaults', () => { }); }); + it('registers format SQL as a query editor shortcut', () => { + expect(DEFAULT_SHORTCUT_OPTIONS.formatSql).toEqual({ + mac: { combo: 'Alt+Shift+F', enabled: true }, + windows: { combo: 'Alt+Shift+F', enabled: true }, + }); + expect(SHORTCUT_ACTION_META.formatSql).toMatchObject({ + label: '美化 SQL', + scope: 'queryEditor', + allowInEditable: true, + }); + }); + it('registers query results panel toggle as a query editor shortcut', () => { expect(DEFAULT_SHORTCUT_OPTIONS.toggleQueryResultsPanel).toEqual({ mac: { combo: 'Meta+Shift+M', enabled: true }, @@ -505,6 +519,13 @@ describe('comboToMonacoKeyBinding', () => { }); }); + it('maps Alt+Shift+F correctly', () => { + expect(comboToMonacoKeyBinding('Alt+Shift+F', mockKeyMod, mockKeyCode)).toEqual({ + keyMod: mockKeyMod.Alt | mockKeyMod.Shift, + keyCode: mockKeyCode.KeyF, + }); + }); + it('maps Meta+Enter (macOS variant)', () => { expect(comboToMonacoKeyBinding('Meta+Enter', mockKeyMod, mockKeyCode)).toEqual({ keyMod: mockKeyMod.WinCtrl, diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts index 0180b608..cc08c144 100644 --- a/frontend/src/utils/shortcuts.ts +++ b/frontend/src/utils/shortcuts.ts @@ -6,6 +6,7 @@ export type ShortcutAction = | 'runQuery' | 'selectCurrentStatement' | 'saveQuery' + | 'formatSql' | 'toggleQueryResultsPanel' | 'sendAIChatMessage' | 'focusSidebarSearch' @@ -103,6 +104,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [ 'runQuery', 'selectCurrentStatement', 'saveQuery', + 'formatSql', 'toggleQueryResultsPanel', 'sendAIChatMessage', 'focusSidebarSearch', @@ -155,6 +157,12 @@ const SHORTCUT_ACTION_META_DEFINITIONS: Record