mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-21 04:41:48 +08:00
✨ feat(query-editor): 增强 SQL 编辑器 AI 内联补全与独立模型配置
- 新增独立内联补全模型配置与服务端透传 - 优化 SQL 编辑器 Alt+\\ 触发、ghost 延续与对象位补全 - 引入基于已存查询和执行日志的 SQL 记忆补全 - 补充前后端本地化、快捷键与补全回归测试
This commit is contained in:
@@ -154,6 +154,7 @@ describe('shortcut localization', () => {
|
||||
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.triggerSqlAiCompletion.label).toBe('Trigger SQL AI Completion');
|
||||
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');
|
||||
@@ -168,6 +169,7 @@ describe('shortcut localization', () => {
|
||||
setCurrentLanguage('zh-CN');
|
||||
expect(SHORTCUT_ACTION_META.runQuery.label).toBe('执行 SQL');
|
||||
expect(SHORTCUT_ACTION_META.formatSql.label).toBe('美化 SQL');
|
||||
expect(SHORTCUT_ACTION_META.triggerSqlAiCompletion.label).toBe('触发 SQL AI 自动补全');
|
||||
expect(findReservedConflict('Ctrl+S')?.label).toBe('浏览器保存');
|
||||
} finally {
|
||||
setCurrentLanguage('zh-CN');
|
||||
@@ -265,6 +267,54 @@ describe('IME shortcut guards', () => {
|
||||
expect(isShortcutMatch(event, 'Ctrl+Enter')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches modifier shortcuts from KeyboardEvent.code when WebView reports Process', () => {
|
||||
const event = {
|
||||
key: 'Process',
|
||||
code: 'Backslash',
|
||||
keyCode: 220,
|
||||
which: 220,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: true,
|
||||
shiftKey: false,
|
||||
isComposing: false,
|
||||
nativeEvent: {
|
||||
code: 'Backslash',
|
||||
keyCode: 220,
|
||||
which: 220,
|
||||
isComposing: false,
|
||||
},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
expect(isImeComposingKeyEvent(event)).toBe(false);
|
||||
expect(eventToShortcut(event)).toBe('Alt+\\');
|
||||
expect(isShortcutMatch(event, 'Alt+\\')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches modifier shortcuts from IntlBackslash layout events when WebView reports Process', () => {
|
||||
const event = {
|
||||
key: 'Process',
|
||||
code: 'IntlBackslash',
|
||||
keyCode: 226,
|
||||
which: 226,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: true,
|
||||
shiftKey: false,
|
||||
isComposing: false,
|
||||
nativeEvent: {
|
||||
code: 'IntlBackslash',
|
||||
keyCode: 226,
|
||||
which: 226,
|
||||
isComposing: false,
|
||||
},
|
||||
} as unknown as KeyboardEvent;
|
||||
|
||||
expect(isImeComposingKeyEvent(event)).toBe(false);
|
||||
expect(eventToShortcut(event)).toBe('Alt+\\');
|
||||
expect(isShortcutMatch(event, 'Alt+\\')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats number keys as non-shortcuts while a composition session is active', () => {
|
||||
setGlobalImeCompositionActive(true);
|
||||
const event = {
|
||||
@@ -366,6 +416,18 @@ describe('shortcut defaults', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('registers manual SQL AI completion as a query editor shortcut', () => {
|
||||
expect(DEFAULT_SHORTCUT_OPTIONS.triggerSqlAiCompletion).toEqual({
|
||||
mac: { combo: 'Alt+\\', enabled: true },
|
||||
windows: { combo: 'Alt+\\', enabled: true },
|
||||
});
|
||||
expect(SHORTCUT_ACTION_META.triggerSqlAiCompletion).toMatchObject({
|
||||
label: '触发 SQL AI 自动补全',
|
||||
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 },
|
||||
@@ -581,6 +643,13 @@ describe('comboToMonacoKeyBinding', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Alt+\\ (manual AI completion)', () => {
|
||||
expect(comboToMonacoKeyBinding('Alt+\\', mockKeyMod, mockKeyCode)).toEqual({
|
||||
keyMod: mockKeyMod.Alt,
|
||||
keyCode: mockKeyCode.Oem5,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for empty combo', () => {
|
||||
expect(comboToMonacoKeyBinding('', mockKeyMod, mockKeyCode)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ShortcutAction =
|
||||
| 'duplicateCurrentLine'
|
||||
| 'saveQuery'
|
||||
| 'formatSql'
|
||||
| 'triggerSqlAiCompletion'
|
||||
| 'toggleQueryResultsPanel'
|
||||
| 'sendAIChatMessage'
|
||||
| 'focusSidebarSearch'
|
||||
@@ -108,6 +109,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
|
||||
'duplicateCurrentLine',
|
||||
'saveQuery',
|
||||
'formatSql',
|
||||
'triggerSqlAiCompletion',
|
||||
'toggleQueryResultsPanel',
|
||||
'sendAIChatMessage',
|
||||
'focusSidebarSearch',
|
||||
@@ -174,6 +176,12 @@ const SHORTCUT_ACTION_META_DEFINITIONS: Record<ShortcutAction, ShortcutActionMet
|
||||
scope: 'queryEditor',
|
||||
allowInEditable: true,
|
||||
},
|
||||
triggerSqlAiCompletion: {
|
||||
labelKey: 'app.shortcuts.action.triggerSqlAiCompletion.label',
|
||||
descriptionKey: 'app.shortcuts.action.triggerSqlAiCompletion.description',
|
||||
scope: 'queryEditor',
|
||||
allowInEditable: true,
|
||||
},
|
||||
toggleQueryResultsPanel: {
|
||||
labelKey: 'app.shortcuts.action.toggleQueryResultsPanel.label',
|
||||
descriptionKey: 'app.shortcuts.action.toggleQueryResultsPanel.description',
|
||||
@@ -282,6 +290,10 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
|
||||
mac: { combo: 'Alt+Shift+F', enabled: true },
|
||||
windows: { combo: 'Alt+Shift+F', enabled: true },
|
||||
},
|
||||
triggerSqlAiCompletion: {
|
||||
mac: { combo: 'Alt+\\', enabled: true },
|
||||
windows: { combo: 'Alt+\\', enabled: true },
|
||||
},
|
||||
toggleQueryResultsPanel: {
|
||||
mac: { combo: 'Meta+Shift+M', enabled: true },
|
||||
windows: { combo: 'Ctrl+Shift+M', enabled: true },
|
||||
@@ -406,6 +418,54 @@ const normalizeKeyboardKey = (key: string): string => {
|
||||
return token.length > 1 ? token[0].toUpperCase() + token.slice(1) : token;
|
||||
};
|
||||
|
||||
const KEYBOARD_CODE_ALIASES: Record<string, string> = {
|
||||
Backslash: '\\',
|
||||
IntlBackslash: '\\',
|
||||
Slash: '/',
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Semicolon: ';',
|
||||
Quote: "'",
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
Backquote: '`',
|
||||
};
|
||||
|
||||
const KEY_CODE_ALIASES: Record<number, string> = {
|
||||
186: ';',
|
||||
187: '=',
|
||||
188: ',',
|
||||
189: '-',
|
||||
190: '.',
|
||||
191: '/',
|
||||
192: '`',
|
||||
219: '[',
|
||||
220: '\\',
|
||||
226: '\\',
|
||||
221: ']',
|
||||
222: "'",
|
||||
};
|
||||
|
||||
const normalizeKeyboardEventCode = (
|
||||
event: (KeyboardEvent | ReactKeyboardEvent) & {
|
||||
code?: string;
|
||||
keyCode?: number;
|
||||
which?: number;
|
||||
nativeEvent?: { code?: string; keyCode?: number; which?: number };
|
||||
},
|
||||
): string => {
|
||||
const code = String(event.code || event.nativeEvent?.code || '').trim();
|
||||
if (code) {
|
||||
const alias = KEYBOARD_CODE_ALIASES[code];
|
||||
if (alias) return alias;
|
||||
}
|
||||
|
||||
const keyCode = Number(event.keyCode ?? event.nativeEvent?.keyCode ?? event.which ?? event.nativeEvent?.which ?? 0);
|
||||
return KEY_CODE_ALIASES[keyCode] || '';
|
||||
};
|
||||
|
||||
let globalImeCompositionActive = false;
|
||||
|
||||
export const setGlobalImeCompositionActive = (active: boolean): void => {
|
||||
@@ -490,15 +550,16 @@ export const isImeComposingKeyEvent = (
|
||||
const key = String(event.key || '').trim();
|
||||
const keyCode = Number(event.keyCode ?? nativeEvent?.keyCode ?? 0);
|
||||
const which = Number(event.which ?? nativeEvent?.which ?? 0);
|
||||
const hasModifier = Boolean(event.ctrlKey || event.metaKey || event.altKey);
|
||||
|
||||
// Primary IME indicators — reliable across all browsers/WebViews.
|
||||
if (
|
||||
globalImeCompositionActive
|
||||
|| event.isComposing
|
||||
|| nativeEvent?.isComposing
|
||||
|| key === 'Process'
|
||||
|| keyCode === 229
|
||||
|| which === 229
|
||||
|| (key === 'Process' && !hasModifier)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -512,7 +573,6 @@ export const isImeComposingKeyEvent = (
|
||||
// even when the user is pressing a shortcut (e.g. Cmd+E) while a CJK input
|
||||
// method is simply *enabled* (not actively composing). Blocking modifier-
|
||||
// key combos would break all window-level shortcuts for CJK users.
|
||||
const hasModifier = Boolean(event.ctrlKey || event.metaKey || event.altKey);
|
||||
if (!hasModifier && isMonacoImeInputTarget(event.target)) {
|
||||
return true;
|
||||
}
|
||||
@@ -520,29 +580,70 @@ export const isImeComposingKeyEvent = (
|
||||
return false;
|
||||
};
|
||||
|
||||
export const eventToShortcut = (event: KeyboardEvent | ReactKeyboardEvent): string => {
|
||||
if (isImeComposingKeyEvent(event)) {
|
||||
return '';
|
||||
}
|
||||
const key = normalizeKeyboardKey(event.key);
|
||||
if (!key || MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const resolveShortcutModifiersFromEvent = (event: KeyboardEvent | ReactKeyboardEvent): string[] => {
|
||||
const modifiers: string[] = [];
|
||||
if (event.ctrlKey) modifiers.push('Ctrl');
|
||||
if (event.metaKey) modifiers.push('Meta');
|
||||
if (event.altKey) modifiers.push('Alt');
|
||||
if (event.shiftKey) modifiers.push('Shift');
|
||||
return modifiers;
|
||||
};
|
||||
|
||||
const normalizeShortcutCandidate = (modifiers: string[], key: string): string => {
|
||||
if (!key || MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number])) {
|
||||
return '';
|
||||
}
|
||||
return normalizeShortcutCombo([...modifiers, key].join('+'));
|
||||
};
|
||||
|
||||
const isUsableShortcutKey = (key: string): boolean => (
|
||||
Boolean(key)
|
||||
&& !MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number])
|
||||
&& key !== 'Process'
|
||||
&& key !== 'Unidentified'
|
||||
&& key !== 'Dead'
|
||||
);
|
||||
|
||||
const eventToShortcutCandidates = (event: KeyboardEvent | ReactKeyboardEvent): string[] => {
|
||||
if (isImeComposingKeyEvent(event)) {
|
||||
return [];
|
||||
}
|
||||
const modifiers = resolveShortcutModifiersFromEvent(event);
|
||||
const candidates: string[] = [];
|
||||
const pushCandidate = (key: string) => {
|
||||
const candidate = normalizeShortcutCandidate(modifiers, key);
|
||||
if (candidate && !candidates.includes(candidate)) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
};
|
||||
|
||||
const key = normalizeKeyboardKey(event.key);
|
||||
if (isUsableShortcutKey(key)) {
|
||||
pushCandidate(key);
|
||||
}
|
||||
|
||||
const codeKey = normalizeKeyboardEventCode(event);
|
||||
if (
|
||||
codeKey
|
||||
&& (
|
||||
!isUsableShortcutKey(key)
|
||||
|| key.length !== 1
|
||||
)
|
||||
) {
|
||||
pushCandidate(codeKey);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
};
|
||||
|
||||
export const eventToShortcut = (event: KeyboardEvent | ReactKeyboardEvent): string => {
|
||||
return eventToShortcutCandidates(event)[0] || '';
|
||||
};
|
||||
|
||||
export const isShortcutMatch = (event: KeyboardEvent | ReactKeyboardEvent, combo: string): boolean => {
|
||||
const expected = normalizeShortcutCombo(combo);
|
||||
if (!expected) return false;
|
||||
const actual = eventToShortcut(event);
|
||||
return actual === expected;
|
||||
return eventToShortcutCandidates(event).includes(expected);
|
||||
};
|
||||
|
||||
export const getShortcutPlatform = (isMacRuntime?: boolean): ShortcutPlatform => (
|
||||
|
||||
Reference in New Issue
Block a user