Files
MyGoNavi/frontend/src/utils/shortcuts.ts
tianqijiuyun-latiao 0ba984b277 Merge remote-tracking branch 'origin/dev' into feature/20260602_connection_driver_i18n
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/components/AISettingsModal.tsx
#	frontend/src/components/ConnectionModal.edit-password.test.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataSyncModal.i18n.test.ts
#	frontend/src/components/DataSyncModal.tsx
#	frontend/src/components/QueryEditor.external-sql-save.test.tsx
#	frontend/src/components/QueryEditor.tsx
#	frontend/src/components/Sidebar.locate-toolbar.test.tsx
#	frontend/src/components/Sidebar.tsx
#	frontend/src/components/SnippetSettingsModal.tsx
#	frontend/src/components/TableOverview.tsx
#	frontend/src/components/ai/AIChatHeader.test.tsx
#	frontend/src/components/ai/AISettingsProvidersSection.tsx
#	frontend/src/components/ai/aiChatPayloadDispatch.ts
#	frontend/src/components/ai/aiChatReadiness.ts
#	frontend/src/components/ai/aiSettingsModalConfig.tsx
#	frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx
#	frontend/src/components/sidebarV2Utils.ts
#	frontend/src/i18n/catalog.test.ts
#	frontend/src/utils/connectionTypeCatalog.test.ts
#	frontend/src/utils/connectionTypeCatalog.ts
#	frontend/src/utils/tabDisplay.ts
#	internal/ai/provider/custom.go
#	internal/ai/service/service.go
#	internal/app/methods_driver.go
#	internal/app/methods_file.go
#	internal/db/custom_impl.go
#	internal/db/iris_impl.go
#	internal/db/mariadb_impl.go
#	internal/db/sqlserver_impl.go
#	shared/i18n/de-DE.json
#	shared/i18n/en-US.json
#	shared/i18n/ja-JP.json
#	shared/i18n/ru-RU.json
#	shared/i18n/zh-CN.json
#	shared/i18n/zh-TW.json
2026-06-23 12:41:27 +08:00

