mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-12 17:39:42 +08:00
✨ feat(query-editor): 支持查询重命名导出与保存快捷键
- 支持已保存查询重命名并同步当前标签标题 - 新增 SQL 文件导出接口、Wails 绑定和浏览器 mock - 补充 Ctrl/Cmd+S 保存查询与 Ctrl+, 快捷键入口修复 - 覆盖 SQL 编辑器保存、导出和快捷键回归测试
This commit is contained in:
@@ -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);");
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user