feat(query-editor): 支持查询重命名导出与保存快捷键

- 支持已保存查询重命名并同步当前标签标题

- 新增 SQL 文件导出接口、Wails 绑定和浏览器 mock

- 补充 Ctrl/Cmd+S 保存查询与 Ctrl+, 快捷键入口修复

- 覆盖 SQL 编辑器保存、导出和快捷键回归测试
This commit is contained in:
Syngnat
2026-05-31 22:32:48 +08:00
parent e687ae2819
commit 63db9fecb3
11 changed files with 583 additions and 35 deletions

View File

@@ -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);");

View File

@@ -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]);

View File

@@ -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 ? <section>{children}</section> : null),
Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
<section>
{children}
<button type="button" onClick={onOk}>{okText}</button>
</section>
) : null),
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
Form,
Dropdown: ({ children }: any) => <>{children}</>,
Dropdown: ({ children, menu }: any) => (
<>
{children}
{menu?.items?.map((item: any) => (
item?.type === 'divider'
? null
: <button key={item.key} type="button" disabled={item.disabled} onClick={item.onClick}>{item.label}</button>
))}
</>
),
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> = {}): 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<string, ((event?: any) => 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(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
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(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
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(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
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 () => {

View File

@@ -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<string, unknown>) => {
}));
};
const resolveEventTargetNode = (target: EventTarget | null): Node | null => (
typeof Node !== 'undefined' && target instanceof Node ? target : null
);
const clearQueryEditorLinkDecorations = (
editor: any,
decorationIdsRef: React.MutableRefObject<string[]>,
@@ -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<any>(null);
const runQueryActionRef = useRef<any>(null);
const selectCurrentStatementActionRef = useRef<any>(null);
const saveQueryActionRef = useRef<any>(null);
const lastExternalQueryRef = useRef<string>(getTabQueryValue(tab));
const lastEditorCursorPositionRef = useRef<any>(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;
}
`}</style>
<div ref={editorPaneRef} className={isV2Ui ? 'gn-v2-query-editor-pane' : undefined}>
<div className={isV2Ui ? 'gn-v2-query-toolbar' : undefined} style={{ padding: '4px 8px 8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
@@ -4360,9 +4557,22 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
</Button>
)}
</Button.Group>
<Button icon={<SaveOutlined />} onClick={handleQuickSave}>
</Button>
<Button.Group>
<Tooltip
title={
saveQueryShortcutBinding.enabled && saveQueryShortcutBinding.combo
? `保存(${getShortcutDisplayLabel(saveQueryShortcutBinding.combo, activeShortcutPlatform)}`
: '保存'
}
>
<Button icon={<SaveOutlined />} onClick={handleQuickSave}>
</Button>
</Tooltip>
<Dropdown menu={{ items: saveMoreMenuItems }} placement="bottomRight">
<Button></Button>
</Dropdown>
</Button.Group>
<Button.Group>
<Tooltip title="美化 SQL">
@@ -4426,9 +4636,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
items={resultSets.map((rs, idx) => ({
key: rs.key,
label: (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<div className="query-result-tab-label">
<Tooltip title={rs.sql}>
<span>{(() => {
<span className="query-result-tab-text">{(() => {
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
</Tooltip>
<Tooltip title="关闭结果">
<span
className="query-result-tab-close"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCloseResult(rs.key);
}}
style={{ display: 'inline-flex', alignItems: 'center', color: '#999', cursor: 'pointer' }}
>
<CloseOutlined style={{ fontSize: 12 }} />
</span>
@@ -4527,11 +4737,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
</div>
<Modal
title="保存查询"
title={saveModalMode === 'rename' ? '重命名查询' : '保存查询'}
open={isSaveModalOpen}
onOk={handleSave}
onCancel={() => setIsSaveModalOpen(false)}
okText="确认"
okText={saveModalMode === 'rename' ? '重命名' : '保存'}
cancelText="取消"
>
<Form form={saveForm} layout="vertical">

View File

@@ -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) => {

View File

@@ -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');
});
});

View File

@@ -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<string, string> = {
export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'runQuery',
'selectCurrentStatement',
'saveQuery',
'sendAIChatMessage',
'focusSidebarSearch',
'newQueryTab',
@@ -109,6 +111,12 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
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<ShortcutOptions> | null | undefined,
action: ShortcutAction,