882 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import { getCurrentLanguage, t } from '../i18n';
export type ShortcutAction =
| 'runQuery'
| 'selectCurrentStatement'
| 'saveQuery'
| 'toggleQueryResultsPanel'
| 'sendAIChatMessage'
| 'focusSidebarSearch'
| 'newQueryTab'
| 'switchToNextTab'
| 'switchToPreviousTab'
| 'newConnection'
| 'toggleAIPanel'
| 'toggleLogPanel'
| 'toggleTheme'
| 'openShortcutManager'
| 'toggleMacFullscreen'
| 'resetWindowZoom'
| 'diagnoseQuery'
| 'showSlowQueries';
export type ShortcutPlatform = 'mac' | 'windows';
export interface ShortcutPlatformBinding {
combo: string;
enabled: boolean;
}
export type ShortcutBinding = Record<ShortcutPlatform, ShortcutPlatformBinding>;
export type ShortcutOptions = Record<ShortcutAction, ShortcutBinding>;
export interface ShortcutActionMeta {
label: string;
description: string;
allowInEditable?: boolean;
allowWithoutModifier?: boolean;
scope?: 'global' | 'aiComposer' | 'queryEditor';
requiredKey?: string;
disallowShift?: boolean;
platformOnly?: 'mac';
}
interface ShortcutActionMetaDefinition extends Omit<ShortcutActionMeta, 'label' | 'description'> {
label?: string;
description?: string;
labelKey?: string;
descriptionKey?: string;
}
const MODIFIER_ORDER = ['Ctrl', 'Meta', 'Alt', 'Shift'] as const;
const MODIFIER_SET = new Set(MODIFIER_ORDER);
const KEY_ALIASES: Record<string, string> = {
control: 'Ctrl',
ctrl: 'Ctrl',
command: 'Meta',
cmd: 'Meta',
meta: 'Meta',
option: 'Alt',
alt: 'Alt',
shift: 'Shift',
escape: 'Esc',
esc: 'Esc',
return: 'Enter',
enter: 'Enter',
tab: 'Tab',
space: 'Space',
' ': 'Space',
backspace: 'Backspace',
delete: 'Delete',
del: 'Delete',
arrowup: 'Up',
up: 'Up',
arrowdown: 'Down',
down: 'Down',
arrowleft: 'Left',
left: 'Left',
arrowright: 'Right',
right: 'Right',
pagedown: 'PageDown',
pageup: 'PageUp',
home: 'Home',
end: 'End',
insert: 'Insert',
',': ',',
'.': '.',
'/': '/',
';': ';',
"'": "'",
'[': '[',
']': ']',
'\\': '\\',
'-': '-',
'=': '=',
'`': '`',
};
export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'runQuery',
'selectCurrentStatement',
'saveQuery',
'toggleQueryResultsPanel',
'sendAIChatMessage',
'focusSidebarSearch',
'newQueryTab',
'switchToNextTab',
'switchToPreviousTab',
'newConnection',
'toggleAIPanel',
'toggleLogPanel',
'toggleTheme',
'diagnoseQuery',
'showSlowQueries',
'openShortcutManager',
'toggleMacFullscreen',
'resetWindowZoom',
];
const localizeShortcut = (key: string): string => t(key, undefined, getCurrentLanguage());
const createShortcutActionMeta = (
definition: ShortcutActionMetaDefinition,
): ShortcutActionMeta => ({
get label() {
return definition.label ?? localizeShortcut(definition.labelKey || '');
},
get description() {
return definition.description ?? localizeShortcut(definition.descriptionKey || '');
},
allowInEditable: definition.allowInEditable,
allowWithoutModifier: definition.allowWithoutModifier,
scope: definition.scope,
requiredKey: definition.requiredKey,
disallowShift: definition.disallowShift,
platformOnly: definition.platformOnly,
});
const SHORTCUT_ACTION_META_DEFINITIONS: Record<ShortcutAction, ShortcutActionMetaDefinition> = {
runQuery: {
labelKey: 'app.shortcuts.action.runQuery.label',
descriptionKey: 'app.shortcuts.action.runQuery.description',
},
selectCurrentStatement: {
labelKey: 'app.shortcuts.action.selectCurrentStatement.label',
descriptionKey: 'app.shortcuts.action.selectCurrentStatement.description',
scope: 'queryEditor',
},
saveQuery: {
labelKey: 'app.shortcuts.action.saveQuery.label',
descriptionKey: 'app.shortcuts.action.saveQuery.description',
scope: 'queryEditor',
allowInEditable: true,
},
toggleQueryResultsPanel: {
labelKey: 'app.shortcuts.action.toggleQueryResultsPanel.label',
descriptionKey: 'app.shortcuts.action.toggleQueryResultsPanel.description',
scope: 'queryEditor',
allowInEditable: true,
},
sendAIChatMessage: {
labelKey: 'app.shortcuts.action.sendAIChatMessage.label',
descriptionKey: 'app.shortcuts.action.sendAIChatMessage.description',
allowInEditable: true,
allowWithoutModifier: true,
scope: 'aiComposer',
requiredKey: 'Enter',
disallowShift: true,
},
focusSidebarSearch: {
labelKey: 'app.shortcuts.action.focusSidebarSearch.label',
descriptionKey: 'app.shortcuts.action.focusSidebarSearch.description',
allowInEditable: true,
},
newQueryTab: {
labelKey: 'app.shortcuts.action.newQueryTab.label',
descriptionKey: 'app.shortcuts.action.newQueryTab.description',
},
switchToNextTab: {
labelKey: 'app.shortcuts.action.switchToNextTab.label',
descriptionKey: 'app.shortcuts.action.switchToNextTab.description',
allowInEditable: true,
},
switchToPreviousTab: {
labelKey: 'app.shortcuts.action.switchToPreviousTab.label',
descriptionKey: 'app.shortcuts.action.switchToPreviousTab.description',
allowInEditable: true,
},
newConnection: {
labelKey: 'app.shortcuts.action.newConnection.label',
descriptionKey: 'app.shortcuts.action.newConnection.description',
},
toggleAIPanel: {
labelKey: 'app.shortcuts.action.toggleAIPanel.label',
descriptionKey: 'app.shortcuts.action.toggleAIPanel.description',
allowInEditable: true,
},
toggleLogPanel: {
labelKey: 'app.shortcuts.action.toggleLogPanel.label',
descriptionKey: 'app.shortcuts.action.toggleLogPanel.description',
},
toggleTheme: {
labelKey: 'app.shortcuts.action.toggleTheme.label',
descriptionKey: 'app.shortcuts.action.toggleTheme.description',
},
diagnoseQuery: {
labelKey: 'app.shortcuts.action.diagnoseQuery.label',
descriptionKey: 'app.shortcuts.action.diagnoseQuery.description',
scope: 'queryEditor',
allowInEditable: true,
},
showSlowQueries: {
labelKey: 'app.shortcuts.action.showSlowQueries.label',
descriptionKey: 'app.shortcuts.action.showSlowQueries.description',
scope: 'queryEditor',
allowInEditable: true,
},
openShortcutManager: {
labelKey: 'app.shortcuts.action.openShortcutManager.label',
descriptionKey: 'app.shortcuts.action.openShortcutManager.description',
allowInEditable: true,
},
toggleMacFullscreen: {
labelKey: 'app.shortcuts.action.toggleMacFullscreen.label',
descriptionKey: 'app.shortcuts.action.toggleMacFullscreen.description',
platformOnly: 'mac',
},
resetWindowZoom: {
labelKey: 'app.shortcuts.action.resetWindowZoom.label',
descriptionKey: 'app.shortcuts.action.resetWindowZoom.description',
allowInEditable: true,
},
};
export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> = Object.fromEntries(
SHORTCUT_ACTION_ORDER.map((action) => [
action,
createShortcutActionMeta(SHORTCUT_ACTION_META_DEFINITIONS[action]),
]),
) as Record<ShortcutAction, ShortcutActionMeta>;
export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
runQuery: {
mac: { combo: 'Meta+R', enabled: true },
windows: { combo: 'Ctrl+R', enabled: true },
},
selectCurrentStatement: {
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 },
},
toggleQueryResultsPanel: {
mac: { combo: 'Meta+Shift+M', enabled: true },
windows: { combo: 'Ctrl+Shift+M', enabled: true },
},
sendAIChatMessage: {
mac: { combo: 'Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },
},
focusSidebarSearch: {
mac: { combo: 'Meta+K', enabled: true },
windows: { combo: 'Ctrl+K', enabled: true },
},
newQueryTab: {
mac: { combo: 'Meta+N', enabled: true },
windows: { combo: 'Ctrl+N', enabled: true },
},
switchToNextTab: {
mac: { combo: 'Ctrl+Tab', enabled: true },
windows: { combo: 'Ctrl+Tab', enabled: true },
},
switchToPreviousTab: {
mac: { combo: 'Ctrl+Shift+Tab', enabled: true },
windows: { combo: 'Ctrl+Shift+Tab', enabled: true },
},
newConnection: {
mac: { combo: 'Meta+Shift+N', enabled: true },
windows: { combo: 'Ctrl+Shift+N', enabled: true },
},
toggleAIPanel: {
mac: { combo: 'Meta+J', enabled: true },
windows: { combo: 'Ctrl+J', enabled: true },
},
toggleLogPanel: {
mac: { combo: 'Meta+Shift+H', enabled: true },
windows: { combo: 'Ctrl+H', enabled: true },
},
toggleTheme: {
mac: { combo: 'Meta+Shift+D', enabled: true },
windows: { combo: 'Ctrl+Shift+D', enabled: true },
},
// SQL 诊断:避开 toggleTheme 的 Ctrl+Shift+D用 Ctrl+Shift+PP = Plan
diagnoseQuery: {
mac: { combo: 'Meta+Shift+P', enabled: true },
windows: { combo: 'Ctrl+Shift+P', enabled: true },
},
// 慢查询历史:避开 toggleLogPanel 的 Ctrl+H / Meta+Shift+H用 Ctrl+Shift+LL = Log
showSlowQueries: {
mac: { combo: 'Meta+Shift+L', enabled: true },
windows: { combo: 'Ctrl+Shift+L', enabled: true },
},
openShortcutManager: {
mac: { combo: 'Meta+,', enabled: true },
windows: { combo: 'Ctrl+,', enabled: true },
},
toggleMacFullscreen: {
mac: { combo: 'Ctrl+Meta+F', enabled: true },
windows: { combo: '', enabled: false },
},
resetWindowZoom: {
mac: { combo: '', enabled: false },
windows: { combo: 'Ctrl+Shift+0', enabled: true },
},
};
const normalizeKeyToken = (value: string): string => {
const token = String(value || '').trim();
if (!token) return '';
const alias = KEY_ALIASES[token.toLowerCase()];
if (alias) return alias;
if (/^f([1-9]|1[0-2])$/i.test(token)) {
return token.toUpperCase();
}
if (token.length === 1) {
return token === '+' ? '+' : token.toUpperCase();
}
return token.length > 1 ? token[0].toUpperCase() + token.slice(1).toLowerCase() : token;
};
export const normalizeShortcutCombo = (combo: string): string => {
const raw = String(combo || '').trim();
if (!raw) return '';
const pieces = raw
.split('+')
.map(part => part.trim())
.filter(Boolean);
const modifiers: string[] = [];
let key = '';
pieces.forEach((part) => {
const normalized = normalizeKeyToken(part);
if (!normalized) return;
if (MODIFIER_SET.has(normalized as typeof MODIFIER_ORDER[number])) {
if (!modifiers.includes(normalized)) {
modifiers.push(normalized);
}
return;
}
key = normalized;
});
modifiers.sort((a, b) => MODIFIER_ORDER.indexOf(a as typeof MODIFIER_ORDER[number]) - MODIFIER_ORDER.indexOf(b as typeof MODIFIER_ORDER[number]));
if (!key) {
return modifiers.join('+');
}
return [...modifiers, key].join('+');
};
const normalizeKeyboardKey = (key: string): string => {
const token = String(key || '').trim();
if (!token) return '';
const alias = KEY_ALIASES[token.toLowerCase()];
if (alias) return alias;
if (token.length === 1) {
if (token === ' ') return 'Space';
return token.toUpperCase();
}
if (/^f([1-9]|1[0-2])$/i.test(token)) {
return token.toUpperCase();
}
return token.length > 1 ? token[0].toUpperCase() + token.slice(1) : token;
};
let globalImeCompositionActive = false;
export const setGlobalImeCompositionActive = (active: boolean): void => {
globalImeCompositionActive = active === true;
};
export const isGlobalImeCompositionActive = (): boolean => globalImeCompositionActive;
type ImeCompositionEventTarget = Pick<Window, 'addEventListener' | 'removeEventListener'>;
type ImeCompositionDocumentTarget = Pick<Document, 'addEventListener' | 'removeEventListener'> & {
visibilityState?: DocumentVisibilityState;
};
export const installGlobalImeCompositionTracking = (
eventTarget: ImeCompositionEventTarget = window,
documentTarget: ImeCompositionDocumentTarget | null = document,
): (() => void) => {
const handleCompositionStart = () => setGlobalImeCompositionActive(true);
const handleCompositionEnd = () => setGlobalImeCompositionActive(false);
const handleBlur = () => setGlobalImeCompositionActive(false);
const handleVisibilityChange = () => {
if (!documentTarget || documentTarget.visibilityState === 'hidden') {
setGlobalImeCompositionActive(false);
}
};
eventTarget.addEventListener('compositionstart', handleCompositionStart, true);
eventTarget.addEventListener('compositionend', handleCompositionEnd, true);
eventTarget.addEventListener('blur', handleBlur, true);
documentTarget?.addEventListener('visibilitychange', handleVisibilityChange, true);
return () => {
eventTarget.removeEventListener('compositionstart', handleCompositionStart, true);
eventTarget.removeEventListener('compositionend', handleCompositionEnd, true);
eventTarget.removeEventListener('blur', handleBlur, true);
documentTarget?.removeEventListener('visibilitychange', handleVisibilityChange, true);
setGlobalImeCompositionActive(false);
};
};
const isMonacoImeInputTarget = (target: EventTarget | null | undefined): boolean => {
if (!target || typeof target !== 'object') {
return false;
}
const element = target as Element & {
className?: unknown;
classList?: { contains?: (name: string) => boolean };
closest?: (selector: string) => Element | null;
};
if (typeof element.classList?.contains === 'function' && element.classList.contains('ime-input')) {
return true;
}
if (typeof element.className === 'string' && /\bime-input\b/.test(element.className)) {
return true;
}
if (typeof element.closest === 'function') {
return Boolean(element.closest('.monaco-editor .inputarea.ime-input, .monaco-editor textarea.ime-input, .ime-input'));
}
return false;
};
export const isImeComposingKeyEvent = (
event: (KeyboardEvent | ReactKeyboardEvent | null | undefined) & {
nativeEvent?: {
isComposing?: boolean;
keyCode?: number;
which?: number;
};
keyCode?: number;
which?: number;
isComposing?: boolean;
key?: string;
target?: EventTarget | null;
},
): boolean => {
if (!event) {
return false;
}
const nativeEvent = event.nativeEvent;
const key = String(event.key || '').trim();
const keyCode = Number(event.keyCode ?? nativeEvent?.keyCode ?? 0);
const which = Number(event.which ?? nativeEvent?.which ?? 0);
return Boolean(
globalImeCompositionActive
|| event.isComposing
|| nativeEvent?.isComposing
|| isMonacoImeInputTarget(event.target)
|| key === 'Process'
|| keyCode === 229
|| which === 229,
);
};
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 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 normalizeShortcutCombo([...modifiers, key].join('+'));
};
export const isShortcutMatch = (event: KeyboardEvent | ReactKeyboardEvent, combo: string): boolean => {
const expected = normalizeShortcutCombo(combo);
if (!expected) return false;
const actual = eventToShortcut(event);
return actual === expected;
};
export const getShortcutPlatform = (isMacRuntime?: boolean): ShortcutPlatform => (
isMacRuntime ? 'mac' : 'windows'
);
export const hasModifierKey = (combo: string): boolean => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized) return false;
return normalized.split('+').some(part => MODIFIER_SET.has(part as typeof MODIFIER_ORDER[number]));
};
const getShortcutKeyToken = (combo: string): string => {
const parts = normalizeShortcutCombo(combo).split('+').filter(Boolean);
const key = parts[parts.length - 1] || '';
return MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number]) ? '' : key;
};
const getShortcutModifierTokens = (combo: string): string[] => (
normalizeShortcutCombo(combo)
.split('+')
.filter(part => MODIFIER_SET.has(part as typeof MODIFIER_ORDER[number]))
);
export const canRecordShortcutForAction = (action: ShortcutAction, combo: string): boolean => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized || !getShortcutKeyToken(normalized)) {
return false;
}
const meta = SHORTCUT_ACTION_META[action];
if (meta.requiredKey && getShortcutKeyToken(normalized) !== normalizeShortcutCombo(meta.requiredKey)) {
return false;
}
if (meta.disallowShift && normalized.split('+').includes('Shift')) {
return false;
}
if (meta.allowWithoutModifier) {
return getShortcutModifierTokens(normalized).length <= 1;
}
return hasModifierKey(normalized);
};
const cloneShortcutPlatformBinding = (
action: ShortcutAction,
platform: ShortcutPlatform,
value?: Partial<ShortcutPlatformBinding> | null,
): ShortcutPlatformBinding => {
const fallback = DEFAULT_SHORTCUT_OPTIONS[action]?.[platform] ?? { combo: '', enabled: false };
const normalized = normalizeShortcutCombo(value?.combo || fallback.combo);
return {
combo: normalized && canRecordShortcutForAction(action, normalized) ? normalized : fallback.combo,
enabled: value?.enabled === false ? false : fallback.enabled !== false,
};
};
export const cloneShortcutOptions = (value: ShortcutOptions): ShortcutOptions => {
return SHORTCUT_ACTION_ORDER.reduce((acc, action) => {
acc[action] = {
mac: cloneShortcutPlatformBinding(action, 'mac', value[action]?.mac),
windows: cloneShortcutPlatformBinding(action, 'windows', value[action]?.windows),
};
return acc;
}, {} as ShortcutOptions);
};
const isLegacyShortcutBinding = (value: Record<string, unknown>): boolean => (
Object.prototype.hasOwnProperty.call(value, 'combo')
|| Object.prototype.hasOwnProperty.call(value, 'enabled')
);
const sanitizeShortcutPlatformBinding = (
action: ShortcutAction,
platform: ShortcutPlatform,
value: unknown,
fallback: ShortcutPlatformBinding,
): ShortcutPlatformBinding => {
if (!value || typeof value !== 'object') {
return { ...fallback };
}
const binding = value as Record<string, unknown>;
const combo = normalizeShortcutCombo(String(binding.combo || fallback.combo));
return {
combo: combo && canRecordShortcutForAction(action, combo) ? combo : fallback.combo,
enabled: binding.enabled === false ? false : true,
};
};
export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const defaults = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS);
SHORTCUT_ACTION_ORDER.forEach((action) => {
const actionRaw = raw[action];
if (!actionRaw || typeof actionRaw !== 'object') {
return;
}
const binding = actionRaw as Record<string, unknown>;
if (isLegacyShortcutBinding(binding)) {
defaults[action] = {
mac: sanitizeShortcutPlatformBinding(action, 'mac', binding, defaults[action].mac),
windows: sanitizeShortcutPlatformBinding(action, 'windows', binding, defaults[action].windows),
};
return;
}
defaults[action] = {
mac: sanitizeShortcutPlatformBinding(action, 'mac', binding.mac, defaults[action].mac),
windows: sanitizeShortcutPlatformBinding(action, 'windows', binding.windows, defaults[action].windows),
};
});
return defaults;
};
export const resolveShortcutBinding = (
options: Partial<ShortcutOptions> | null | undefined,
action: ShortcutAction,
platform: ShortcutPlatform,
): ShortcutPlatformBinding => {
const defaults = DEFAULT_SHORTCUT_OPTIONS[action];
const binding = options?.[action];
return cloneShortcutPlatformBinding(action, platform, binding?.[platform] ?? defaults[platform]);
};
export const isEditableElement = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
const tag = target.tagName.toLowerCase();
if (target.isContentEditable) {
return true;
}
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
return true;
}
if (target.closest('.monaco-editor, .monaco-inputbox, .ant-select, .ant-picker, .ant-input')) {
return true;
}
return false;
};
export const getShortcutDisplay = (combo: string): string => {
const normalized = normalizeShortcutCombo(combo);
return normalized || '-';
};
const DISPLAY_SYMBOLS: Record<string, string> = {
Ctrl: '⌃',
Meta: '⌘',
Alt: '⌥',
Shift: '⇧',
Enter: '↵',
Esc: 'Esc',
Space: 'Space',
Up: '↑',
Down: '↓',
Left: '←',
Right: '→',
};
export const getShortcutDisplayLabel = (
combo: string,
platform: ShortcutPlatform,
): string => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized) return '-';
if (platform !== 'mac') return normalized;
return normalized
.split('+')
.filter(Boolean)
.map((part) => DISPLAY_SYMBOLS[part] || part)
.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,
platform: ShortcutPlatform,
): string => {
const binding = resolveShortcutBinding(options, action, platform);
if (!binding.enabled) return '-';
return getShortcutDisplayLabel(binding.combo, platform);
};
export type ConflictContext = 'global' | 'monaco' | 'datagrid';
export interface ReservedShortcut {
combo: string;
label: string;
context: ConflictContext;
monacoCommandId?: string;
platforms?: ShortcutPlatform[];
}
interface ReservedShortcutDefinition extends Omit<ReservedShortcut, 'label'> {
labelKey: string;
}
export interface ConflictInfo {
label: string;
context: ConflictContext;
monacoCommandId?: string;
}
const RESERVED_SHORTCUT_DEFINITIONS: ReservedShortcutDefinition[] = [
// Browser / WebView built-in shortcuts
{ combo: 'Ctrl+S', labelKey: 'app.shortcuts.reserved.browser_save', context: 'global' },
{ combo: 'Ctrl+P', labelKey: 'app.shortcuts.reserved.browser_print', context: 'global' },
{ combo: 'Ctrl+W', labelKey: 'app.shortcuts.reserved.browser_close_tab', context: 'global' },
{ combo: 'Ctrl+T', labelKey: 'app.shortcuts.reserved.browser_new_tab', context: 'global' },
{ combo: 'Ctrl+N', labelKey: 'app.shortcuts.reserved.browser_new_window', context: 'global' },
{ combo: 'Ctrl+Shift+N', labelKey: 'app.shortcuts.reserved.browser_new_incognito_window', context: 'global' },
// Monaco editor built-in shortcuts
{ combo: 'Ctrl+F', labelKey: 'app.shortcuts.reserved.editor_find', context: 'monaco', monacoCommandId: 'actions.find', platforms: ['windows'] },
{ combo: 'Meta+F', labelKey: 'app.shortcuts.reserved.editor_find', context: 'monaco', monacoCommandId: 'actions.find', platforms: ['mac'] },
{ combo: 'Ctrl+H', labelKey: 'app.shortcuts.reserved.editor_replace', context: 'monaco', monacoCommandId: 'editor.action.startFindReplaceAction', platforms: ['windows'] },
{ combo: 'Meta+H', labelKey: 'app.shortcuts.reserved.editor_replace', context: 'monaco', monacoCommandId: 'editor.action.startFindReplaceAction', platforms: ['mac'] },
{ combo: 'Ctrl+G', labelKey: 'app.shortcuts.reserved.editor_goto_line', context: 'monaco', monacoCommandId: 'editor.action.gotoLine', platforms: ['windows'] },
{ combo: 'Meta+G', labelKey: 'app.shortcuts.reserved.editor_goto_line', context: 'monaco', monacoCommandId: 'editor.action.gotoLine', platforms: ['mac'] },
{ combo: 'Ctrl+P', labelKey: 'app.shortcuts.reserved.editor_quick_open', context: 'monaco', monacoCommandId: 'actions.quickOpen', platforms: ['windows'] },
{ combo: 'Meta+P', labelKey: 'app.shortcuts.reserved.editor_quick_open', context: 'monaco', monacoCommandId: 'actions.quickOpen', platforms: ['mac'] },
{ combo: 'Ctrl+Shift+F', labelKey: 'app.shortcuts.reserved.editor_find_global', context: 'monaco', monacoCommandId: 'actions.quickOpenNavigate', platforms: ['windows'] },
{ combo: 'Meta+Shift+F', labelKey: 'app.shortcuts.reserved.editor_find_global', context: 'monaco', monacoCommandId: 'actions.quickOpenNavigate', platforms: ['mac'] },
{ combo: 'Ctrl+D', labelKey: 'app.shortcuts.reserved.editor_add_selection', context: 'monaco', monacoCommandId: 'editor.action.addSelectionToNextFindMatch', platforms: ['windows'] },
{ combo: 'Meta+D', labelKey: 'app.shortcuts.reserved.editor_add_selection', context: 'monaco', monacoCommandId: 'editor.action.addSelectionToNextFindMatch', platforms: ['mac'] },
{ combo: 'Ctrl+Shift+K', labelKey: 'app.shortcuts.reserved.editor_delete_line', context: 'monaco', monacoCommandId: 'editor.action.deleteLines', platforms: ['windows'] },
{ combo: 'Meta+Shift+K', labelKey: 'app.shortcuts.reserved.editor_delete_line', context: 'monaco', monacoCommandId: 'editor.action.deleteLines', platforms: ['mac'] },
{ combo: 'Ctrl+Enter', labelKey: 'app.shortcuts.reserved.editor_insert_line_after', context: 'monaco', monacoCommandId: 'editor.action.insertLineAfter', platforms: ['windows'] },
{ combo: 'Meta+Enter', labelKey: 'app.shortcuts.reserved.editor_insert_line_after', context: 'monaco', monacoCommandId: 'editor.action.insertLineAfter', platforms: ['mac'] },
{ combo: 'Ctrl+Shift+Enter', labelKey: 'app.shortcuts.reserved.editor_insert_line_before', context: 'monaco', monacoCommandId: 'editor.action.insertLineBefore', platforms: ['windows'] },
{ combo: 'Meta+Shift+Enter', labelKey: 'app.shortcuts.reserved.editor_insert_line_before', context: 'monaco', monacoCommandId: 'editor.action.insertLineBefore', platforms: ['mac'] },
{ combo: 'F2', labelKey: 'app.shortcuts.reserved.editor_rename_symbol', context: 'monaco', monacoCommandId: 'editor.action.rename' },
// DataGrid shortcuts
{ combo: 'Ctrl+C', labelKey: 'app.shortcuts.reserved.datagrid_copy', context: 'datagrid', platforms: ['windows'] },
{ combo: 'Meta+C', labelKey: 'app.shortcuts.reserved.datagrid_copy', context: 'datagrid', platforms: ['mac'] },
];
export const RESERVED_SHORTCUTS: ReservedShortcut[] = RESERVED_SHORTCUT_DEFINITIONS.map((definition) => ({
...definition,
get label() {
return localizeShortcut(definition.labelKey);
},
}));
const CONTEXT_DESCRIPTION_KEYS: Record<ConflictContext, string> = {
global: 'app.shortcuts.context.global',
monaco: 'app.shortcuts.context.monaco',
datagrid: 'app.shortcuts.context.datagrid',
};
export const describeConflictContext = (context: ConflictContext): string => {
const key = CONTEXT_DESCRIPTION_KEYS[context];
return key ? localizeShortcut(key) : 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 = findReservedConflicts(normalizedCombo)[0];
if (!conflict) return null;
return conflict;
};
export const findReservedConflicts = (normalizedCombo: string, platform?: ShortcutPlatform): ConflictInfo[] => {
return RESERVED_SHORTCUTS
.filter((r) => r.combo === normalizedCombo && (!platform || !r.platforms || r.platforms.includes(platform)))
.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<string, number>) => number;
const MONACO_KEY_MAP: Record<string, KeyCodeResolver> = {
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<string, number>): 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<string, number>,
keyCodeEnum: Record<string, number>,
): 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 };
};