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" }}
>
-
+
} onClick={onFormat}>
{t("query_editor.action.format")}
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