diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d15af6d..fe7b173 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,10 +65,13 @@ import { ShortcutAction, canRecordShortcutForAction, eventToShortcut, + findReservedConflicts, getShortcutDisplay, isEditableElement, isShortcutMatch, normalizeShortcutCombo, + splitConflictsByContext, + type ConflictInfo, } from './utils/shortcuts'; import { resolveTitleBarToggleIconKey, resolveWindowsScaleCheckDelayMs, shouldApplyWindowsScaleFix, shouldToggleMaximisedWindowForScaleFix, type WindowsScaleCheckTrigger } from './utils/windowStateUi'; import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds'; @@ -1889,6 +1892,18 @@ function App() { const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false); const [isSnippetModalOpen, setIsSnippetModalOpen] = useState(false); const [capturingShortcutAction, setCapturingShortcutAction] = useState(null); + const shortcutConflictMap = useMemo(() => { + const map: Partial> = {}; + for (const action of SHORTCUT_ACTION_ORDER) { + const binding = shortcutOptions[action]; + if (!binding?.enabled || !binding.combo) continue; + const conflicts = findReservedConflicts(normalizeShortcutCombo(binding.combo)); + if (conflicts.length > 0) { + map[action] = conflicts; + } + } + return map; + }, [shortcutOptions]); const [isProxyModalOpen, setIsProxyModalOpen] = useState(false); const [isDataRootModalOpen, setIsDataRootModalOpen] = useState(false); const [dataRootInfo, setDataRootInfo] = useState(null); @@ -2561,6 +2576,17 @@ function App() { return; } + const reservedConflicts = findReservedConflicts(normalizedCombo); + if (reservedConflicts.length > 0) { + const { hasMonaco, hasOther, monacoLabels, otherLabels, otherContexts } = splitConflictsByContext(reservedConflicts); + if (hasMonaco) { + void message.info(`已覆盖编辑器「${monacoLabels}」默认快捷键`, 4); + } + if (hasOther) { + void message.warning(`与${otherContexts}「${otherLabels}」冲突,可能失效`, 4); + } + } + updateShortcut(capturingShortcutAction, { combo: normalizedCombo, enabled: true }); setCapturingShortcutAction(null); }; @@ -3580,6 +3606,8 @@ function App() { } const binding = shortcutOptions[action] ?? { combo: '', enabled: false }; const isCapturing = capturingShortcutAction === action; + const conflicts = shortcutConflictMap[action]; + const conflictInfo = conflicts?.length ? splitConflictsByContext(conflicts) : null; return (
{meta.label}
{meta.description}
+ {conflictInfo && ( +
+ {conflictInfo.hasMonaco && ( + <>⚠ 已覆盖编辑器「{conflictInfo.monacoLabels}」默认快捷键 + )} + {conflictInfo.hasOther && ( + <>⚠ 与{conflictInfo.otherContexts}「{conflictInfo.otherLabels}」冲突,可能失效 + )} +
+ )}
= ({ tab, isAc const [editorHeight, setEditorHeight] = useState(300); const editorRef = useRef(null); const monacoRef = useRef(null); + const runQueryActionRef = useRef(null); const lastExternalQueryRef = useRef(tab.query || ''); const dragRef = useRef<{ startY: number, startHeight: number } | null>(null); const queryEditorRootRef = useRef(null); @@ -918,10 +924,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }); }); - // 全局只注册一次 SQL completion provider,避免多 tab 重复注册导致补全项重复 + // Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding + const runBinding = shortcutOptions.runQuery; + if (runBinding?.enabled && runBinding.combo) { + const keyBinding = comboToMonacoKeyBinding( + runBinding.combo, monaco.KeyMod, monaco.KeyCode + ); + if (keyBinding) { + runQueryActionRef.current = editor.addAction({ + id: 'gonavi.runQuery', + label: 'GoNavi: 执行 SQL', + keybindings: [keyBinding.keyMod | keyBinding.keyCode], + run: () => { + window.dispatchEvent(new CustomEvent('gonavi:run-active-query')); + }, + }); + } + } + + // HMR 重载时释放旧注册避免补全项重复 if (!sqlCompletionRegistered) { sqlCompletionRegistered = true; - monaco.languages.registerCompletionItemProvider('sql', { + _g.__gonaviSqlCompletionState.registered = true; + sqlCompletionDisposables.forEach((d: any) => d?.dispose?.()); + sqlCompletionDisposables.length = 0; + sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', { triggerCharacters: ['.'], provideCompletionItems: async (model: any, position: any) => { const word = model.getWordUntilPosition(position); @@ -1328,7 +1355,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ]; return { suggestions }; } - }); + })); // 注册 / 斜杠命令 AI 快捷补全 const slashCmdDefs = [ { cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:' }, @@ -1343,7 +1370,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc // 全局变量存储命令定义,供 onDidChangeModelContent 使用 (window as any).__gonaviSlashCmdDefs = slashCmdDefs; - monaco.languages.registerCompletionItemProvider('sql', { + sqlCompletionDisposables.push(monaco.languages.registerCompletionItemProvider('sql', { triggerCharacters: ['/'], provideCompletionItems: (model: any, position: any) => { const lineContent = model.getLineContent(position.lineNumber); @@ -1370,7 +1397,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc })), }; }, - }); + })); + // SQL snippet completion provider monaco.languages.registerCompletionItemProvider('sql', { @@ -2158,12 +2186,46 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc void handleRun(); }; - window.addEventListener('keydown', handleRunShortcut); + window.addEventListener('keydown', handleRunShortcut, true); return () => { - window.removeEventListener('keydown', handleRunShortcut); + window.removeEventListener('keydown', handleRunShortcut, true); }; }, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]); + // Re-register Monaco internal keybinding when runQuery shortcut changes + useEffect(() => { + if (runQueryActionRef.current) { + runQueryActionRef.current.dispose(); + runQueryActionRef.current = null; + } + + const editor = editorRef.current; + const monaco = monacoRef.current; + if (!editor || !monaco) return; + + const binding = shortcutOptions.runQuery; + if (!binding?.enabled || !binding.combo) return; + + const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); + if (keyBinding) { + runQueryActionRef.current = editor.addAction({ + id: 'gonavi.runQuery', + label: 'GoNavi: 执行 SQL', + keybindings: [keyBinding.keyMod | keyBinding.keyCode], + run: () => { + window.dispatchEvent(new CustomEvent('gonavi:run-active-query')); + }, + }); + } + + return () => { + if (runQueryActionRef.current) { + runQueryActionRef.current.dispose(); + runQueryActionRef.current = null; + } + }; + }, [shortcutOptions.runQuery]); + useEffect(() => { const handleRunActiveQuery = () => { if (activeTabId !== tab.id) { diff --git a/frontend/src/utils/shortcuts.test.ts b/frontend/src/utils/shortcuts.test.ts new file mode 100644 index 0000000..357bc40 --- /dev/null +++ b/frontend/src/utils/shortcuts.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest'; + +import { + findReservedConflict, + findReservedConflicts, + describeConflictContext, + normalizeShortcutCombo, + RESERVED_SHORTCUTS, + comboToMonacoKeyBinding, +} from './shortcuts'; +import type { ConflictInfo } from './shortcuts'; + +// ─── findReservedConflict ──────────────────────────────────────────── + +describe('findReservedConflict', () => { + it('finds Ctrl+F conflict (Monaco Find)', () => { + const result = findReservedConflict('Ctrl+F'); + expect(result).not.toBeNull(); + expect(result!.label).toBe('编辑器查找'); + expect(result!.context).toBe('monaco'); + expect(result!.monacoCommandId).toBe('actions.find'); + }); + + it('finds Ctrl+S conflict (browser save)', () => { + const result = findReservedConflict('Ctrl+S'); + expect(result).not.toBeNull(); + expect(result!.label).toBe('浏览器保存'); + expect(result!.context).toBe('global'); + }); + + it('returns null for non-reserved combo', () => { + expect(findReservedConflict('Ctrl+Shift+R')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(findReservedConflict('')).toBeNull(); + }); + + it('finds Meta+F (macOS variant)', () => { + const result = findReservedConflict('Meta+F'); + expect(result).not.toBeNull(); + expect(result!.label).toBe('编辑器查找'); + expect(result!.context).toBe('monaco'); + }); + + it('matches after normalization (ctrl+f → Ctrl+F)', () => { + const result = findReservedConflict(normalizeShortcutCombo('ctrl+f')); + expect(result).not.toBeNull(); + expect(result!.label).toBe('编辑器查找'); + }); + + it('finds F2 conflict', () => { + const result = findReservedConflict('F2'); + expect(result).not.toBeNull(); + expect(result!.context).toBe('monaco'); + }); +}); + +// ─── findReservedConflicts ─────────────────────────────────────────── + +describe('findReservedConflicts', () => { + it('returns multiple conflicts for Ctrl+Enter', () => { + const results = findReservedConflicts('Ctrl+Enter'); + expect(results.length).toBeGreaterThanOrEqual(1); + const labels = results.map(r => r.label); + expect(labels).toContain('编辑器在下方插入行'); + }); + + it('returns empty array for non-reserved combo', () => { + expect(findReservedConflicts('Ctrl+Shift+Q')).toEqual([]); + }); + + it('preserves monacoCommandId in results', () => { + const results = findReservedConflicts('Ctrl+F'); + expect(results[0].monacoCommandId).toBe('actions.find'); + }); +}); + +// ─── describeConflictContext ───────────────────────────────────────── + +describe('describeConflictContext', () => { + it('describes global context', () => { + expect(describeConflictContext('global')).toBe('浏览器'); + }); + + it('describes monaco context', () => { + expect(describeConflictContext('monaco')).toBe('编辑器'); + }); + + it('describes datagrid context', () => { + expect(describeConflictContext('datagrid')).toBe('数据表格'); + }); +}); + +// ─── RESERVED_SHORTCUTS sanity ─────────────────────────────────────── + +describe('RESERVED_SHORTCUTS', () => { + it('all combos are already normalized', () => { + for (const entry of RESERVED_SHORTCUTS) { + expect(entry.combo).toBe(normalizeShortcutCombo(entry.combo)); + } + }); + + it('has at least 10 entries', () => { + expect(RESERVED_SHORTCUTS.length).toBeGreaterThanOrEqual(10); + }); + + it('every entry has a label and context', () => { + for (const entry of RESERVED_SHORTCUTS) { + expect(entry.label).toBeTruthy(); + expect(['global', 'monaco', 'datagrid']).toContain(entry.context); + } + }); +}); + +// ─── comboToMonacoKeyBinding ───────────────────────────────────────── + +describe('comboToMonacoKeyBinding', () => { + const mockKeyMod = { + CtrlCmd: 2048, + WinCtrl: 256, + Alt: 512, + Shift: 1024, + }; + + const mockKeyCode = { + Enter: 3, + Tab: 2, + Escape: 9, + Space: 10, + Backspace: 1, + Delete: 20, + Home: 14, + End: 13, + PageUp: 11, + PageDown: 12, + UpArrow: 16, + DownArrow: 17, + LeftArrow: 15, + RightArrow: 18, + Insert: 19, + KeyA: 31, KeyB: 32, KeyC: 33, KeyD: 34, KeyE: 35, + KeyF: 41, + KeyG: 42, KeyH: 43, KeyK: 47, KeyN: 50, KeyP: 52, KeyR: 54, KeyS: 55, + Digit0: 21, Digit1: 22, Digit2: 23, Digit3: 24, Digit4: 25, + Digit5: 26, Digit6: 27, Digit7: 28, Digit8: 29, Digit9: 30, + F1: 61, F2: 62, F3: 63, F4: 64, F5: 65, F6: 66, + F7: 67, F8: 68, F9: 69, F10: 70, F11: 71, F12: 72, + Oem1: 80, Oem2: 81, Oem3: 82, Oem4: 83, Oem5: 84, + Oem6: 85, Oem7: 86, OemComma: 87, OemMinus: 88, + OemPlus: 89, OemPeriod: 90, + }; + + it('maps Ctrl+Enter correctly', () => { + expect(comboToMonacoKeyBinding('Ctrl+Enter', mockKeyMod, mockKeyCode)).toEqual({ + keyMod: mockKeyMod.CtrlCmd, + keyCode: mockKeyCode.Enter, + }); + }); + + it('maps Ctrl+Shift+R correctly', () => { + expect(comboToMonacoKeyBinding('Ctrl+Shift+R', mockKeyMod, mockKeyCode)).toEqual({ + keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Shift, + keyCode: mockKeyCode.KeyR, + }); + }); + + it('maps Meta+Enter (macOS variant)', () => { + expect(comboToMonacoKeyBinding('Meta+Enter', mockKeyMod, mockKeyCode)).toEqual({ + keyMod: mockKeyMod.WinCtrl, + keyCode: mockKeyCode.Enter, + }); + }); + + it('maps F2 key', () => { + expect(comboToMonacoKeyBinding('F2', mockKeyMod, mockKeyCode)).toEqual({ + keyMod: 0, + keyCode: mockKeyCode.F2, + }); + }); + + it('maps Ctrl+, (comma)', () => { + expect(comboToMonacoKeyBinding('Ctrl+,', mockKeyMod, mockKeyCode)).toEqual({ + keyMod: mockKeyMod.CtrlCmd, + keyCode: mockKeyCode.OemComma, + }); + }); + + it('returns null for empty combo', () => { + expect(comboToMonacoKeyBinding('', mockKeyMod, mockKeyCode)).toBeNull(); + }); + + it('returns null for combo with only modifiers', () => { + expect(comboToMonacoKeyBinding('Ctrl+Shift', mockKeyMod, mockKeyCode)).toBeNull(); + }); + + it('maps Ctrl+Digit1', () => { + expect(comboToMonacoKeyBinding('Ctrl+1', mockKeyMod, mockKeyCode)).toEqual({ + keyMod: mockKeyMod.CtrlCmd, + keyCode: mockKeyCode.Digit1, + }); + }); + + it('maps Ctrl+Alt+Delete', () => { + expect(comboToMonacoKeyBinding('Ctrl+Alt+Delete', mockKeyMod, mockKeyCode)).toEqual({ + keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Alt, + keyCode: mockKeyCode.Delete, + }); + }); +}); diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts index eb34234..1bc9d5f 100644 --- a/frontend/src/utils/shortcuts.ts +++ b/frontend/src/utils/shortcuts.ts @@ -312,3 +312,179 @@ export const getShortcutDisplay = (combo: string): string => { return normalized || '-'; }; +export type ConflictContext = 'global' | 'monaco' | 'datagrid'; + +export interface ReservedShortcut { + combo: string; + label: string; + context: ConflictContext; + monacoCommandId?: string; +} + +export interface ConflictInfo { + label: string; + context: ConflictContext; + monacoCommandId?: string; +} + +export const RESERVED_SHORTCUTS: ReservedShortcut[] = [ + // Browser / WebView built-in shortcuts + { combo: 'Ctrl+S', label: '浏览器保存', context: 'global' }, + { combo: 'Ctrl+P', label: '浏览器打印', context: 'global' }, + { combo: 'Ctrl+W', label: '浏览器关闭标签页', context: 'global' }, + { combo: 'Ctrl+T', label: '浏览器新建标签页', context: 'global' }, + { combo: 'Ctrl+N', label: '浏览器新建窗口', context: 'global' }, + { combo: 'Ctrl+Shift+N', label: '浏览器新建隐身窗口', context: 'global' }, + + // Monaco editor built-in shortcuts + { combo: 'Ctrl+F', label: '编辑器查找', context: 'monaco', monacoCommandId: 'actions.find' }, + { combo: 'Meta+F', label: '编辑器查找', context: 'monaco', monacoCommandId: 'actions.find' }, + { combo: 'Ctrl+H', label: '编辑器替换', context: 'monaco', monacoCommandId: 'editor.action.startFindReplaceAction' }, + { combo: 'Meta+H', label: '编辑器替换', context: 'monaco', monacoCommandId: 'editor.action.startFindReplaceAction' }, + { combo: 'Ctrl+G', label: '编辑器跳转行', context: 'monaco', monacoCommandId: 'editor.action.gotoLine' }, + { combo: 'Meta+G', label: '编辑器跳转行', context: 'monaco', monacoCommandId: 'editor.action.gotoLine' }, + { combo: 'Ctrl+P', label: '编辑器快速打开', context: 'monaco', monacoCommandId: 'actions.quickOpen' }, + { combo: 'Meta+P', label: '编辑器快速打开', context: 'monaco', monacoCommandId: 'actions.quickOpen' }, + { combo: 'Ctrl+Shift+F', label: '编辑器全局查找', context: 'monaco', monacoCommandId: 'actions.quickOpenNavigate' }, + { combo: 'Meta+Shift+F', label: '编辑器全局查找', context: 'monaco', monacoCommandId: 'actions.quickOpenNavigate' }, + { combo: 'Ctrl+D', label: '编辑器添加选区', context: 'monaco', monacoCommandId: 'editor.action.addSelectionToNextFindMatch' }, + { combo: 'Meta+D', label: '编辑器添加选区', context: 'monaco', monacoCommandId: 'editor.action.addSelectionToNextFindMatch' }, + { combo: 'Ctrl+Shift+K', label: '编辑器删除行', context: 'monaco', monacoCommandId: 'editor.action.deleteLines' }, + { combo: 'Meta+Shift+K', label: '编辑器删除行', context: 'monaco', monacoCommandId: 'editor.action.deleteLines' }, + { combo: 'Ctrl+Enter', label: '编辑器在下方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineAfter' }, + { combo: 'Meta+Enter', label: '编辑器在下方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineAfter' }, + { combo: 'Ctrl+Shift+Enter', label: '编辑器在上方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineBefore' }, + { combo: 'Meta+Shift+Enter', label: '编辑器在上方插入行', context: 'monaco', monacoCommandId: 'editor.action.insertLineBefore' }, + { combo: 'F2', label: '编辑器重命名符号', context: 'monaco', monacoCommandId: 'editor.action.rename' }, + + // DataGrid shortcuts + { combo: 'Ctrl+C', label: '数据表格复制', context: 'datagrid' }, + { combo: 'Meta+C', label: '数据表格复制', context: 'datagrid' }, +]; + +const CONTEXT_DESCRIPTION: Record = { + global: '浏览器', + monaco: '编辑器', + datagrid: '数据表格', +}; + +export const describeConflictContext = (context: ConflictContext): string => { + return CONTEXT_DESCRIPTION[context] || context; +}; + +export const splitConflictsByContext = (conflicts: ConflictInfo[]) => { + const monaco = conflicts.filter(c => c.context === 'monaco'); + const other = conflicts.filter(c => c.context !== 'monaco'); + const dedupe = (items: ConflictInfo[], fn: (c: ConflictInfo) => string) => + [...new Set(items.map(fn))].join('、'); + return { + monacoLabels: dedupe(monaco, c => c.label), + otherLabels: dedupe(other, c => c.label), + otherContexts: dedupe(other, c => describeConflictContext(c.context)), + hasMonaco: monaco.length > 0, + hasOther: other.length > 0, + }; +}; + +export const findReservedConflict = (normalizedCombo: string): ConflictInfo | null => { + const conflict = RESERVED_SHORTCUTS.find((r) => r.combo === normalizedCombo); + if (!conflict) return null; + return { label: conflict.label, context: conflict.context, monacoCommandId: conflict.monacoCommandId }; +}; + +export const findReservedConflicts = (normalizedCombo: string): ConflictInfo[] => { + return RESERVED_SHORTCUTS + .filter((r) => r.combo === normalizedCombo) + .map((r) => ({ label: r.label, context: r.context, monacoCommandId: r.monacoCommandId })); +}; + +export interface MonacoKeyBinding { + keyMod: number; + keyCode: number; +} + +/** Map key token (after normalization) to a function that returns KeyCode. + * The function receives the KeyCode enum to avoid importing monaco at module level. */ +type KeyCodeResolver = (kc: Record) => number; + +const MONACO_KEY_MAP: Record = { + Enter: (kc) => kc.Enter, + Tab: (kc) => kc.Tab, + Esc: (kc) => kc.Escape, + Space: (kc) => kc.Space, + Backspace: (kc) => kc.Backspace, + Delete: (kc) => kc.Delete, + Home: (kc) => kc.Home, + End: (kc) => kc.End, + PageUp: (kc) => kc.PageUp, + PageDown: (kc) => kc.PageDown, + Up: (kc) => kc.UpArrow, + Down: (kc) => kc.DownArrow, + Left: (kc) => kc.LeftArrow, + Right: (kc) => kc.RightArrow, + Insert: (kc) => kc.Insert, + '/': (kc) => kc.Oem2, + ',': (kc) => kc.OemComma, + '-': (kc) => kc.OemMinus, + '=': (kc) => kc.OemPlus, + '.': (kc) => kc.OemPeriod, + ';': (kc) => kc.Oem1, + "'": (kc) => kc.Oem7, + '[': (kc) => kc.Oem4, + ']': (kc) => kc.Oem6, + '\\': (kc) => kc.Oem5, + '`': (kc) => kc.Oem3, +}; + +function resolveKeyCode(token: string, kc: Record): number | null { + // F1-F12 + const fMatch = token.match(/^F([1-9]|1[0-2])$/); + if (fMatch) { + return kc['F' + fMatch[1]] ?? null; + } + // A-Z + if (/^[A-Z]$/.test(token)) { + return kc['Key' + token] ?? null; + } + // 0-9 + if (/^[0-9]$/.test(token)) { + return kc['Digit' + token] ?? null; + } + // Special keys map + const resolver = MONACO_KEY_MAP[token]; + if (resolver) { + return resolver(kc); + } + return null; +} + +export const comboToMonacoKeyBinding = ( + combo: string, + keyModEnum: Record, + keyCodeEnum: Record, +): MonacoKeyBinding | null => { + const normalized = normalizeShortcutCombo(combo); + if (!normalized) return null; + + const pieces = normalized.split('+'); + let keyMod = 0; + let keyCode: number | null = null; + + for (const piece of pieces) { + if (piece === 'Ctrl') { + keyMod |= keyModEnum.CtrlCmd ?? 0; + } else if (piece === 'Meta') { + keyMod |= keyModEnum.WinCtrl ?? 0; + } else if (piece === 'Alt') { + keyMod |= keyModEnum.Alt ?? 0; + } else if (piece === 'Shift') { + keyMod |= keyModEnum.Shift ?? 0; + } else { + keyCode = resolveKeyCode(piece, keyCodeEnum); + } + } + + if (keyCode == null) return null; + return { keyMod, keyCode }; +}; +