diff --git a/docs/references/data/preference-schema-guide.md b/docs/references/data/preference-schema-guide.md index e5d6cf3b71c..9bb0e076030 100644 --- a/docs/references/data/preference-schema-guide.md +++ b/docs/references/data/preference-schema-guide.md @@ -74,7 +74,7 @@ Only use object values when the data is frequently read/written as a whole unit. ```typescript // Acceptable: Shortcut config is always read/written together -'shortcut.app.show_main_window': { key: string[], enabled: boolean, ... } +'shortcut.general.show_main_window': { binding: string[], enabled: boolean } ``` **Rule of thumb:** If you find yourself frequently accessing just one property of an object, split it into separate keys. diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index dde06f1ba9f..79430323e54 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -181,6 +181,8 @@ export enum IpcChannel { Windows_IsMaximized = 'window:is-maximized', Windows_MaximizedChanged = 'window:maximized-changed', Windows_NavigateToAbout = 'window:navigate-to-about', + Windows_NavigateToSettings = 'window:navigate-to-settings', + Shortcut_RegistrationConflict = 'shortcut:registration-conflict', // Tab Tab_Attach = 'tab:attach', @@ -261,8 +263,6 @@ export enum IpcChannel { Export_Word = 'export:word', - Shortcuts_Update = 'shortcuts:update', - // backup Backup_Backup = 'backup:backup', Backup_Restore = 'backup:restore', diff --git a/packages/shared/data/bootConfig/bootConfigSchemas.ts b/packages/shared/data/bootConfig/bootConfigSchemas.ts index 08923905d7f..245266fb133 100644 --- a/packages/shared/data/bootConfig/bootConfigSchemas.ts +++ b/packages/shared/data/bootConfig/bootConfigSchemas.ts @@ -1,6 +1,6 @@ /** * Auto-generated boot config schema - * Generated at: 2026-04-13T03:21:18.289Z + * Generated at: 2026-04-14T03:21:01.612Z * * This file is automatically generated from classification.json (plus a * small MANUAL_BOOT_CONFIG_ITEMS list in generate-boot-config.js for keys diff --git a/packages/shared/data/preference/preferenceSchemas.ts b/packages/shared/data/preference/preferenceSchemas.ts index ac6c7d3034b..3772f939b6b 100644 --- a/packages/shared/data/preference/preferenceSchemas.ts +++ b/packages/shared/data/preference/preferenceSchemas.ts @@ -1,6 +1,6 @@ /** * Auto-generated preferences configuration - * Generated at: 2026-04-13T03:21:18.282Z + * Generated at: 2026-04-14T03:21:01.605Z * * This file is automatically generated from classification.json * To update this file, modify classification.json and run: @@ -424,38 +424,46 @@ export interface PreferenceSchemas { 'feature.translate.page.source_language': PreferenceTypes.TranslateSourceLanguage // dexieSettings/settings/translate:target:language 'feature.translate.page.target_language': PreferenceTypes.TranslateLanguageCode + // redux/shortcuts/shortcuts.clear_topic + 'shortcut.chat.clear': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.copy_last_message + 'shortcut.chat.copy_last_message': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.edit_last_user_message + 'shortcut.chat.edit_last_user_message': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.search_message_in_chat + 'shortcut.chat.search_message': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.select_model + 'shortcut.chat.select_model': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.toggle_new_context + 'shortcut.chat.toggle_new_context': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.mini_window + 'shortcut.feature.quick_assistant.toggle_window': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.selection_assistant_select_text + 'shortcut.feature.selection.get_text': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.selection_assistant_toggle + 'shortcut.feature.selection.toggle_enabled': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.exit_fullscreen - 'shortcut.app.exit_fullscreen': Record + 'shortcut.general.exit_fullscreen': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.search_message - 'shortcut.app.search_message': Record + 'shortcut.general.search': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.show_app - 'shortcut.app.show_main_window': Record - // redux/shortcuts/shortcuts.mini_window - 'shortcut.app.show_mini_window': Record + 'shortcut.general.show_main_window': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.show_settings - 'shortcut.app.show_settings': Record + 'shortcut.general.show_settings': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.toggle_show_assistants - 'shortcut.app.toggle_show_assistants': Record + 'shortcut.general.toggle_sidebar': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_in - 'shortcut.app.zoom_in': Record + 'shortcut.general.zoom_in': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_out - 'shortcut.app.zoom_out': Record + 'shortcut.general.zoom_out': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_reset - 'shortcut.app.zoom_reset': Record - // redux/shortcuts/shortcuts.clear_topic - 'shortcut.chat.clear': Record - // redux/shortcuts/shortcuts.copy_last_message - 'shortcut.chat.copy_last_message': Record - // redux/shortcuts/shortcuts.search_message_in_chat - 'shortcut.chat.search_message': Record - // redux/shortcuts/shortcuts.toggle_new_context - 'shortcut.chat.toggle_new_context': Record - // redux/shortcuts/shortcuts.selection_assistant_select_text - 'shortcut.selection.get_text': Record - // redux/shortcuts/shortcuts.selection_assistant_toggle - 'shortcut.selection.toggle_enabled': Record + 'shortcut.general.zoom_reset': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.new_topic - 'shortcut.topic.new': Record + 'shortcut.topic.new': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.rename_topic + 'shortcut.topic.rename': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.toggle_show_topics + 'shortcut.topic.toggle_show_topics': PreferenceTypes.PreferenceShortcutType // redux/settings/enableTopicNaming 'topic.naming.enabled': boolean // redux/settings/topicNamingPrompt @@ -713,42 +721,26 @@ export const DefaultPreferences: PreferenceSchemas = { 'feature.translate.page.scroll_sync': false, 'feature.translate.page.source_language': 'auto', 'feature.translate.page.target_language': 'zh-cn', - 'shortcut.app.exit_fullscreen': { editable: false, enabled: true, key: ['Escape'], system: true }, - 'shortcut.app.search_message': { - editable: true, - enabled: true, - key: ['CommandOrControl', 'Shift', 'F'], - system: false - }, - 'shortcut.app.show_main_window': { editable: true, enabled: true, key: [], system: true }, - 'shortcut.app.show_mini_window': { editable: true, enabled: false, key: ['CommandOrControl', 'E'], system: true }, - 'shortcut.app.show_settings': { editable: false, enabled: true, key: ['CommandOrControl', ','], system: true }, - 'shortcut.app.toggle_show_assistants': { - editable: true, - enabled: true, - key: ['CommandOrControl', '['], - system: false - }, - 'shortcut.app.zoom_in': { editable: false, enabled: true, key: ['CommandOrControl', '='], system: true }, - 'shortcut.app.zoom_out': { editable: false, enabled: true, key: ['CommandOrControl', '-'], system: true }, - 'shortcut.app.zoom_reset': { editable: false, enabled: true, key: ['CommandOrControl', '0'], system: true }, - 'shortcut.chat.clear': { editable: true, enabled: true, key: ['CommandOrControl', 'L'], system: false }, - 'shortcut.chat.copy_last_message': { - editable: true, - enabled: false, - key: ['CommandOrControl', 'Shift', 'C'], - system: false - }, - 'shortcut.chat.search_message': { editable: true, enabled: true, key: ['CommandOrControl', 'F'], system: false }, - 'shortcut.chat.toggle_new_context': { - editable: true, - enabled: true, - key: ['CommandOrControl', 'K'], - system: false - }, - 'shortcut.selection.get_text': { editable: true, enabled: false, key: [], system: true }, - 'shortcut.selection.toggle_enabled': { editable: true, enabled: false, key: [], system: true }, - 'shortcut.topic.new': { editable: true, enabled: true, key: ['CommandOrControl', 'N'], system: false }, + 'shortcut.chat.clear': { binding: ['CommandOrControl', 'L'], enabled: true }, + 'shortcut.chat.copy_last_message': { binding: ['CommandOrControl', 'Shift', 'C'], enabled: false }, + 'shortcut.chat.edit_last_user_message': { binding: ['CommandOrControl', 'Shift', 'E'], enabled: false }, + 'shortcut.chat.search_message': { binding: ['CommandOrControl', 'F'], enabled: true }, + 'shortcut.chat.select_model': { binding: ['CommandOrControl', 'Shift', 'M'], enabled: true }, + 'shortcut.chat.toggle_new_context': { binding: ['CommandOrControl', 'K'], enabled: true }, + 'shortcut.feature.quick_assistant.toggle_window': { binding: ['CommandOrControl', 'E'], enabled: false }, + 'shortcut.feature.selection.get_text': { binding: [], enabled: false }, + 'shortcut.feature.selection.toggle_enabled': { binding: [], enabled: false }, + 'shortcut.general.exit_fullscreen': { binding: ['Escape'], enabled: true }, + 'shortcut.general.search': { binding: ['CommandOrControl', 'Shift', 'F'], enabled: true }, + 'shortcut.general.show_main_window': { binding: [], enabled: false }, + 'shortcut.general.show_settings': { binding: ['CommandOrControl', ','], enabled: true }, + 'shortcut.general.toggle_sidebar': { binding: ['CommandOrControl', '['], enabled: true }, + 'shortcut.general.zoom_in': { binding: ['CommandOrControl', '='], enabled: true }, + 'shortcut.general.zoom_out': { binding: ['CommandOrControl', '-'], enabled: true }, + 'shortcut.general.zoom_reset': { binding: ['CommandOrControl', '0'], enabled: true }, + 'shortcut.topic.new': { binding: ['CommandOrControl', 'N'], enabled: true }, + 'shortcut.topic.rename': { binding: ['CommandOrControl', 'T'], enabled: false }, + 'shortcut.topic.toggle_show_topics': { binding: ['CommandOrControl', ']'], enabled: true }, 'topic.naming.enabled': true, 'topic.naming_prompt': '', 'topic.position': 'left', @@ -782,9 +774,9 @@ export const DefaultPreferences: PreferenceSchemas = { /** * 生成统计: - * - 总配置项: 225 + * - 总配置项: 229 * - electronStore项: 1 - * - redux项: 205 + * - redux项: 209 * - localStorage项: 0 * - dexieSettings项: 7 */ diff --git a/packages/shared/data/preference/preferenceTypes.ts b/packages/shared/data/preference/preferenceTypes.ts index 139d0533760..ee6021b2465 100644 --- a/packages/shared/data/preference/preferenceTypes.ts +++ b/packages/shared/data/preference/preferenceTypes.ts @@ -22,10 +22,8 @@ export type PreferenceUpdateOptions = { } export type PreferenceShortcutType = { - key: string[] - editable: boolean + binding: string[] enabled: boolean - system: boolean } export enum SelectionTriggerMode { diff --git a/packages/shared/shortcuts/__tests__/shortcutUtils.test.ts b/packages/shared/shortcuts/__tests__/shortcutUtils.test.ts new file mode 100644 index 00000000000..bdcce86d1df --- /dev/null +++ b/packages/shared/shortcuts/__tests__/shortcutUtils.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest' + +import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '../definitions' +import type { ShortcutDefinition } from '../types' +import { + convertAcceleratorToHotkey, + convertKeyToAccelerator, + formatShortcutDisplay, + getDefaultShortcut, + isShortcutDefinitionEnabled, + isValidShortcut, + resolveShortcutPreference +} from '../utils' + +const makeDefinition = (overrides: Partial = {}): ShortcutDefinition => ({ + key: 'shortcut.chat.clear', + scope: 'renderer', + category: 'chat', + labelKey: 'clear_topic', + ...overrides +}) + +describe('convertKeyToAccelerator', () => { + it('maps known keys to accelerator format', () => { + expect(convertKeyToAccelerator('Command')).toBe('CommandOrControl') + expect(convertKeyToAccelerator('Cmd')).toBe('CommandOrControl') + expect(convertKeyToAccelerator('Control')).toBe('Ctrl') + expect(convertKeyToAccelerator('ArrowUp')).toBe('Up') + expect(convertKeyToAccelerator('ArrowDown')).toBe('Down') + expect(convertKeyToAccelerator('Slash')).toBe('/') + expect(convertKeyToAccelerator('BracketLeft')).toBe('[') + }) + + it('returns the key unchanged if not in the map', () => { + expect(convertKeyToAccelerator('A')).toBe('A') + expect(convertKeyToAccelerator('Shift')).toBe('Shift') + }) +}) + +describe('convertAcceleratorToHotkey', () => { + it('converts modifier keys to hotkey format', () => { + expect(convertAcceleratorToHotkey(['CommandOrControl', 'L'])).toBe('mod+l') + expect(convertAcceleratorToHotkey(['Ctrl', 'Shift', 'F'])).toBe('ctrl+shift+f') + expect(convertAcceleratorToHotkey(['Alt', 'N'])).toBe('alt+n') + expect(convertAcceleratorToHotkey(['Command', 'K'])).toBe('meta+k') + expect(convertAcceleratorToHotkey(['Meta', 'E'])).toBe('meta+e') + }) + + it('handles single keys', () => { + expect(convertAcceleratorToHotkey(['Escape'])).toBe('escape') + }) +}) + +describe('formatShortcutDisplay', () => { + it('formats for Mac with symbols', () => { + expect(formatShortcutDisplay(['CommandOrControl', 'L'], true)).toBe('⌘L') + expect(formatShortcutDisplay(['Ctrl', 'Shift', 'F'], true)).toBe('⌃⇧F') + expect(formatShortcutDisplay(['Alt', 'N'], true)).toBe('⌥N') + expect(formatShortcutDisplay(['Meta', 'E'], true)).toBe('⌘E') + }) + + it('formats for non-Mac with text', () => { + expect(formatShortcutDisplay(['CommandOrControl', 'L'], false)).toBe('Ctrl+L') + expect(formatShortcutDisplay(['Ctrl', 'Shift', 'F'], false)).toBe('Ctrl+Shift+F') + expect(formatShortcutDisplay(['Alt', 'N'], false)).toBe('Alt+N') + expect(formatShortcutDisplay(['Meta', 'E'], false)).toBe('Win+E') + }) + + it('capitalizes non-modifier keys', () => { + expect(formatShortcutDisplay(['Escape'], true)).toBe('Escape') + expect(formatShortcutDisplay(['f1'], false)).toBe('F1') + }) +}) + +describe('isValidShortcut', () => { + it('returns false for empty array', () => { + expect(isValidShortcut([])).toBe(false) + }) + + it('returns true for modifier + non-modifier key', () => { + expect(isValidShortcut(['CommandOrControl', 'A'])).toBe(true) + expect(isValidShortcut(['Ctrl', 'Shift', 'N'])).toBe(true) + expect(isValidShortcut(['Alt', 'X'])).toBe(true) + }) + + it('returns false for modifier-only combinations', () => { + expect(isValidShortcut(['CommandOrControl'])).toBe(false) + expect(isValidShortcut(['Ctrl', 'Shift'])).toBe(false) + expect(isValidShortcut(['Alt', 'Meta'])).toBe(false) + }) + + it('returns true for special single keys', () => { + expect(isValidShortcut(['Escape'])).toBe(true) + expect(isValidShortcut(['F1'])).toBe(true) + expect(isValidShortcut(['F12'])).toBe(true) + }) + + it('returns false for non-modifier non-special single key', () => { + expect(isValidShortcut(['A'])).toBe(false) + expect(isValidShortcut(['L'])).toBe(false) + }) +}) + +describe('getDefaultShortcut', () => { + it('returns default preference from schema defaults', () => { + const def = makeDefinition() + const result = getDefaultShortcut(def) + + expect(result.binding).toEqual(['CommandOrControl', 'L']) + expect(result.enabled).toBe(true) + }) +}) + +describe('resolveShortcutPreference', () => { + it('returns fallback when value is undefined', () => { + const def = makeDefinition() + const result = resolveShortcutPreference(def, undefined) + + expect(result.binding).toEqual(['CommandOrControl', 'L']) + expect(result.enabled).toBe(true) + }) + + it('returns fallback when value is null', () => { + const def = makeDefinition() + const result = resolveShortcutPreference(def, null) + + expect(result.binding).toEqual(['CommandOrControl', 'L']) + }) + + it('uses custom binding when provided', () => { + const def = makeDefinition() + const result = resolveShortcutPreference(def, { + binding: ['Alt', 'L'], + enabled: true + }) + + expect(result.binding).toEqual(['Alt', 'L']) + }) + + it('returns empty binding when binding is explicitly cleared (empty array)', () => { + const def = makeDefinition() + const result = resolveShortcutPreference(def, { + binding: [], + enabled: true + }) + + expect(result.binding).toEqual([]) + }) + + it('respects enabled: false from preference', () => { + const def = makeDefinition() + const result = resolveShortcutPreference(def, { + binding: ['CommandOrControl', 'L'], + enabled: false + }) + + expect(result.enabled).toBe(false) + }) +}) + +describe('isShortcutDefinitionEnabled', () => { + it('returns true when no dependency is declared', () => { + expect(isShortcutDefinitionEnabled(makeDefinition(), () => false)).toBe(true) + }) + + it('returns true when the required preference is enabled', () => { + const def = makeDefinition({ enabledWhen: 'feature.quick_assistant.enabled' }) + expect(isShortcutDefinitionEnabled(def, () => true)).toBe(true) + }) + + it('returns false when the required preference is disabled', () => { + const def = makeDefinition({ enabledWhen: 'feature.quick_assistant.enabled' }) + expect(isShortcutDefinitionEnabled(def, () => false)).toBe(false) + }) +}) + +describe('SHORTCUT_DEFINITIONS', () => { + it('has unique preference keys', () => { + const keys = SHORTCUT_DEFINITIONS.map((d) => d.key) + const unique = new Set(keys) + expect(unique.size).toBe(keys.length) + }) + + it('has non-empty labelKey for every entry', () => { + for (const def of SHORTCUT_DEFINITIONS) { + expect(def.labelKey, `missing labelKey for ${def.key}`).toBeTruthy() + } + }) + + it('uses `shortcut.` prefix for every preference key', () => { + for (const def of SHORTCUT_DEFINITIONS) { + expect(def.key.startsWith('shortcut.')).toBe(true) + } + }) + + it('has schema defaults for every definition', () => { + for (const def of SHORTCUT_DEFINITIONS) { + const resolved = getDefaultShortcut(def) + expect(Array.isArray(resolved.binding), `missing default binding for ${def.key}`).toBe(true) + expect(typeof resolved.enabled, `missing default enabled flag for ${def.key}`).toBe('boolean') + } + }) + + it('is resolvable via findShortcutDefinition', () => { + for (const def of SHORTCUT_DEFINITIONS) { + expect(findShortcutDefinition(def.key)).toBe(def) + } + }) + + it('returns undefined for unknown keys', () => { + expect(findShortcutDefinition('shortcut.unknown.nope' as never)).toBeUndefined() + }) +}) diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts new file mode 100644 index 00000000000..0c9dcb735d7 --- /dev/null +++ b/packages/shared/shortcuts/definitions.ts @@ -0,0 +1,156 @@ +import type { ShortcutDefinition, ShortcutPreferenceKey } from './types' + +const defineShortcut = (definition: T): ShortcutDefinition & T => definition + +export const SHORTCUT_DEFINITIONS = [ + // ==================== Application shortcuts ==================== + defineShortcut({ + key: 'shortcut.general.show_main_window', + scope: 'main', + category: 'general', + labelKey: 'show_app', + global: true + }), + defineShortcut({ + key: 'shortcut.general.show_settings', + scope: 'main', + category: 'general', + labelKey: 'show_settings', + editable: false + }), + defineShortcut({ + key: 'shortcut.general.toggle_sidebar', + scope: 'renderer', + category: 'general', + labelKey: 'toggle_sidebar' + }), + defineShortcut({ + key: 'shortcut.general.exit_fullscreen', + scope: 'renderer', + category: 'general', + labelKey: 'exit_fullscreen', + editable: false + }), + defineShortcut({ + key: 'shortcut.general.zoom_in', + scope: 'main', + category: 'general', + labelKey: 'zoom_in', + editable: false, + variants: [['CommandOrControl', 'numadd']] + }), + defineShortcut({ + key: 'shortcut.general.zoom_out', + scope: 'main', + category: 'general', + labelKey: 'zoom_out', + editable: false, + variants: [['CommandOrControl', 'numsub']] + }), + defineShortcut({ + key: 'shortcut.general.zoom_reset', + scope: 'main', + category: 'general', + labelKey: 'zoom_reset', + editable: false + }), + defineShortcut({ + key: 'shortcut.general.search', + scope: 'renderer', + category: 'general', + labelKey: 'search_message' + }), + // ==================== Chat shortcuts ==================== + defineShortcut({ + key: 'shortcut.chat.clear', + scope: 'renderer', + category: 'chat', + labelKey: 'clear_topic' + }), + defineShortcut({ + key: 'shortcut.chat.search_message', + scope: 'renderer', + category: 'chat', + labelKey: 'search_message_in_chat' + }), + defineShortcut({ + key: 'shortcut.chat.toggle_new_context', + scope: 'renderer', + category: 'chat', + labelKey: 'toggle_new_context' + }), + defineShortcut({ + key: 'shortcut.chat.copy_last_message', + scope: 'renderer', + category: 'chat', + labelKey: 'copy_last_message' + }), + defineShortcut({ + key: 'shortcut.chat.edit_last_user_message', + scope: 'renderer', + category: 'chat', + labelKey: 'edit_last_user_message' + }), + defineShortcut({ + key: 'shortcut.chat.select_model', + scope: 'renderer', + category: 'chat', + labelKey: 'select_model' + }), + // ==================== Topic shortcuts ==================== + defineShortcut({ + key: 'shortcut.topic.new', + scope: 'renderer', + category: 'topic', + labelKey: 'new_topic' + }), + defineShortcut({ + key: 'shortcut.topic.rename', + scope: 'renderer', + category: 'topic', + labelKey: 'rename_topic' + }), + defineShortcut({ + key: 'shortcut.topic.toggle_show_topics', + scope: 'renderer', + category: 'topic', + labelKey: 'toggle_show_topics' + }), + // ==================== Feature shortcuts ==================== + defineShortcut({ + key: 'shortcut.feature.quick_assistant.toggle_window', + scope: 'main', + category: 'feature.quick_assistant', + labelKey: 'mini_window', + global: true, + enabledWhen: 'feature.quick_assistant.enabled' + }), + defineShortcut({ + key: 'shortcut.feature.selection.toggle_enabled', + scope: 'main', + category: 'feature.selection', + labelKey: 'selection_assistant_toggle', + global: true, + enabledWhen: 'feature.selection.enabled', + supportedPlatforms: ['darwin', 'win32'] + }), + defineShortcut({ + key: 'shortcut.feature.selection.get_text', + scope: 'main', + category: 'feature.selection', + labelKey: 'selection_assistant_select_text', + global: true, + enabledWhen: 'feature.selection.enabled', + supportedPlatforms: ['darwin', 'win32'] + }) +] as const satisfies readonly ShortcutDefinition[] + +export type ShortcutLabelKey = (typeof SHORTCUT_DEFINITIONS)[number]['labelKey'] + +const definitionMap = new Map( + SHORTCUT_DEFINITIONS.map((definition) => [definition.key, definition]) +) + +export const findShortcutDefinition = (key: ShortcutPreferenceKey): ShortcutDefinition | undefined => { + return definitionMap.get(key) +} diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts new file mode 100644 index 00000000000..47b6c1e8119 --- /dev/null +++ b/packages/shared/shortcuts/types.ts @@ -0,0 +1,51 @@ +import type { PreferenceKeyType } from '@shared/data/preference/preferenceTypes' + +export type ShortcutScope = 'main' | 'renderer' | 'both' + +/** Built-in shortcut categories for UI grouping. */ +export type BuiltinShortcutCategory = 'general' | 'chat' | 'topic' | 'feature.quick_assistant' | 'feature.selection' + +/** + * Dot-separated namespace for UI grouping in the settings page. + * Built-in: `general`, `chat`, `topic`, `feature.quick_assistant`, `feature.selection`. + * Plugins: `plugin.{pluginId}` (e.g. `plugin.translator`). + */ +export type ShortcutCategory = BuiltinShortcutCategory | `plugin.${string}` + +/** Desktop platforms actually supported by Cherry Studio */ +export type SupportedPlatform = Extract + +export type ShortcutPreferenceKey = Extract +export type ShortcutDependencyPreferenceKey = Extract + +export type ShortcutKey = ShortcutPreferenceKey extends `shortcut.${infer Rest}` ? Rest : never + +/** Static metadata for a single shortcut — the single source of truth for the shortcut system. */ +export interface ShortcutDefinition { + /** Preference key in `shortcut.{category}.{name}` format for built-in shortcuts. Plugins use `shortcut.plugin.{pluginId}.{name}`. */ + key: ShortcutPreferenceKey + /** Where the shortcut is registered: `main` (globalShortcut), `renderer` (react-hotkeys-hook), or `both`. */ + scope: ShortcutScope + /** Dot-separated category for UI grouping (e.g. `general`, `chat`, `topic`, `plugin.translator`). */ + category: ShortcutCategory + /** i18n label key used by `getShortcutLabel()` for display. */ + labelKey: string + /** Whether users can modify the binding in settings. Defaults to `true`. */ + editable?: boolean + /** Global shortcut — stays registered when the window loses focus. Aligns with Electron `globalShortcut`. */ + global?: boolean + /** Additional equivalent bindings for the same action (e.g. numpad variants for zoom). */ + variants?: string[][] + /** Restrict this shortcut to specific operating systems. Omit to enable on all platforms. */ + supportedPlatforms?: SupportedPlatform[] + /** Optional feature toggle that must be enabled before this shortcut can be shown or registered. */ + enabledWhen?: ShortcutDependencyPreferenceKey +} + +/** Runtime-resolved shortcut state after merging user preferences with definition defaults. */ +export interface ResolvedShortcut { + /** Effective key binding used at runtime. User-defined, default, or empty (explicitly cleared). */ + binding: string[] + /** Whether this shortcut is currently enabled. */ + enabled: boolean +} diff --git a/packages/shared/shortcuts/utils.ts b/packages/shared/shortcuts/utils.ts new file mode 100644 index 00000000000..48b79307001 --- /dev/null +++ b/packages/shared/shortcuts/utils.ts @@ -0,0 +1,138 @@ +import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas' +import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' + +import type { ResolvedShortcut, ShortcutDefinition } from './types' + +const modifierKeys = ['CommandOrControl', 'Ctrl', 'Alt', 'Shift', 'Meta', 'Command'] +const specialSingleKeys = ['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'] + +const acceleratorKeyMap: Record = { + Command: 'CommandOrControl', + Cmd: 'CommandOrControl', + Control: 'Ctrl', + Meta: 'Meta', + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + AltGraph: 'AltGr', + Slash: '/', + Semicolon: ';', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Quote: "'", + Comma: ',', + Minus: '-', + Equal: '=' +} + +export const convertKeyToAccelerator = (key: string): string => acceleratorKeyMap[key] || key + +export const convertAcceleratorToHotkey = (accelerator: string[]): string => { + return accelerator + .map((key) => { + switch (key.toLowerCase()) { + case 'commandorcontrol': + return 'mod' + case 'command': + case 'cmd': + return 'meta' + case 'control': + case 'ctrl': + return 'ctrl' + case 'alt': + return 'alt' + case 'shift': + return 'shift' + case 'meta': + return 'meta' + default: + return key.toLowerCase() + } + }) + .join('+') +} + +export const formatKeyDisplay = (key: string, isMac: boolean): string => { + switch (key.toLowerCase()) { + case 'ctrl': + case 'control': + return isMac ? '⌃' : 'Ctrl' + case 'command': + case 'cmd': + return isMac ? '⌘' : 'Win' + case 'commandorcontrol': + return isMac ? '⌘' : 'Ctrl' + case 'alt': + return isMac ? '⌥' : 'Alt' + case 'shift': + return isMac ? '⇧' : 'Shift' + case 'meta': + return isMac ? '⌘' : 'Win' + default: + return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase() + } +} + +export const formatShortcutDisplay = (keys: string[], isMac: boolean): string => { + return keys.map((key) => formatKeyDisplay(key, isMac)).join(isMac ? '' : '+') +} + +export const isValidShortcut = (keys: string[]): boolean => { + if (!keys.length) { + return false + } + + if (new Set(keys).size !== keys.length) { + return false + } + + const hasModifier = keys.some((key) => modifierKeys.includes(key)) + const hasNonModifier = keys.some((key) => !modifierKeys.includes(key)) + const isSpecialKey = keys.length === 1 && specialSingleKeys.includes(keys[0]) + + return (hasModifier && hasNonModifier) || isSpecialKey +} + +const ensureArray = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string') + } + return [] +} + +const ensureBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback) + +export const getDefaultShortcut = (definition: ShortcutDefinition): ResolvedShortcut => { + const fallback = DefaultPreferences.default[definition.key] + + return { + binding: ensureArray(fallback?.binding), + enabled: ensureBoolean(fallback?.enabled, true) + } +} + +export const resolveShortcutPreference = ( + definition: ShortcutDefinition, + value?: PreferenceShortcutType | null +): ResolvedShortcut => { + const fallback = getDefaultShortcut(definition) + const binding = value != null ? (value.binding?.length ? ensureArray(value.binding) : []) : fallback.binding + + return { + binding, + enabled: ensureBoolean(value?.enabled, fallback.enabled) + } +} + +export const isShortcutDefinitionEnabled = ( + definition: ShortcutDefinition, + getPreferenceValue: (key: NonNullable) => unknown +): boolean => { + if (!definition.enabledWhen) { + return true + } + + return getPreferenceValue(definition.enabledWhen) === true +} diff --git a/src/main/data/migration/v2/migrators/PreferencesMigrator.ts b/src/main/data/migration/v2/migrators/PreferencesMigrator.ts index ef72ab957c2..1a166ae16bc 100644 --- a/src/main/data/migration/v2/migrators/PreferencesMigrator.ts +++ b/src/main/data/migration/v2/migrators/PreferencesMigrator.ts @@ -306,6 +306,11 @@ export class PreferencesMigrator extends BaseMigrator { // Process Redux mappings for (const [category, mappings] of Object.entries(REDUX_STORE_MAPPINGS)) { for (const mapping of mappings) { + // Shortcut entries are handled by a complex mapping because the legacy + // Redux source stores them as an array, which cannot be read via + // `reduxState.get(category, key)`. See ShortcutMappings.ts. + if (mapping.targetKey.startsWith('shortcut.')) continue + const defaultValue = DefaultPreferences.default[mapping.targetKey] ?? null items.push({ originalKey: mapping.originalKey, @@ -353,9 +358,10 @@ export class PreferencesMigrator extends BaseMigrator { keys.push(mapping.targetKey) } - // Collect from Redux mappings + // Collect from Redux mappings (excluding shortcuts — handled by complex mapping) for (const mappings of Object.values(REDUX_STORE_MAPPINGS)) { for (const mapping of mappings) { + if (mapping.targetKey.startsWith('shortcut.')) continue keys.push(mapping.targetKey) } } diff --git a/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts b/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts index 89e8d6e27c9..39e401e8040 100644 --- a/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts @@ -1,6 +1,6 @@ /** * Auto-generated boot config mappings from classification.json - * Generated at: 2026-04-13T03:21:18.293Z + * Generated at: 2026-04-14T03:21:01.613Z * * This file contains pure mapping relationships without default values. * Default values are managed in packages/shared/data/bootConfig/bootConfigSchemas.ts diff --git a/src/main/data/migration/v2/migrators/mappings/ComplexPreferenceMappings.ts b/src/main/data/migration/v2/migrators/mappings/ComplexPreferenceMappings.ts index f8afac87190..8599f72e3d2 100644 --- a/src/main/data/migration/v2/migrators/mappings/ComplexPreferenceMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/ComplexPreferenceMappings.ts @@ -21,6 +21,7 @@ import { flattenCompressionConfig, migrateWebSearchProviders } from '../transformers/PreferenceTransformers' import { transformCodeCli } from './CodeCliTransforms' import { mergeFileProcessingOverrides } from './FileProcessingOverrideMappings' +import { SHORTCUT_TARGET_KEYS, transformShortcuts } from './ShortcutMappings' // ============================================================================ // Type Definitions @@ -128,6 +129,17 @@ export const COMPLEX_PREFERENCE_MAPPINGS: ComplexMapping[] = [ transform: transformCodeCli }, + // Shortcut preferences (legacy array → per-key PreferenceShortcutType) + { + id: 'shortcut_preferences_migrate', + description: 'Convert legacy shortcuts array into per-key { binding, enabled } preferences', + sources: { + shortcuts: { source: 'redux', category: 'shortcuts', key: 'shortcuts' } + }, + targetKeys: [...SHORTCUT_TARGET_KEYS], + transform: transformShortcuts + }, + // File processing overrides merging { id: 'file_processing_overrides_merge', diff --git a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts index be885149f3e..97190c8b893 100644 --- a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts @@ -1,6 +1,6 @@ /** * Auto-generated preference mappings from classification.json - * Generated at: 2026-04-13T03:21:18.293Z + * Generated at: 2026-04-14T03:21:01.613Z * * This file contains pure mapping relationships without default values. * Default values are managed in packages/shared/data/preferences.ts @@ -750,55 +750,55 @@ export const REDUX_STORE_MAPPINGS = { shortcuts: [ { originalKey: 'shortcuts.zoom_in', - targetKey: 'shortcut.app.zoom_in' + targetKey: 'shortcut.general.zoom_in' }, { originalKey: 'shortcuts.zoom_out', - targetKey: 'shortcut.app.zoom_out' + targetKey: 'shortcut.general.zoom_out' }, { originalKey: 'shortcuts.zoom_reset', - targetKey: 'shortcut.app.zoom_reset' + targetKey: 'shortcut.general.zoom_reset' }, { originalKey: 'shortcuts.show_settings', - targetKey: 'shortcut.app.show_settings' + targetKey: 'shortcut.general.show_settings' }, { originalKey: 'shortcuts.show_app', - targetKey: 'shortcut.app.show_main_window' + targetKey: 'shortcut.general.show_main_window' }, { - originalKey: 'shortcuts.mini_window', - targetKey: 'shortcut.app.show_mini_window' + originalKey: 'shortcuts.new_topic', + targetKey: 'shortcut.topic.new' }, { - originalKey: 'shortcuts.selection_assistant_toggle', - targetKey: 'shortcut.selection.toggle_enabled' + originalKey: 'shortcuts.rename_topic', + targetKey: 'shortcut.topic.rename' }, { - originalKey: 'shortcuts.selection_assistant_select_text', - targetKey: 'shortcut.selection.get_text' - }, - { - originalKey: 'shortcuts.new_topic', - targetKey: 'shortcut.topic.new' + originalKey: 'shortcuts.toggle_show_topics', + targetKey: 'shortcut.topic.toggle_show_topics' }, { originalKey: 'shortcuts.toggle_show_assistants', - targetKey: 'shortcut.app.toggle_show_assistants' + targetKey: 'shortcut.general.toggle_sidebar' }, { originalKey: 'shortcuts.copy_last_message', targetKey: 'shortcut.chat.copy_last_message' }, + { + originalKey: 'shortcuts.edit_last_user_message', + targetKey: 'shortcut.chat.edit_last_user_message' + }, { originalKey: 'shortcuts.search_message_in_chat', targetKey: 'shortcut.chat.search_message' }, { originalKey: 'shortcuts.search_message', - targetKey: 'shortcut.app.search_message' + targetKey: 'shortcut.general.search' }, { originalKey: 'shortcuts.clear_topic', @@ -808,9 +808,25 @@ export const REDUX_STORE_MAPPINGS = { originalKey: 'shortcuts.toggle_new_context', targetKey: 'shortcut.chat.toggle_new_context' }, + { + originalKey: 'shortcuts.select_model', + targetKey: 'shortcut.chat.select_model' + }, { originalKey: 'shortcuts.exit_fullscreen', - targetKey: 'shortcut.app.exit_fullscreen' + targetKey: 'shortcut.general.exit_fullscreen' + }, + { + originalKey: 'shortcuts.mini_window', + targetKey: 'shortcut.feature.quick_assistant.toggle_window' + }, + { + originalKey: 'shortcuts.selection_assistant_toggle', + targetKey: 'shortcut.feature.selection.toggle_enabled' + }, + { + originalKey: 'shortcuts.selection_assistant_select_text', + targetKey: 'shortcut.feature.selection.get_text' } ], translate: [ @@ -937,11 +953,11 @@ export const LOCALSTORAGE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetK /** * 映射统计: * - ElectronStore项: 1 - * - Redux Store项: 207 + * - Redux Store项: 211 * - Redux分类: settings, selectionStore, memory, nutstore, preprocess, shortcuts, translate, websearch, ocr, note * - DexieSettings项: 7 * - localStorage项: 0 - * - 总配置项: 215 + * - 总配置项: 219 * * 使用说明: * 1. ElectronStore读取: configManager.get(mapping.originalKey) diff --git a/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts b/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts new file mode 100644 index 00000000000..78d957b5df1 --- /dev/null +++ b/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts @@ -0,0 +1,110 @@ +/** + * Shortcut preference migration + * + * Legacy Redux stores shortcuts as an array of objects: + * { key: 'show_app', shortcut: ['CommandOrControl', 'S'], enabled: true, editable, system } + * + * The new preference schema stores each shortcut under its own key with shape: + * { binding: string[], enabled: boolean } + * + * Because the source is an array (not a keyed object), the simple mapping layer + * cannot read it via `reduxState.get('shortcuts', 'show_app')`. This complex + * mapping reads the entire `shortcuts` category, walks the array, and emits one + * entry per known shortcut. + */ + +import { loggerService } from '@logger' +import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' + +import type { TransformFunction } from './ComplexPreferenceMappings' + +const logger = loggerService.withContext('Migration:ShortcutMappings') + +/** + * Maps the legacy Redux shortcut `key` field to the new preference target key. + */ +const LEGACY_KEY_TO_TARGET_KEY: Record = { + zoom_in: 'shortcut.general.zoom_in', + zoom_out: 'shortcut.general.zoom_out', + zoom_reset: 'shortcut.general.zoom_reset', + show_settings: 'shortcut.general.show_settings', + show_app: 'shortcut.general.show_main_window', + new_topic: 'shortcut.topic.new', + rename_topic: 'shortcut.topic.rename', + toggle_show_topics: 'shortcut.topic.toggle_show_topics', + toggle_show_assistants: 'shortcut.general.toggle_sidebar', + toggle_sidebar: 'shortcut.general.toggle_sidebar', + copy_last_message: 'shortcut.chat.copy_last_message', + edit_last_user_message: 'shortcut.chat.edit_last_user_message', + search_message_in_chat: 'shortcut.chat.search_message', + search_message: 'shortcut.general.search', + clear_topic: 'shortcut.chat.clear', + toggle_new_context: 'shortcut.chat.toggle_new_context', + select_model: 'shortcut.chat.select_model', + exit_fullscreen: 'shortcut.general.exit_fullscreen', + mini_window: 'shortcut.feature.quick_assistant.toggle_window', + selection_assistant_toggle: 'shortcut.feature.selection.toggle_enabled', + selection_assistant_select_text: 'shortcut.feature.selection.get_text' +} + +export const SHORTCUT_TARGET_KEYS: readonly string[] = [...new Set(Object.values(LEGACY_KEY_TO_TARGET_KEY))] + +interface LegacyShortcutEntry { + key?: unknown + shortcut?: unknown + enabled?: unknown +} + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === 'string') + +const LEGACY_KEY_PRIORITY: Record = { + toggle_show_assistants: 0, + toggle_sidebar: 1 +} + +export const transformShortcuts: TransformFunction = (sources) => { + const shortcuts = sources.shortcuts + const result: Record = {} + const priorities = new Map() + + if (!Array.isArray(shortcuts)) { + if (shortcuts !== undefined) { + logger.warn('Legacy shortcuts source is not an array; skipping migration', { + type: typeof shortcuts + }) + } + return result + } + + for (const entry of shortcuts as LegacyShortcutEntry[]) { + if (!entry || typeof entry !== 'object') continue + const legacyKey = typeof entry.key === 'string' ? entry.key : undefined + if (!legacyKey) continue + + const targetKey = LEGACY_KEY_TO_TARGET_KEY[legacyKey] + if (!targetKey) { + logger.debug(`Skipping unknown legacy shortcut key: ${legacyKey}`) + continue + } + + if (entry.shortcut !== undefined && !isStringArray(entry.shortcut)) { + logger.warn(`Skipping malformed legacy shortcut binding for key: ${legacyKey}`) + continue + } + + const currentPriority = LEGACY_KEY_PRIORITY[legacyKey] ?? 0 + const existingPriority = priorities.get(targetKey) ?? -1 + if (currentPriority < existingPriority) { + continue + } + + const binding = isStringArray(entry.shortcut) ? entry.shortcut : [] + const enabled = typeof entry.enabled === 'boolean' ? entry.enabled : true + + result[targetKey] = { binding, enabled } + priorities.set(targetKey, currentPriority) + } + + return result +} diff --git a/src/main/data/migration/v2/migrators/mappings/__tests__/ComplexPreferenceMappings.test.ts b/src/main/data/migration/v2/migrators/mappings/__tests__/ComplexPreferenceMappings.test.ts index 9192e0a0689..f4f49510ab9 100644 --- a/src/main/data/migration/v2/migrators/mappings/__tests__/ComplexPreferenceMappings.test.ts +++ b/src/main/data/migration/v2/migrators/mappings/__tests__/ComplexPreferenceMappings.test.ts @@ -104,7 +104,8 @@ describe('ComplexPreferenceMappings', () => { expect(keys).toContain('chat.web_search.provider_overrides') expect(keys).toContain('feature.code_cli.overrides') expect(keys).toContain('feature.file_processing.overrides') - expect(keys.length).toBe(10) // 7 websearch compression + 1 provider overrides + 1 code_cli overrides + 1 file processing overrides + expect(keys).toContain('shortcut.general.zoom_in') + expect(keys.length).toBe(30) }) it('should flatten target keys from all mappings', () => { diff --git a/src/main/data/migration/v2/migrators/mappings/__tests__/ShortcutMappings.test.ts b/src/main/data/migration/v2/migrators/mappings/__tests__/ShortcutMappings.test.ts new file mode 100644 index 00000000000..6289a6c0891 --- /dev/null +++ b/src/main/data/migration/v2/migrators/mappings/__tests__/ShortcutMappings.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest' + +import { transformShortcuts } from '../ShortcutMappings' + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + debug: vi.fn(), + warn: vi.fn() + }) + } +})) + +describe('transformShortcuts', () => { + it('maps legacy shortcut entries into per-key preferences', () => { + const result = transformShortcuts({ + shortcuts: [ + { + key: 'mini_window', + shortcut: ['CommandOrControl', 'E'], + enabled: false + }, + { + key: 'show_settings', + shortcut: ['CommandOrControl', ','], + enabled: true + }, + { + key: 'selection_assistant_toggle', + shortcut: [], + enabled: false + } + ] + }) + + expect(result).toEqual({ + 'shortcut.feature.quick_assistant.toggle_window': { + binding: ['CommandOrControl', 'E'], + enabled: false + }, + 'shortcut.general.show_settings': { + binding: ['CommandOrControl', ','], + enabled: true + }, + 'shortcut.feature.selection.toggle_enabled': { + binding: [], + enabled: false + } + }) + }) + + it('prefers the renamed toggle_sidebar key over toggle_show_assistants', () => { + const result = transformShortcuts({ + shortcuts: [ + { + key: 'toggle_show_assistants', + shortcut: ['CommandOrControl', '['], + enabled: true + }, + { + key: 'toggle_sidebar', + shortcut: ['CommandOrControl', 'Shift', '['], + enabled: false + } + ] + }) + + expect(result['shortcut.general.toggle_sidebar']).toEqual({ + binding: ['CommandOrControl', 'Shift', '['], + enabled: false + }) + }) + + it('skips malformed bindings instead of silently clearing them', () => { + const result = transformShortcuts({ + shortcuts: [ + { + key: 'show_settings', + shortcut: ['CommandOrControl', ','], + enabled: true + }, + { + key: 'show_settings', + shortcut: ['CommandOrControl', 1], + enabled: false + } + ] + }) + + expect(result['shortcut.general.show_settings']).toEqual({ + binding: ['CommandOrControl', ','], + enabled: true + }) + }) + + it('returns an empty result for non-array legacy sources', () => { + expect(transformShortcuts({ shortcuts: 'nope' })).toEqual({}) + }) +}) diff --git a/src/main/services/AppMenuService.ts b/src/main/services/AppMenuService.ts index d30da18d268..5f45610fc95 100644 --- a/src/main/services/AppMenuService.ts +++ b/src/main/services/AppMenuService.ts @@ -1,10 +1,35 @@ import { application } from '@application' import { BaseService, Conditional, Injectable, onPlatform, Phase, ServicePhase } from '@main/core/lifecycle' import { getAppLanguage, locales } from '@main/utils/language' +import { handleZoomFactor } from '@main/utils/zoom' +import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' +import { findShortcutDefinition } from '@shared/shortcuts/definitions' +import type { ShortcutPreferenceKey } from '@shared/shortcuts/types' +import { resolveShortcutPreference } from '@shared/shortcuts/utils' import type { MenuItemConstructorOptions } from 'electron' import { app, Menu, shell } from 'electron' +const zoomShortcutKeys: ShortcutPreferenceKey[] = [ + 'shortcut.general.zoom_in', + 'shortcut.general.zoom_out', + 'shortcut.general.zoom_reset' +] + +const getShortcutAccelerator = (key: ShortcutPreferenceKey): string | undefined => { + const definition = findShortcutDefinition(key) + if (!definition) return undefined + const rawPref = application.get('PreferenceService').get(key) as PreferenceShortcutType | undefined + const resolved = resolveShortcutPreference(definition, rawPref) + if (!resolved.enabled || !resolved.binding.length) { + return undefined + } + return resolved.binding.join('+') +} + +const getMainWindows = (): Electron.BrowserWindow[] => + [application.get('WindowService').getMainWindow()].filter(Boolean) as Electron.BrowserWindow[] + @Injectable('AppMenuService') @ServicePhase(Phase.WhenReady) @Conditional(onPlatform('darwin')) @@ -12,6 +37,11 @@ export class AppMenuService extends BaseService { protected async onInit() { const preferenceService = application.get('PreferenceService') this.registerDisposable(preferenceService.subscribeChange('app.language', () => this.setupApplicationMenu())) + + for (const key of zoomShortcutKeys) { + this.registerDisposable(preferenceService.subscribeChange(key, () => this.setupApplicationMenu())) + } + this.setupApplicationMenu() } @@ -19,6 +49,10 @@ export class AppMenuService extends BaseService { const locale = locales[getAppLanguage()] const { appMenu } = locale.translation + const zoomInAccelerator = getShortcutAccelerator('shortcut.general.zoom_in') + const zoomOutAccelerator = getShortcutAccelerator('shortcut.general.zoom_out') + const zoomResetAccelerator = getShortcutAccelerator('shortcut.general.zoom_reset') + const template: MenuItemConstructorOptions[] = [ { label: app.name, @@ -67,9 +101,21 @@ export class AppMenuService extends BaseService { { role: 'forceReload', label: appMenu.forceReload }, { role: 'toggleDevTools', label: appMenu.toggleDevTools }, { type: 'separator' }, - { role: 'resetZoom', label: appMenu.resetZoom }, - { role: 'zoomIn', label: appMenu.zoomIn }, - { role: 'zoomOut', label: appMenu.zoomOut }, + { + label: appMenu.resetZoom, + accelerator: zoomResetAccelerator, + click: () => handleZoomFactor(getMainWindows(), 0, true) + }, + { + label: appMenu.zoomIn, + accelerator: zoomInAccelerator, + click: () => handleZoomFactor(getMainWindows(), 0.1) + }, + { + label: appMenu.zoomOut, + accelerator: zoomOutAccelerator, + click: () => handleZoomFactor(getMainWindows(), -0.1) + }, { type: 'separator' }, { role: 'togglefullscreen', label: appMenu.toggleFullscreen } ] diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 83012e23486..69fdd8d46c0 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,348 +1,329 @@ -/** - * @deprecated Scheduled for removal in v2.0.0 - * -------------------------------------------------------------------------- - * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) - * -------------------------------------------------------------------------- - * STOP: Feature PRs affecting this file are currently BLOCKED. - * Only critical bug fixes are accepted during this migration phase. - * - * This file is being refactored to v2 standards. - * Any non-critical changes will conflict with the ongoing work. - * - * 🔗 Context & Status: - * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 - * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 - * -------------------------------------------------------------------------- - */ import { application } from '@application' import { loggerService } from '@logger' import { BaseService, DependsOn, Injectable, Phase, ServicePhase } from '@main/core/lifecycle' import { handleZoomFactor } from '@main/utils/zoom' +import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' -import type { Shortcut } from '@types' +import { SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions' +import type { ShortcutPreferenceKey, SupportedPlatform } from '@shared/shortcuts/types' +import { isShortcutDefinitionEnabled, resolveShortcutPreference } from '@shared/shortcuts/utils' import type { BrowserWindow } from 'electron' import { globalShortcut } from 'electron' -// TODO: Migrate configManager usage to PreferenceService -import { configManager } from './ConfigManager' - const logger = loggerService.withContext('ShortcutService') +type ShortcutHandler = (window?: BrowserWindow) => void +type RegisteredShortcut = { key: ShortcutPreferenceKey; handler: ShortcutHandler; window: BrowserWindow } + +const toAccelerator = (keys: string[]): string => keys.join('+') + +const relevantDefinitions = SHORTCUT_DEFINITIONS.filter( + (d) => + d.scope !== 'renderer' && + (!d.supportedPlatforms || d.supportedPlatforms.includes(process.platform as SupportedPlatform)) +) @Injectable('ShortcutService') @ServicePhase(Phase.WhenReady) -@DependsOn(['WindowService', 'SelectionService', 'PreferenceService']) +@DependsOn(['WindowService', 'SelectionService']) export class ShortcutService extends BaseService { private mainWindow: BrowserWindow | null = null - private showAppAccelerator: string | null = null - private showMiniWindowAccelerator: string | null = null - private selectionAssistantToggleAccelerator: string | null = null - private selectionAssistantSelectTextAccelerator: string | null = null - //indicate if the shortcuts are registered on app boot time + private handlers = new Map() + private registeredWindows = new Set() + private conflictedKeys = new Set() private isRegisterOnBoot = true - // store the focus and blur handlers for each window to unregister them later - private windowOnHandlers = new Map void; onBlurHandler: () => void }>() + private registeredAccelerators = new Map() protected async onInit() { - this.registerIpcHandlers() + this.registerBuiltInHandlers() + this.subscribeToPreferenceChanges() const windowService = application.get('WindowService') - this.registerDisposable(windowService.onMainWindowCreated((window) => this.registerShortcutsForWindow(window))) + this.registerDisposable(windowService.onMainWindowCreated((window) => this.registerForWindow(window))) - // WORKAROUND: WindowService.onReady() creates the window before ShortcutService initializes - // (dependency layer ordering), so the onMainWindowCreated event fires before we subscribe. - // Emitter does not replay past events — check if the window already exists. - // TODO: resolve during ShortcutService refactoring (e.g., move shortcut registration - // into WindowService's window setup pipeline, or use a replay-capable event). const existingWindow = windowService.getMainWindow() if (existingWindow && !existingWindow.isDestroyed()) { - this.registerShortcutsForWindow(existingWindow) + this.registerForWindow(existingWindow) } } protected async onStop() { - this.unregisterAllShortcuts() - this.mainWindow = null + this.unregisterAll() + this.resetRuntimeState() } - private registerIpcHandlers() { - this.ipcHandle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => { - configManager.setShortcuts(shortcuts) - if (this.mainWindow) { - this.unregisterAllShortcuts() - this.registerShortcutsForWindow(this.mainWindow) + private registerBuiltInHandlers(): void { + this.handlers.set('shortcut.general.show_main_window', () => { + application.get('WindowService').toggleMainWindow() + }) + + this.handlers.set('shortcut.general.show_settings', () => { + const windowService = application.get('WindowService') + let targetWindow = windowService.getMainWindow() + + if ( + !targetWindow || + targetWindow.isDestroyed() || + targetWindow.isMinimized() || + !targetWindow.isVisible() || + !targetWindow.isFocused() + ) { + windowService.showMainWindow() + targetWindow = windowService.getMainWindow() + } + + if (!targetWindow || targetWindow.isDestroyed()) return + + const navigateToSettings = () => { + if (!targetWindow || targetWindow.isDestroyed()) return + targetWindow.webContents.send(IpcChannel.Windows_NavigateToSettings) + } + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once('did-finish-load', navigateToSettings) + return } + + navigateToSettings() + }) + + this.handlers.set('shortcut.feature.quick_assistant.toggle_window', () => { + if (!application.get('PreferenceService').get('feature.quick_assistant.enabled')) return + application.get('WindowService').toggleMiniWindow() + }) + + this.handlers.set('shortcut.general.zoom_in', (window) => { + if (window) handleZoomFactor([window], 0.1) + }) + + this.handlers.set('shortcut.general.zoom_out', (window) => { + if (window) handleZoomFactor([window], -0.1) + }) + + this.handlers.set('shortcut.general.zoom_reset', (window) => { + if (window) handleZoomFactor([window], 0, true) + }) + + this.handlers.set('shortcut.feature.selection.toggle_enabled', () => { + application.get('SelectionService').toggleEnabled() + }) + + this.handlers.set('shortcut.feature.selection.get_text', () => { + application.get('SelectionService').processSelectTextByShortcut() }) } - private registerShortcutsForWindow(window: BrowserWindow) { + private subscribeToPreferenceChanges(): void { + const preferenceService = application.get('PreferenceService') + for (const definition of relevantDefinitions) { + this.registerDisposable( + preferenceService.subscribeChange(definition.key, () => { + logger.debug(`Shortcut preference changed: ${definition.key}`) + this.reregisterShortcuts() + }) + ) + } + + const dependencyKeys = new Set>() + for (const definition of relevantDefinitions) { + if (!definition.enabledWhen) { + continue + } + dependencyKeys.add(definition.enabledWhen) + } + + for (const key of dependencyKeys) { + this.registerDisposable( + preferenceService.subscribeChange(key, () => { + logger.debug(`Shortcut dependency changed: ${key}`) + this.reregisterShortcuts() + }) + ) + } + } + + private registerForWindow(window: BrowserWindow): void { this.mainWindow = window if (this.isRegisterOnBoot) { - window.once('ready-to-show', () => { + const onReadyToShow = () => { + if (!this.mainWindow || this.mainWindow.isDestroyed()) return if (application.get('PreferenceService').get('app.tray.on_launch')) { - registerOnlyUniversalShortcuts() + this.registerShortcuts(window, true) } - }) + } + window.once('ready-to-show', onReadyToShow) + this.registerDisposable(() => window.off('ready-to-show', onReadyToShow)) this.isRegisterOnBoot = false } - //only for clearer code - const registerOnlyUniversalShortcuts = () => { - register(true) - } - - //onlyUniversalShortcuts is used to register shortcuts that are not window specific, like show_app & mini_window - //onlyUniversalShortcuts is needed when we launch to tray - const register = (onlyUniversalShortcuts: boolean = false) => { - if (window.isDestroyed()) return + if (!this.registeredWindows.has(window)) { + this.registeredWindows.add(window) - const shortcuts = configManager.getShortcuts() - if (!shortcuts) return + const onFocus = () => { + if (this.mainWindow !== window) return + this.registerShortcuts(window, false) + } + const onBlur = () => { + if (this.mainWindow !== window) return + this.registerShortcuts(window, true) + } + const onClosed = () => { + this.registeredWindows.delete(window) + if (this.mainWindow === window) { + this.mainWindow = null + } + } - shortcuts.forEach((shortcut) => { - try { - if (shortcut.shortcut.length === 0) { - return - } + window.on('focus', onFocus) + window.on('blur', onBlur) + window.once('closed', onClosed) + this.registerDisposable(() => window.off('focus', onFocus)) + this.registerDisposable(() => window.off('blur', onBlur)) + this.registerDisposable(() => window.off('closed', onClosed)) + } - if (!shortcut.enabled) { - return - } + if (!window.isDestroyed() && window.isFocused()) { + this.registerShortcuts(window, false) + } + } - // only register universal shortcuts when needed - if ( - onlyUniversalShortcuts && - !['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes( - shortcut.key - ) - ) { - return - } + private registerShortcuts(window: BrowserWindow, onlyPersistent: boolean): void { + if (window.isDestroyed()) return - const handler = this.getShortcutHandler(shortcut) - if (!handler) { - return - } + const preferenceService = application.get('PreferenceService') - switch (shortcut.key) { - case 'show_app': - this.showAppAccelerator = this.formatShortcutKey(shortcut.shortcut) - break - - case 'mini_window': - // 移除注册时的条件检查,在处理器内部进行检查 - logger.info(`Processing mini_window shortcut, enabled: ${shortcut.enabled}`) - this.showMiniWindowAccelerator = this.formatShortcutKey(shortcut.shortcut) - logger.debug(`Mini window accelerator set to: ${this.showMiniWindowAccelerator}`) - break - - case 'selection_assistant_toggle': - this.selectionAssistantToggleAccelerator = this.formatShortcutKey(shortcut.shortcut) - break - - case 'selection_assistant_select_text': - this.selectionAssistantSelectTextAccelerator = this.formatShortcutKey(shortcut.shortcut) - break - - //the following ZOOMs will register shortcuts separately, so will return - case 'zoom_in': - globalShortcut.register('CommandOrControl+=', () => handler(window)) - globalShortcut.register('CommandOrControl+numadd', () => handler(window)) - return - - case 'zoom_out': - globalShortcut.register('CommandOrControl+-', () => handler(window)) - globalShortcut.register('CommandOrControl+numsub', () => handler(window)) - return - - case 'zoom_reset': - globalShortcut.register('CommandOrControl+0', () => handler(window)) - return - } + // Build the desired set of accelerators + const desired = new Map() - const accelerator = this.convertShortcutFormat(shortcut.shortcut) + for (const definition of relevantDefinitions) { + if (onlyPersistent && !definition.global) continue - globalShortcut.register(accelerator, () => handler(window)) - } catch (error) { - logger.warn(`Failed to register shortcut ${shortcut.key}`) - } - }) - } + if (!isShortcutDefinitionEnabled(definition, (key) => preferenceService.get(key))) { + continue + } - const unregister = () => { - if (window.isDestroyed()) return + const rawPref = preferenceService.get(definition.key) as PreferenceShortcutType | undefined + const pref = resolveShortcutPreference(definition, rawPref) + if (!pref.enabled || !pref.binding.length) continue - try { - globalShortcut.unregisterAll() + const handler = this.handlers.get(definition.key) + if (!handler) continue - if (this.showAppAccelerator) { - const handler = this.getShortcutHandler({ key: 'show_app' } as Shortcut) - const accelerator = this.convertShortcutFormat(this.showAppAccelerator) - handler && globalShortcut.register(accelerator, () => handler(window)) - } + const accelerator = toAccelerator(pref.binding) + if (accelerator) { + desired.set(accelerator, { key: definition.key, handler, window }) + } - if (this.showMiniWindowAccelerator) { - const handler = this.getShortcutHandler({ key: 'mini_window' } as Shortcut) - const accelerator = this.convertShortcutFormat(this.showMiniWindowAccelerator) - handler && globalShortcut.register(accelerator, () => handler(window)) + if (definition.variants) { + for (const variant of definition.variants) { + const variantAccelerator = toAccelerator(variant) + if (variantAccelerator) { + desired.set(variantAccelerator, { key: definition.key, handler, window }) + } } + } + } - if (this.selectionAssistantToggleAccelerator) { - const handler = this.getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut) - const accelerator = this.convertShortcutFormat(this.selectionAssistantToggleAccelerator) - handler && globalShortcut.register(accelerator, () => handler(window)) - } + const activeKeys = new Set(Array.from(desired.values(), (entry) => entry.key)) + for (const key of this.conflictedKeys) { + if (!activeKeys.has(key)) { + this.clearRegistrationConflict(key) + } + } - if (this.selectionAssistantSelectTextAccelerator) { - const handler = this.getShortcutHandler({ key: 'selection_assistant_select_text' } as Shortcut) - const accelerator = this.convertShortcutFormat(this.selectionAssistantSelectTextAccelerator) - handler && globalShortcut.register(accelerator, () => handler(window)) + // Unregister shortcuts that are no longer needed or have a different handler + for (const [accelerator, previous] of this.registeredAccelerators) { + const entry = desired.get(accelerator) + if (!entry || entry.handler !== previous.handler || entry.window !== previous.window) { + try { + globalShortcut.unregister(accelerator) + } catch (error) { + logger.debug(`Failed to unregister shortcut accelerator: ${accelerator}`, error as Error) } - } catch (error) { - logger.warn('Failed to unregister shortcuts') + this.registeredAccelerators.delete(accelerator) } } - // only register the event handlers once - if (undefined === this.windowOnHandlers.get(window)) { - // pass register() directly to listener, the func will receive Event as argument, it's not expected - const registerHandler = () => { - register() + // Register new or changed shortcuts + for (const [accelerator, { key, handler, window: win }] of desired) { + if (!this.registeredAccelerators.has(accelerator)) { + try { + const success = globalShortcut.register(accelerator, () => { + const targetWindow = win?.isDestroyed?.() ? undefined : win + try { + handler(targetWindow) + } catch (error) { + logger.error(`Shortcut handler threw for accelerator: ${accelerator}`, error as Error) + } + }) + if (success) { + this.registeredAccelerators.set(accelerator, { key, handler, window: win }) + this.clearRegistrationConflict(key) + } else { + logger.warn(`Failed to register shortcut ${accelerator}: accelerator is held by another application`) + this.markRegistrationConflict(key, accelerator) + } + } catch (error) { + logger.error(`Failed to register shortcut ${accelerator}`, error as Error) + this.markRegistrationConflict(key, accelerator) + } } - window.on('focus', registerHandler) - window.on('blur', unregister) - this.windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister }) } + } - if (!window.isDestroyed() && window.isFocused()) { - register() + private reregisterShortcuts(): void { + if (!this.mainWindow || this.mainWindow.isDestroyed()) return + + if (this.mainWindow.isFocused()) { + this.registerShortcuts(this.mainWindow, false) + } else { + this.registerShortcuts(this.mainWindow, true) } } - private unregisterAllShortcuts() { - try { - this.showAppAccelerator = null - this.showMiniWindowAccelerator = null - this.selectionAssistantToggleAccelerator = null - this.selectionAssistantSelectTextAccelerator = null - this.windowOnHandlers.forEach((handlers, window) => { - window.off('focus', handlers.onFocusHandler) - window.off('blur', handlers.onBlurHandler) - }) - this.windowOnHandlers.clear() - globalShortcut.unregisterAll() - } catch (error) { - logger.warn('Failed to unregister all shortcuts') + private unregisterAll(): void { + for (const accelerator of this.registeredAccelerators.keys()) { + try { + globalShortcut.unregister(accelerator) + } catch (error) { + logger.debug(`Failed to unregister shortcut accelerator: ${accelerator}`, error as Error) + } } + this.registeredAccelerators.clear() } - private getShortcutHandler(shortcut: Shortcut) { - switch (shortcut.key) { - case 'zoom_in': - return (window: BrowserWindow) => handleZoomFactor([window], 0.1) - case 'zoom_out': - return (window: BrowserWindow) => handleZoomFactor([window], -0.1) - case 'zoom_reset': - return (window: BrowserWindow) => handleZoomFactor([window], 0, true) - case 'show_app': - return () => { - application.get('WindowService').toggleMainWindow() - } - case 'mini_window': - return () => { - // 在处理器内部检查QuickAssistant状态,而不是在注册时检查 - const quickAssistantEnabled = application.get('PreferenceService').get('feature.quick_assistant.enabled') - logger.info(`mini_window shortcut triggered, QuickAssistant enabled: ${quickAssistantEnabled}`) - - if (!quickAssistantEnabled) { - logger.warn('QuickAssistant is disabled, ignoring mini_window shortcut trigger') - return - } + private resetRuntimeState(): void { + this.mainWindow = null + this.registeredWindows.clear() + this.conflictedKeys.clear() + this.isRegisterOnBoot = true + } - application.get('WindowService').toggleMiniWindow() - } - case 'selection_assistant_toggle': - return () => { - application.get('SelectionService').toggleEnabled() - } - case 'selection_assistant_select_text': - return () => { - application.get('SelectionService').processSelectTextByShortcut() - } - default: - return null + private markRegistrationConflict(key: ShortcutPreferenceKey, accelerator: string): void { + if (this.conflictedKeys.has(key)) { + return } + + this.conflictedKeys.add(key) + this.emitRegistrationConflict({ key, accelerator, hasConflict: true }) } - private formatShortcutKey(shortcut: string[]): string { - return shortcut.join('+') + private clearRegistrationConflict(key: ShortcutPreferenceKey): void { + if (!this.conflictedKeys.delete(key)) { + return + } + this.emitRegistrationConflict({ key, hasConflict: false }) } - // convert the shortcut recorded by JS keyboard event key value to electron global shortcut format - // see: https://www.electronjs.org/zh/docs/latest/api/accelerator - private convertShortcutFormat(shortcut: string | string[]): string { - const accelerator = (() => { - if (Array.isArray(shortcut)) { - return shortcut - } else { - return shortcut.split('+').map((key) => key.trim()) - } - })() - - return accelerator - .map((key) => { - switch (key) { - // NEW WAY FOR MODIFIER KEYS - // you can see all the modifier keys in the same - case 'CommandOrControl': - return 'CommandOrControl' - case 'Ctrl': - return 'Ctrl' - case 'Alt': - return 'Alt' // Use `Alt` instead of `Option`. The `Option` key only exists on macOS, whereas the `Alt` key is available on all platforms. - case 'Meta': - return 'Meta' // `Meta` key is mapped to the Windows key on Windows and Linux, `Cmd` on macOS. - case 'Shift': - return 'Shift' - - // For backward compatibility with old data - case 'Command': - case 'Cmd': - return 'CommandOrControl' - case 'Control': - return 'Ctrl' - case 'ArrowUp': - return 'Up' - case 'ArrowDown': - return 'Down' - case 'ArrowLeft': - return 'Left' - case 'ArrowRight': - return 'Right' - case 'AltGraph': - return 'AltGr' - case 'Slash': - return '/' - case 'Semicolon': - return ';' - case 'BracketLeft': - return '[' - case 'BracketRight': - return ']' - case 'Backslash': - return '\\' - case 'Quote': - return "'" - case 'Comma': - return ',' - case 'Minus': - return '-' - case 'Equal': - return '=' - default: - return key - } - }) - .join('+') + private emitRegistrationConflict(payload: { + key: ShortcutPreferenceKey + accelerator?: string + hasConflict: boolean + }): void { + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return + } + + this.mainWindow.webContents.send(IpcChannel.Shortcut_RegistrationConflict, payload) } } diff --git a/src/main/services/__tests__/ShortcutService.test.ts b/src/main/services/__tests__/ShortcutService.test.ts new file mode 100644 index 00000000000..52cd80ca681 --- /dev/null +++ b/src/main/services/__tests__/ShortcutService.test.ts @@ -0,0 +1,273 @@ +import { EventEmitter } from 'events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn() + }) + } +})) + +vi.mock('@data/PreferenceService', async () => { + const { MockMainPreferenceServiceExport } = await import('@test-mocks/main/PreferenceService') + return MockMainPreferenceServiceExport +}) + +const { windowServiceMock, selectionServiceMock, globalShortcutMock } = vi.hoisted(() => ({ + windowServiceMock: { + getMainWindow: vi.fn(), + onMainWindowCreated: vi.fn(), + showMainWindow: vi.fn(), + toggleMainWindow: vi.fn(), + toggleMiniWindow: vi.fn() + }, + selectionServiceMock: { + toggleEnabled: vi.fn(), + processSelectTextByShortcut: vi.fn() + }, + globalShortcutMock: { + register: vi.fn(), + unregister: vi.fn() + } +})) + +vi.mock('@application', async () => { + const { mockApplicationFactory } = await import('@test-mocks/main/application') + return mockApplicationFactory({ + WindowService: windowServiceMock, + SelectionService: selectionServiceMock + } as any) +}) + +vi.mock('@main/core/lifecycle', () => { + class MockBaseService { + protected readonly _disposables: Array<{ dispose: () => void } | (() => void)> = [] + + protected registerDisposable void } | (() => void)>(disposable: T): T { + this._disposables.push(disposable) + return disposable + } + } + + return { + BaseService: MockBaseService, + Injectable: () => (target: unknown) => target, + ServicePhase: () => (target: unknown) => target, + DependsOn: () => (target: unknown) => target, + Phase: { WhenReady: 'whenReady' } + } +}) + +vi.mock('@main/utils/zoom', () => ({ + handleZoomFactor: vi.fn() +})) + +vi.mock('electron', () => ({ + globalShortcut: globalShortcutMock +})) + +import { handleZoomFactor } from '@main/utils/zoom' +import { IpcChannel } from '@shared/IpcChannel' +import { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceService' + +import { ShortcutService } from '../ShortcutService' + +const supportsSelectionShortcuts = ['darwin', 'win32'].includes(process.platform) + +class MockBrowserWindow { + private readonly events = new EventEmitter() + private readonly webContentsEvents = new EventEmitter() + private destroyed = false + private focused = true + + public readonly webContents = { + send: vi.fn(), + isLoadingMainFrame: vi.fn(() => false), + once: vi.fn((event: string, callback: (...args: any[]) => void) => { + this.webContentsEvents.once(event, callback) + }) + } + + public readonly on = vi.fn((event: string, callback: (...args: any[]) => void) => { + this.events.on(event, callback) + return this + }) + + public readonly once = vi.fn((event: string, callback: (...args: any[]) => void) => { + this.events.once(event, callback) + return this + }) + + public readonly off = vi.fn((event: string, callback: (...args: any[]) => void) => { + this.events.off(event, callback) + return this + }) + + public readonly isDestroyed = vi.fn(() => this.destroyed) + public readonly isFocused = vi.fn(() => this.focused) + public readonly isMinimized = vi.fn(() => false) + public readonly isVisible = vi.fn(() => true) + + public emit(event: string, ...args: any[]) { + this.events.emit(event, ...args) + } + + public emitWebContents(event: string, ...args: any[]) { + this.webContentsEvents.emit(event, ...args) + } + + public setFocused(value: boolean) { + this.focused = value + } + + public destroy() { + this.destroyed = true + } +} + +describe('ShortcutService', () => { + let service: ShortcutService + let mainWindow: MockBrowserWindow + + beforeEach(() => { + vi.clearAllMocks() + MockMainPreferenceServiceUtils.resetMocks() + + mainWindow = new MockBrowserWindow() + windowServiceMock.getMainWindow.mockReturnValue(mainWindow) + windowServiceMock.onMainWindowCreated.mockImplementation((callback: (window: MockBrowserWindow) => void) => { + return { dispose: vi.fn(), callback } + }) + + globalShortcutMock.register.mockReturnValue(true) + + service = new ShortcutService() + }) + + it('registers focused window shortcuts including shortcut variants', async () => { + await (service as any).onInit() + + expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+,', expect.any(Function)) + expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+=', expect.any(Function)) + expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+numadd', expect.any(Function)) + }) + + it('re-registers only the changed accelerator when shortcut binding changes', async () => { + await (service as any).onInit() + globalShortcutMock.register.mockClear() + globalShortcutMock.unregister.mockClear() + + MockMainPreferenceServiceUtils.setPreferenceValue('shortcut.general.show_settings', { + binding: ['Alt', ','], + enabled: true + }) + + expect(globalShortcutMock.unregister).toHaveBeenCalledWith('CommandOrControl+,') + expect(globalShortcutMock.register).toHaveBeenCalledWith('Alt+,', expect.any(Function)) + expect(globalShortcutMock.register).not.toHaveBeenCalledWith('CommandOrControl+=', expect.any(Function)) + }) + + it('reacts to quick assistant enablement changes for mini window shortcut', async () => { + MockMainPreferenceServiceUtils.setPreferenceValue('shortcut.feature.quick_assistant.toggle_window', { + binding: ['CommandOrControl', 'E'], + enabled: true + }) + MockMainPreferenceServiceUtils.setPreferenceValue('feature.quick_assistant.enabled', false) + + await (service as any).onInit() + + expect(globalShortcutMock.register).not.toHaveBeenCalledWith('CommandOrControl+E', expect.any(Function)) + + globalShortcutMock.register.mockClear() + MockMainPreferenceServiceUtils.setPreferenceValue('feature.quick_assistant.enabled', true) + + expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+E', expect.any(Function)) + }) + + it('reacts to selection assistant enablement changes for selection shortcuts', async () => { + MockMainPreferenceServiceUtils.setPreferenceValue('shortcut.feature.selection.toggle_enabled', { + binding: ['CommandOrControl', 'Shift', 'S'], + enabled: true + }) + MockMainPreferenceServiceUtils.setPreferenceValue('feature.selection.enabled', false) + + await (service as any).onInit() + + expect(globalShortcutMock.register).not.toHaveBeenCalledWith('CommandOrControl+Shift+S', expect.any(Function)) + + globalShortcutMock.register.mockClear() + MockMainPreferenceServiceUtils.setPreferenceValue('feature.selection.enabled', true) + + if (supportsSelectionShortcuts) { + expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+Shift+S', expect.any(Function)) + } else { + expect(globalShortcutMock.register).not.toHaveBeenCalledWith('CommandOrControl+Shift+S', expect.any(Function)) + } + }) + + it('re-registers window-bound shortcuts when the main window instance changes', async () => { + await (service as any).onInit() + + const nextWindow = new MockBrowserWindow() + globalShortcutMock.register.mockClear() + globalShortcutMock.unregister.mockClear() + + ;(service as any).registerForWindow(nextWindow) + + expect(globalShortcutMock.unregister).toHaveBeenCalledWith('CommandOrControl+=') + + const zoomInRegistration = globalShortcutMock.register.mock.calls.find( + ([accelerator]) => accelerator === 'CommandOrControl+=' + ) + expect(zoomInRegistration).toBeTruthy() + + const zoomInHandler = zoomInRegistration?.[1] as (() => void) | undefined + zoomInHandler?.() + + expect(handleZoomFactor).toHaveBeenCalledWith([nextWindow], 0.1) + }) + + it('resets boot registration state when the service stops and starts again', async () => { + await (service as any).onInit() + await (service as any).onStop() + + const nextWindow = new MockBrowserWindow() + windowServiceMock.getMainWindow.mockReturnValue(nextWindow) + + await (service as any).onInit() + + expect(nextWindow.once).toHaveBeenCalledWith('ready-to-show', expect.any(Function)) + }) + + it('notifies the renderer when a shortcut cannot be registered', async () => { + globalShortcutMock.register.mockImplementation((accelerator: string) => accelerator !== 'CommandOrControl+,') + + await (service as any).onInit() + + expect(mainWindow.webContents.send).toHaveBeenCalledWith(IpcChannel.Shortcut_RegistrationConflict, { + key: 'shortcut.general.show_settings', + accelerator: 'CommandOrControl+,', + hasConflict: true + }) + }) + + it('does not notify repeatedly for the same shortcut conflict', async () => { + globalShortcutMock.register.mockImplementation((accelerator: string) => accelerator !== 'CommandOrControl+,') + + await (service as any).onInit() + mainWindow.webContents.send.mockClear() + + ;(service as any).reregisterShortcuts() + + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + IpcChannel.Shortcut_RegistrationConflict, + expect.objectContaining({ + key: 'shortcut.general.show_settings', + hasConflict: true + }) + ) + }) +}) diff --git a/src/preload/index.ts b/src/preload/index.ts index 6b5504eee44..8485eac4f5f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,6 +28,7 @@ import type { import type { KnowledgeSearchResult as KnowledgeVectorSearchResult } from '@shared/data/types/knowledge' import type { ExternalAppInfo } from '@shared/externalApp/types' import { IpcChannel } from '@shared/IpcChannel' +import type { ShortcutPreferenceKey } from '@shared/shortcuts/types' import type { AddMemoryOptions, AssistantMessage, @@ -49,7 +50,6 @@ import type { Provider, RestartApiServerStatusResult, S3Config, - Shortcut, StartApiServerStatusResult, StopApiServerStatusResult, SupportedOcrFile, @@ -95,6 +95,12 @@ type DirectoryListOptions = { searchPattern?: string } +type ShortcutRegistrationConflictPayload = { + key: ShortcutPreferenceKey + accelerator?: string + hasConflict: boolean +} + export function tracedInvoke(channel: string, spanContext: SpanContext | undefined, ...args: any[]) { if (spanContext) { const data = { type: 'trace', context: spanContext } @@ -300,9 +306,6 @@ const api = { getFiles: (vaultName: string) => ipcRenderer.invoke(IpcChannel.Obsidian_GetFiles, vaultName) }, openPath: (path: string) => ipcRenderer.invoke(IpcChannel.Open_Path, path), - shortcuts: { - update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts) - }, knowledgeBase: { create: (base: KnowledgeBaseParams, context?: SpanContext) => tracedInvoke(IpcChannel.KnowledgeBase_Create, context, base), @@ -823,6 +826,16 @@ const api = { } } }, + shortcut: { + onRegistrationConflict: (callback: (payload: ShortcutRegistrationConflictPayload) => void): (() => void) => { + const channel = IpcChannel.Shortcut_RegistrationConflict + const listener = (_: Electron.IpcRendererEvent, payload: ShortcutRegistrationConflictPayload) => callback(payload) + ipcRenderer.on(channel, listener) + return () => { + ipcRenderer.removeListener(channel, listener) + } + } + }, // CacheService related APIs cache: { // Broadcast sync message to other windows diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx index 7d60ad17a40..69399a39d08 100644 --- a/src/renderer/src/components/TopView/index.tsx +++ b/src/renderer/src/components/TopView/index.tsx @@ -1,8 +1,8 @@ // import { loggerService } from '@logger' import { Box } from '@cherrystudio/ui' +import { usePreference } from '@data/hooks/usePreference' import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer' import { useAppInit } from '@renderer/hooks/useAppInit' -import { useShortcuts } from '@renderer/hooks/useShortcuts' import { message, Modal } from 'antd' import type { PropsWithChildren } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react' @@ -37,8 +37,8 @@ const TopViewContainer: React.FC = ({ children }) => { const [modal, modalContextHolder] = Modal.useModal() const [messageApi, messageContextHolder] = message.useMessage() - const { shortcuts } = useShortcuts() - const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled + const [exitFullscreenPref] = usePreference('shortcut.general.exit_fullscreen') + const enableQuitFullScreen = exitFullscreenPref?.enabled !== false useAppInit() diff --git a/src/renderer/src/handler/NavigationHandler.tsx b/src/renderer/src/handler/NavigationHandler.tsx index 9f7cb2bace6..e5199a5e6ef 100644 --- a/src/renderer/src/handler/NavigationHandler.tsx +++ b/src/renderer/src/handler/NavigationHandler.tsx @@ -1,42 +1,31 @@ -import { useAppSelector } from '@renderer/store' import { IpcChannel } from '@shared/IpcChannel' -import { useLocation, useNavigate } from '@tanstack/react-router' +import { useNavigate } from '@tanstack/react-router' import { useEffect } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' const NavigationHandler: React.FC = () => { - const location = useLocation() const navigate = useNavigate() - const showSettingsShortcutEnabled = useAppSelector( - (state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled - ) - useHotkeys( - 'meta+, ! ctrl+,', - function () { - if (location.pathname.startsWith('/settings')) { - return - } - void navigate({ to: '/settings/provider' }) - }, - { - splitKey: '!', - enableOnContentEditable: true, - enableOnFormTags: true, - enabled: showSettingsShortcutEnabled - } - ) - - // Listen for navigate to About page event from macOS menu useEffect(() => { const handleNavigateToAbout = () => { void navigate({ to: '/settings/about' }) } - const removeListener = window.electron.ipcRenderer.on(IpcChannel.Windows_NavigateToAbout, handleNavigateToAbout) + const handleNavigateToSettings = () => { + void navigate({ to: '/settings/provider' }) + } + + const removeAboutListener = window.electron.ipcRenderer.on( + IpcChannel.Windows_NavigateToAbout, + handleNavigateToAbout + ) + const removeSettingsListener = window.electron.ipcRenderer.on( + IpcChannel.Windows_NavigateToSettings, + handleNavigateToSettings + ) return () => { - removeListener() + removeAboutListener() + removeSettingsListener() } }, [navigate]) diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index ea1c0cab67e..a95e6442cd5 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -1,23 +1,23 @@ -/** - * @deprecated Scheduled for removal in v2.0.0 - * -------------------------------------------------------------------------- - * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) - * -------------------------------------------------------------------------- - * STOP: Feature PRs affecting this file are currently BLOCKED. - * Only critical bug fixes are accepted during this migration phase. - * - * This file is being refactored to v2 standards. - * Any non-critical changes will conflict with the ongoing work. - * - * 🔗 Context & Status: - * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 - * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 - * -------------------------------------------------------------------------- - */ -import { isMac, isWin } from '@renderer/config/constant' -import { useAppSelector } from '@renderer/store' -import { orderBy } from 'lodash' -import { useCallback } from 'react' +import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference' +import { isMac, platform } from '@renderer/config/constant' +import { getShortcutLabel } from '@renderer/i18n/label' +import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' +import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions' +import type { + ResolvedShortcut, + ShortcutDependencyPreferenceKey, + ShortcutKey, + ShortcutPreferenceKey, + SupportedPlatform +} from '@shared/shortcuts/types' +import { + convertAcceleratorToHotkey, + formatShortcutDisplay, + getDefaultShortcut, + isShortcutDefinitionEnabled, + resolveShortcutPreference +} from '@shared/shortcuts/utils' +import { useCallback, useMemo, useRef } from 'react' import { useHotkeys } from 'react-hotkeys-hook' interface UseShortcutOptions { @@ -25,85 +25,204 @@ interface UseShortcutOptions { enableOnFormTags?: boolean enabled?: boolean description?: string + enableOnContentEditable?: boolean } const defaultOptions: UseShortcutOptions = { preventDefault: true, enableOnFormTags: true, - enabled: true + enabled: true, + enableOnContentEditable: false +} + +const isFullKey = (key: string): key is ShortcutPreferenceKey => key.startsWith('shortcut.') + +const toFullKey = (key: ShortcutKey | ShortcutPreferenceKey): ShortcutPreferenceKey => + isFullKey(key) ? key : (`shortcut.${key}` as ShortcutPreferenceKey) + +const shortcutPreferenceKeyMap = SHORTCUT_DEFINITIONS.reduce>( + (acc, definition) => { + acc[definition.key] = definition.key + return acc + }, + {} +) + +const shortcutDependencyPreferenceKeyMap = SHORTCUT_DEFINITIONS.reduce>( + (acc, definition) => { + if (definition.enabledWhen) { + acc[definition.enabledWhen] = definition.enabledWhen + } + return acc + }, + {} +) + +const buildNextPreference = ( + state: ResolvedShortcut, + currentValue: PreferenceShortcutType | undefined, + patch: Partial +): PreferenceShortcutType => { + const current: Partial = currentValue ?? {} + + return { + binding: Array.isArray(patch.binding) + ? patch.binding + : Array.isArray(current.binding) + ? current.binding + : state.binding, + enabled: + typeof patch.enabled === 'boolean' + ? patch.enabled + : typeof current.enabled === 'boolean' + ? current.enabled + : state.enabled + } } export const useShortcut = ( - shortcutKey: string, - callback: (e: KeyboardEvent) => void, + shortcutKey: ShortcutKey | ShortcutPreferenceKey, + callback: (event: KeyboardEvent) => void, options: UseShortcutOptions = defaultOptions ) => { - const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) - - const formatShortcut = useCallback((shortcut: string[]) => { - return shortcut - .map((key) => { - switch (key.toLowerCase()) { - case 'command': - return 'meta' - case 'commandorcontrol': - return isMac ? 'meta' : 'ctrl' - default: - return key.toLowerCase() - } - }) - .join('+') - }, []) + const fullKey = toFullKey(shortcutKey) + const definition = findShortcutDefinition(fullKey) + const [preference] = usePreference(fullKey) + const resolved = definition ? resolveShortcutPreference(definition, preference) : null + + const callbackRef = useRef(callback) + callbackRef.current = callback + + const optionsRef = useRef(options) + optionsRef.current = options + const isExternallyEnabled = options.enabled !== false + + const hotkey = useMemo(() => { + if (!definition || !resolved) { + return 'none' + } + + if (!isExternallyEnabled) { + return 'none' + } + + if (definition.scope === 'main') { + return 'none' + } + + if (!resolved.enabled) { + return 'none' + } + + if (!resolved.binding.length) { + return 'none' + } - const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey) + return convertAcceleratorToHotkey(resolved.binding) + }, [definition, isExternallyEnabled, resolved]) useHotkeys( - shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none', - (e) => { - if (options.preventDefault) { - e.preventDefault() + hotkey, + (event) => { + if (optionsRef.current.preventDefault) { + event.preventDefault() } - if (options.enabled !== false) { - callback(e) + if (optionsRef.current.enabled !== false) { + callbackRef.current(event) } }, { - enableOnFormTags: options.enableOnFormTags, - description: options.description || shortcutConfig?.key, - enabled: !!shortcutConfig?.enabled - } + enableOnFormTags: optionsRef.current.enableOnFormTags, + description: optionsRef.current.description ?? fullKey, + enabled: isExternallyEnabled && hotkey !== 'none', + enableOnContentEditable: optionsRef.current.enableOnContentEditable + }, + [hotkey, isExternallyEnabled] ) } -export function useShortcuts() { - const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) - return { shortcuts: orderBy(shortcuts, 'system', 'desc') } +export const useShortcutDisplay = (shortcutKey: ShortcutKey | ShortcutPreferenceKey): string => { + const fullKey = toFullKey(shortcutKey) + const definition = findShortcutDefinition(fullKey) + const [preference] = usePreference(fullKey) + const resolved = definition ? resolveShortcutPreference(definition, preference) : null + + return useMemo(() => { + if (!definition || !resolved || !resolved.enabled || !resolved.binding.length) { + return '' + } + + return formatShortcutDisplay(resolved.binding, isMac) + }, [definition, resolved]) } -export function useShortcutDisplay(key: string) { - const formatShortcut = useCallback((shortcut: string[]) => { - return shortcut - .map((key) => { - switch (key.toLowerCase()) { - case 'control': - return isMac ? '⌃' : 'Ctrl' - case 'ctrl': - return isMac ? '⌃' : 'Ctrl' - case 'command': - return isMac ? '⌘' : isWin ? 'Win' : 'Super' - case 'alt': - return isMac ? '⌥' : 'Alt' - case 'shift': - return isMac ? '⇧' : 'Shift' - case 'commandorcontrol': - return isMac ? '⌘' : 'Ctrl' - default: - return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase() +export interface ShortcutListItem { + key: ShortcutPreferenceKey + label: string + definition: (typeof SHORTCUT_DEFINITIONS)[number] + preference: ResolvedShortcut + defaultPreference: ResolvedShortcut +} + +export const getAllShortcutDefaultPreferences = (): Record => { + return SHORTCUT_DEFINITIONS.reduce( + (acc, definition) => { + const defaultPreference = getDefaultShortcut(definition) + acc[definition.key] = { + binding: defaultPreference.binding, + enabled: defaultPreference.enabled + } + return acc + }, + {} as Record + ) +} + +export const useAllShortcuts = () => { + const [values, setValues] = useMultiplePreferences(shortcutPreferenceKeyMap) + const [dependencyValues] = useMultiplePreferences(shortcutDependencyPreferenceKeyMap) + + const updatePreference = useCallback( + async (key: ShortcutPreferenceKey, patch: Partial) => { + const definition = findShortcutDefinition(key) + if (!definition) return + const currentValue = values[definition.key] as PreferenceShortcutType | undefined + const state = resolveShortcutPreference(definition, currentValue) + const nextValue = buildNextPreference(state, currentValue, patch) + await setValues({ [definition.key]: nextValue } as Partial>) + }, + [setValues, values] + ) + + const shortcuts = useMemo( + () => + SHORTCUT_DEFINITIONS.flatMap((definition) => { + const supported = definition.supportedPlatforms + if (supported && platform && !supported.includes(platform as SupportedPlatform)) { + return [] } - }) - .join('+') - }, []) - const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) - const shortcutConfig = shortcuts.find((s) => s.key === key) - return shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : '' + if (!isShortcutDefinitionEnabled(definition, (key) => dependencyValues[key])) { + return [] + } + + const rawValue = values[definition.key] as PreferenceShortcutType | undefined + const preference = resolveShortcutPreference(definition, rawValue) + + return [ + { + key: definition.key, + label: getShortcutLabel(definition.labelKey), + definition, + preference: { + binding: preference.binding, + enabled: preference.enabled && preference.binding.length > 0 + }, + defaultPreference: getDefaultShortcut(definition) + } + ] + }), + [dependencyValues, values] + ) + + return { shortcuts, updatePreference } } diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index d8ca93dfb65..ad7eb386b58 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -7,6 +7,7 @@ import { loggerService } from '@logger' import type { AgentType, BuiltinMCPServerName, BuiltinOcrProviderId } from '@renderer/types' import { BuiltinMCPServerNames } from '@renderer/types' +import { SHORTCUT_DEFINITIONS, type ShortcutLabelKey } from '@shared/shortcuts/definitions' import i18n from './index' @@ -202,41 +203,12 @@ export const getSidebarIconLabel = (key: string): string => { return getLabel(sidebarIconKeyMap, key) } -const shortcutKeyMap = { - action: 'settings.shortcuts.action', - actions: 'settings.shortcuts.actions', - clear_shortcut: 'settings.shortcuts.clear_shortcut', - clear_topic: 'settings.shortcuts.clear_topic', - rename_topic: 'settings.shortcuts.rename_topic', - copy_last_message: 'settings.shortcuts.copy_last_message', - edit_last_user_message: 'settings.shortcuts.edit_last_user_message', - enabled: 'settings.shortcuts.enabled', - exit_fullscreen: 'settings.shortcuts.exit_fullscreen', - label: 'settings.shortcuts.label', - mini_window: 'settings.shortcuts.mini_window', - new_topic: 'settings.shortcuts.new_topic', - press_shortcut: 'settings.shortcuts.press_shortcut', - reset_defaults: 'settings.shortcuts.reset_defaults', - reset_defaults_confirm: 'settings.shortcuts.reset_defaults_confirm', - reset_to_default: 'settings.shortcuts.reset_to_default', - search_message: 'settings.shortcuts.search_message', - search_message_in_chat: 'settings.shortcuts.search_message_in_chat', - select_model: 'settings.shortcuts.select_model', - selection_assistant_select_text: 'settings.shortcuts.selection_assistant_select_text', - selection_assistant_toggle: 'settings.shortcuts.selection_assistant_toggle', - show_app: 'settings.shortcuts.show_app', - show_settings: 'settings.shortcuts.show_settings', - title: 'settings.shortcuts.title', - toggle_new_context: 'settings.shortcuts.toggle_new_context', - toggle_show_assistants: 'settings.shortcuts.toggle_show_assistants', - toggle_show_topics: 'settings.shortcuts.toggle_show_topics', - zoom_in: 'settings.shortcuts.zoom_in', - zoom_out: 'settings.shortcuts.zoom_out', - zoom_reset: 'settings.shortcuts.zoom_reset' -} as const +const shortcutLabelKeyMap = Object.fromEntries( + SHORTCUT_DEFINITIONS.map((definition) => [definition.labelKey, `settings.shortcuts.${definition.labelKey}`]) +) as Record -export const getShortcutLabel = (key: string): string => { - return getLabel(shortcutKeyMap, key) +export const getShortcutLabel = (key: ShortcutLabelKey): string => { + return getLabel(shortcutLabelKeyMap, key) } const selectionDescriptionKeyMap = { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 88b8aabe0cc..f3aa31f3d14 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "Action", "actions": "operation", + "bind_first_to_enable": "Bind a shortcut first to change its enabled state", "clear_shortcut": "Clear Shortcut", "clear_topic": "Clear Messages", + "conflict_with": "Already used by \"{{name}}\"", "copy_last_message": "Copy Last Message", "edit_last_user_message": "Edit Last User Message", "enabled": "Enable", @@ -5563,13 +5565,18 @@ "label": "Key", "mini_window": "Quick Assistant", "new_topic": "New Topic", + "occupied_by_other_application": "This shortcut is already used by the system or another application", "press_shortcut": "Press Shortcut", "rename_topic": "Rename Topic", "reset_defaults": "Reset Defaults", "reset_defaults_confirm": "Are you sure you want to reset all shortcuts?", + "reset_defaults_failed": "Failed to reset shortcuts to defaults", "reset_to_default": "Reset to Default", + "save_failed": "Failed to save shortcut", + "save_failed_with_name": "Failed to save shortcut: {{name}}", "search_message": "Search Message", "search_message_in_chat": "Search Message in Current Chat", + "search_placeholder": "Search shortcuts...", "select_model": "Select Model", "selection_assistant_select_text": "Selection Assistant: Select Text", "selection_assistant_toggle": "Toggle Selection Assistant", @@ -5577,8 +5584,8 @@ "show_settings": "Open Settings", "title": "Keyboard Shortcuts", "toggle_new_context": "Clear Context", - "toggle_show_assistants": "Toggle Assistants", "toggle_show_topics": "Toggle Topics", + "toggle_sidebar": "Toggle Sidebar", "zoom_in": "Zoom In", "zoom_out": "Zoom Out", "zoom_reset": "Reset Zoom" diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 4a0f5a77b1c..03b2d7c1e62 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "操作", "actions": "操作", + "bind_first_to_enable": "请先绑定快捷键,再调整启用状态", "clear_shortcut": "清除快捷键", "clear_topic": "清空消息", + "conflict_with": "已被「{{name}}」使用", "copy_last_message": "复制上一条消息", "edit_last_user_message": "编辑最后一条用户消息", "enabled": "启用", @@ -5563,13 +5565,18 @@ "label": "按键", "mini_window": "快捷助手", "new_topic": "新建话题", + "occupied_by_other_application": "该快捷键已被系统或其他应用占用", "press_shortcut": "按下快捷键", "rename_topic": "重命名话题", "reset_defaults": "重置默认快捷键", "reset_defaults_confirm": "确定要重置所有快捷键吗?", + "reset_defaults_failed": "重置快捷键失败", "reset_to_default": "重置为默认", + "save_failed": "保存快捷键失败", + "save_failed_with_name": "保存快捷键失败:{{name}}", "search_message": "搜索消息", "search_message_in_chat": "在当前对话中搜索消息", + "search_placeholder": "搜索快捷键...", "select_model": "选择模型", "selection_assistant_select_text": "划词助手:取词", "selection_assistant_toggle": "开关划词助手", @@ -5577,8 +5584,8 @@ "show_settings": "打开设置", "title": "快捷键", "toggle_new_context": "清除上下文", - "toggle_show_assistants": "切换助手显示", "toggle_show_topics": "切换话题显示", + "toggle_sidebar": "切换侧边栏", "zoom_in": "放大界面", "zoom_out": "缩小界面", "zoom_reset": "重置缩放" diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 2052ee51b83..097e1fcc86e 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "操作", "actions": "操作", + "bind_first_to_enable": "請先綁定快捷鍵,再調整啟用狀態", "clear_shortcut": "清除快捷鍵", "clear_topic": "清除所有訊息", + "conflict_with": "已被「{{name}}」使用", "copy_last_message": "複製上一則訊息", "edit_last_user_message": "編輯最後一則使用者訊息", "enabled": "啟用", @@ -5563,13 +5565,18 @@ "label": "按鍵", "mini_window": "快捷助手", "new_topic": "新增話題", + "occupied_by_other_application": "此快捷鍵已被系統或其他應用程式占用", "press_shortcut": "按下快捷鍵", "rename_topic": "重新命名話題", "reset_defaults": "重設預設快捷鍵", "reset_defaults_confirm": "確定要重設所有快捷鍵嗎?", + "reset_defaults_failed": "重設快捷鍵失敗", "reset_to_default": "重設為預設", + "save_failed": "儲存快捷鍵失敗", + "save_failed_with_name": "儲存快捷鍵失敗:{{name}}", "search_message": "搜尋訊息", "search_message_in_chat": "在目前對話中搜尋訊息", + "search_placeholder": "搜尋捷徑...", "select_model": "選擇模型", "selection_assistant_select_text": "劃詞助手:取詞", "selection_assistant_toggle": "開關劃詞助手", @@ -5577,8 +5584,8 @@ "show_settings": "開啟設定", "title": "快捷鍵", "toggle_new_context": "清除上下文", - "toggle_show_assistants": "切換助手顯示", "toggle_show_topics": "切換話題顯示", + "toggle_sidebar": "切換側邊欄", "zoom_in": "放大介面", "zoom_out": "縮小介面", "zoom_reset": "重設縮放" @@ -5847,11 +5854,11 @@ } }, "tab": { - "close": "[to be translated]:Close Tab", - "moveToFirst": "[to be translated]:Move to First", + "close": "關閉分頁", + "moveToFirst": "移至第一個", "new": "新標籤頁", - "pin": "[to be translated]:Pin Tab", - "unpin": "[to be translated]:Unpin Tab" + "pin": "釘選分頁", + "unpin": "取消固定分頁" }, "title": { "apps": "小程式", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 90217b02247..b28efb5fb12 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "Aktionen", "actions": "Aktionen", + "bind_first_to_enable": "Weisen Sie zunächst eine Tastenkombination zu, um ihren aktivierten Zustand zu ändern.", "clear_shortcut": "Shortcut löschen", "clear_topic": "Nachricht leeren", + "conflict_with": "Bereits von \"{{name}}\" verwendet", "copy_last_message": "Letzte Nachricht kopieren", "edit_last_user_message": "Letzte Benutzernachricht bearbeiten", "enabled": "Aktivieren", @@ -5563,13 +5565,18 @@ "label": "Taste", "mini_window": "Schnellassistent", "new_topic": "Neues Thema", + "occupied_by_other_application": "Dieses Kürzel wird bereits vom System oder einer anderen Anwendung verwendet", "press_shortcut": "Shortcut drücken", "rename_topic": "Thema umbenennen", "reset_defaults": "Standard-Shortcuts zurücksetzen", "reset_defaults_confirm": "Alle Shortcuts wirklich zurücksetzen?", + "reset_defaults_failed": "Verknüpfungen konnten nicht auf Standard zurückgesetzt werden", "reset_to_default": "Auf Standard zurücksetzen", + "save_failed": "Verknüpfung konnte nicht gespeichert werden", + "save_failed_with_name": "Fehler beim Speichern der Verknüpfung: {{name}}", "search_message": "Nachricht suchen", "search_message_in_chat": "In aktuellem Chat suchen", + "search_placeholder": "Suchverknüpfungen...", "select_model": "Select Model", "selection_assistant_select_text": "Textauswahl-Assistent: Text erfassen", "selection_assistant_toggle": "Textauswahl-Assistent umschalten", @@ -5577,8 +5584,8 @@ "show_settings": "Einstellungen öffnen", "title": "Shortcut", "toggle_new_context": "Kontext löschen", - "toggle_show_assistants": "Assistentenanzeige umschalten", "toggle_show_topics": "Themenanzeige umschalten", + "toggle_sidebar": "Seitenleiste umschalten", "zoom_in": "Oberfläche vergrößern", "zoom_out": "Oberfläche verkleinern", "zoom_reset": "Zoom zurücksetzen" @@ -5847,11 +5854,11 @@ } }, "tab": { - "close": "[to be translated]:Close Tab", - "moveToFirst": "[to be translated]:Move to First", - "new": "[to be translated]:New Tab", - "pin": "[to be translated]:Pin Tab", - "unpin": "[to be translated]:Unpin Tab" + "close": "Tab schließen", + "moveToFirst": "Zum Anfang verschieben", + "new": "Neuer Tab", + "pin": "Tab anheften", + "unpin": "Tab lösen" }, "title": { "apps": "Mini-Apps", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 5867c8078b1..dd3c31bc7f2 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "Ενέργεια", "actions": "Λειτουργία", + "bind_first_to_enable": "Δέστε πρώτα μια συντόμευση για να αλλάξετε την κατάσταση ενεργοποίησής της", "clear_shortcut": "Καθαρισμός συντομού πλήκτρου", "clear_topic": "Άδειασμα μηνυμάτων", + "conflict_with": "Ήδη χρησιμοποιείται από τον \"{{name}}\"", "copy_last_message": "Αντιγραφή του τελευταίου μηνύματος", "edit_last_user_message": "Επεξεργασία του τελευταίου μηνύματος χρήστη", "enabled": "ενεργοποίηση", @@ -5563,13 +5565,18 @@ "label": "Πλήκτρο", "mini_window": "Συντομεύστε επιχειρηματικά", "new_topic": "Νέο θέμα", + "occupied_by_other_application": "Αυτή η συντόμευση χρησιμοποιείται ήδη από το σύστημα ή από άλλη εφαρμογή", "press_shortcut": "Πάτησε το συντομού πλήκτρου", "rename_topic": "Μετονομασία θέματος", "reset_defaults": "Επαναφορά στα προεπιλεγμένα συντομού πλήκτρα", "reset_defaults_confirm": "Θέλετε να επαναφέρετε όλα τα συντομού πλήκτρα στις προεπιλεγμένες τιμές;", + "reset_defaults_failed": "Αποτυχία επαναφοράς των συντομεύσεων στις προεπιλογές", "reset_to_default": "Επαναφορά στις προεπιλεγμένες", + "save_failed": "Αποτυχία αποθήκευσης της συντόμευσης", + "save_failed_with_name": "Αποτυχία αποθήκευσης συντόμευσης: {{name}}", "search_message": "Αναζήτηση μηνυμάτων", "search_message_in_chat": "Αναζήτηση μηνύματος στην τρέχουσα συνομιλία", + "search_placeholder": "Συντομεύσεις αναζήτησης...", "select_model": "Select Model", "selection_assistant_select_text": "Βοηθός επιλογής κειμένου: επιλογή λέξης", "selection_assistant_toggle": "Εναλλαγή βοηθού επιλογής κειμένου", @@ -5577,8 +5584,8 @@ "show_settings": "Άνοιγμα των ρυθμίσεων", "title": "Συντομοί δρομολόγια", "toggle_new_context": "Άδειασμα σενάριων", - "toggle_show_assistants": "Εναλλαγή εμφάνισης βοηθών", "toggle_show_topics": "Εναλλαγή εμφάνισης θεμάτων", + "toggle_sidebar": "Εναλλαγή πλευρικής γραμμής", "zoom_in": "Μεγέθυνση εμφάνισης", "zoom_out": "Σμικρύνση εμφάνισης", "zoom_reset": "Επαναφορά εμφάνισης" @@ -5847,11 +5854,11 @@ } }, "tab": { - "close": "[to be translated]:Close Tab", - "moveToFirst": "[to be translated]:Move to First", - "new": "[to be translated]:New Tab", - "pin": "[to be translated]:Pin Tab", - "unpin": "[to be translated]:Unpin Tab" + "close": "Κλείσιμο Καρτέλας", + "moveToFirst": "Μετακίνηση στο Πρώτο", + "new": "Νέα Καρτέλα", + "pin": "Καρφίτσωμα καρτέλας", + "unpin": "Ξεκαρφίτσωση Καρτέλας" }, "title": { "apps": "Εφαρμογές", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 06ab113f739..72b4ff065eb 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "Acción", "actions": "operación", + "bind_first_to_enable": "Primero vincula un atajo para cambiar su estado de activación", "clear_shortcut": "Borrar atajo", "clear_topic": "Vaciar mensaje", + "conflict_with": "Ya utilizado por \"{{name}}\"", "copy_last_message": "Copiar el último mensaje", "edit_last_user_message": "Editar último mensaje de usuario", "enabled": "habilitar", @@ -5563,13 +5565,18 @@ "label": "Tecla", "mini_window": "Asistente rápido", "new_topic": "Nuevo tema", + "occupied_by_other_application": "Este atajo ya está en uso por el sistema u otra aplicación", "press_shortcut": "Presionar atajo", "rename_topic": "Renombrar tema", "reset_defaults": "Restablecer atajos predeterminados", "reset_defaults_confirm": "¿Está seguro de querer restablecer todos los atajos?", + "reset_defaults_failed": "Error al restablecer los atajos a los valores predeterminados", "reset_to_default": "Restablecer a predeterminado", + "save_failed": "Error al guardar el acceso directo", + "save_failed_with_name": "Error al guardar el acceso directo: {{name}}", "search_message": "Buscar mensaje", "search_message_in_chat": "Buscar mensajes en la conversación actual", + "search_placeholder": "Buscar accesos directos...", "select_model": "Select Model", "selection_assistant_select_text": "Asistente de selección de texto: obtener palabras", "selection_assistant_toggle": "Activar/desactivar el asistente de selección de texto", @@ -5577,8 +5584,8 @@ "show_settings": "Abrir configuración", "title": "Atajos", "toggle_new_context": "Limpiar contexto", - "toggle_show_assistants": "Alternar visibilidad de asistentes", "toggle_show_topics": "Alternar visibilidad de temas", + "toggle_sidebar": "Alternar barra lateral", "zoom_in": "Ampliar interfaz", "zoom_out": "Reducir interfaz", "zoom_reset": "Restablecer zoom" @@ -5847,11 +5854,11 @@ } }, "tab": { - "close": "[to be translated]:Close Tab", - "moveToFirst": "[to be translated]:Move to First", - "new": "[to be translated]:New Tab", - "pin": "[to be translated]:Pin Tab", - "unpin": "[to be translated]:Unpin Tab" + "close": "Cerrar pestaña", + "moveToFirst": "Mover al primero", + "new": "Nueva pestaña", + "pin": "Anclar pestaña", + "unpin": "Desanclar pestaña" }, "title": { "apps": "Aplicaciones", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 1c14baa51e3..dfe049fa72e 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "Action", "actions": "操作", + "bind_first_to_enable": "Liez d'abord un raccourci pour modifier son état activé", "clear_shortcut": "Effacer raccourci clavier", "clear_topic": "Vider les messages", + "conflict_with": "Déjà utilisé par \"{{name}}\"", "copy_last_message": "Copier le dernier message", "edit_last_user_message": "Éditer le dernier message utilisateur", "enabled": "activer", @@ -5563,13 +5565,18 @@ "label": "Touche", "mini_window": "Assistant rapide", "new_topic": "Nouveau sujet", + "occupied_by_other_application": "Ce raccourci est déjà utilisé par le système ou une autre application", "press_shortcut": "Appuyer sur raccourci clavier", "rename_topic": "Renommer le sujet", "reset_defaults": "Réinitialiser raccourcis par défaut", "reset_defaults_confirm": "Êtes-vous sûr de vouloir réinitialiser tous les raccourcis clavier ?", + "reset_defaults_failed": "Échec de la réinitialisation des raccourcis aux valeurs par défaut", "reset_to_default": "Réinitialiser aux valeurs par défaut", + "save_failed": "Échec de l’enregistrement du raccourci", + "save_failed_with_name": "Échec de l’enregistrement du raccourci : {{name}}", "search_message": "Rechercher un message", "search_message_in_chat": "Rechercher un message dans la conversation actuelle", + "search_placeholder": "Raccourcis de recherche...", "select_model": "Select Model", "selection_assistant_select_text": "Assistant de sélection de texte : extraire le texte", "selection_assistant_toggle": "Activer/désactiver l'assistant de sélection de texte", @@ -5577,8 +5584,8 @@ "show_settings": "Ouvrir les paramètres", "title": "Raccourcis", "toggle_new_context": "Effacer le contexte", - "toggle_show_assistants": "Basculer l'affichage des assistants", "toggle_show_topics": "Basculer l'affichage des sujets", + "toggle_sidebar": "Basculer la barre latérale", "zoom_in": "Agrandir l'interface", "zoom_out": "Réduire l'interface", "zoom_reset": "Réinitialiser le zoom" @@ -5847,11 +5854,11 @@ } }, "tab": { - "close": "[to be translated]:Close Tab", - "moveToFirst": "[to be translated]:Move to First", - "new": "[to be translated]:New Tab", - "pin": "[to be translated]:Pin Tab", - "unpin": "[to be translated]:Unpin Tab" + "close": "Fermer l'onglet", + "moveToFirst": "Déplacer vers le premier", + "new": "Nouvel onglet", + "pin": "Épingler l'onglet", + "unpin": "Détacher l'onglet" }, "title": { "apps": "Mini-programmes", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index f0def4892d6..03f2e23856e 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "操作", "actions": "操作", + "bind_first_to_enable": "ショートカットを最初に割り当てて、その有効状態を変更してください", "clear_shortcut": "ショートカットをクリア", "clear_topic": "メッセージを消去", + "conflict_with": "すでに「{{name}}」によって使用されています", "copy_last_message": "最後のメッセージをコピー", "edit_last_user_message": "最後のユーザーメッセージを編集", "enabled": "有効化", @@ -5563,13 +5565,18 @@ "label": "キー", "mini_window": "クイックアシスタント", "new_topic": "新しいトピック", + "occupied_by_other_application": "このショートカットは既にシステムまたは別のアプリケーションで使用されています", "press_shortcut": "ショートカットを押す", "rename_topic": "トピックの名前を変更", "reset_defaults": "デフォルトのショートカットをリセット", "reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?", + "reset_defaults_failed": "ショートカットをデフォルトにリセットできませんでした", "reset_to_default": "デフォルトにリセット", + "save_failed": "ショートカットの保存に失敗しました", + "save_failed_with_name": "ショートカットの保存に失敗しました: {{name}}", "search_message": "メッセージを検索", "search_message_in_chat": "現在のチャットでメッセージを検索", + "search_placeholder": "検索のショートカット...", "select_model": "Select Model", "selection_assistant_select_text": "選択アシスタント:テキストを選択", "selection_assistant_toggle": "選択アシスタントを切り替え", @@ -5577,8 +5584,8 @@ "show_settings": "設定を開く", "title": "ショートカット", "toggle_new_context": "コンテキストをクリア", - "toggle_show_assistants": "アシスタントの表示を切り替え", "toggle_show_topics": "トピックの表示を切り替え", + "toggle_sidebar": "サイドバーを切り替える", "zoom_in": "ズームイン", "zoom_out": "ズームアウト", "zoom_reset": "ズームをリセット" @@ -5847,11 +5854,11 @@ } }, "tab": { - "close": "[to be translated]:Close Tab", - "moveToFirst": "[to be translated]:Move to First", - "new": "[to be translated]:New Tab", - "pin": "[to be translated]:Pin Tab", - "unpin": "[to be translated]:Unpin Tab" + "close": "タブを閉じる", + "moveToFirst": "先頭へ移動", + "new": "新しいタブ", + "pin": "タブをピン留め", + "unpin": "タブを固定解除" }, "title": { "apps": "アプリ", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 7b9b348a495..73163ba75a7 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "Ação", "actions": "operação", + "bind_first_to_enable": "Primeiro, vincule um atalho para alterar o seu estado ativado", "clear_shortcut": "Limpar atalho", "clear_topic": "Limpar mensagem", + "conflict_with": "Já usado por \"{{name}}\"", "copy_last_message": "Copiar a última mensagem", "edit_last_user_message": "Editar última mensagem do usuário", "enabled": "ativar", @@ -5563,13 +5565,18 @@ "label": "Tecla", "mini_window": "Atalho de assistente", "new_topic": "Novo tópico", + "occupied_by_other_application": "Este atalho já está sendo usado pelo sistema ou por outro aplicativo", "press_shortcut": "Pressionar atalho", "rename_topic": "Renomear tópico", "reset_defaults": "Redefinir atalhos padrão", "reset_defaults_confirm": "Tem certeza de que deseja redefinir todos os atalhos?", + "reset_defaults_failed": "Falha ao redefinir atalhos para os padrões", "reset_to_default": "Redefinir para padrão", + "save_failed": "Falha ao salvar atalho", + "save_failed_with_name": "Falha ao salvar atalho: {{name}}", "search_message": "Pesquisar mensagem", "search_message_in_chat": "Pesquisar mensagens nesta conversa", + "search_placeholder": "Atalhos de pesquisa...", "select_model": "Select Model", "selection_assistant_select_text": "Assistente de seleção de texto: selecionar texto", "selection_assistant_toggle": "Ativar/desativar assistente de seleção de texto", @@ -5577,8 +5584,8 @@ "show_settings": "Abrir configurações", "title": "Atalhos", "toggle_new_context": "Limpar contexto", - "toggle_show_assistants": "Alternar exibição de assistentes", "toggle_show_topics": "Alternar exibição de tópicos", + "toggle_sidebar": "Alternar Barra Lateral", "zoom_in": "Ampliar interface", "zoom_out": "Diminuir interface", "zoom_reset": "Redefinir zoom" @@ -5847,11 +5854,11 @@ } }, "tab": { - "close": "[to be translated]:Close Tab", - "moveToFirst": "[to be translated]:Move to First", - "new": "[to be translated]:New Tab", - "pin": "[to be translated]:Pin Tab", - "unpin": "[to be translated]:Unpin Tab" + "close": "Fechar Aba", + "moveToFirst": "Mover para o Primeiro", + "new": "Nova Aba", + "pin": "Fixar Aba", + "unpin": "Desafixar Aba" }, "title": { "apps": "Miniaplicativos", diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index 36a8e61dc6e..6cee6566929 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "Acțiune", "actions": "Comandă", + "bind_first_to_enable": "Asociază mai întâi o comandă rapidă pentru a-i schimba starea de activare", "clear_shortcut": "Șterge comanda rapidă", "clear_topic": "Șterge mesajele", + "conflict_with": "Deja folosit de \"{{name}}\"", "copy_last_message": "Copiază ultimul mesaj", "edit_last_user_message": "Editează ultimul mesaj al utilizatorului", "enabled": "Activează", @@ -5563,13 +5565,18 @@ "label": "Tastă", "mini_window": "Asistent rapid", "new_topic": "Subiect nou", + "occupied_by_other_application": "Această comandă rapidă este deja utilizată de sistem sau de o altă aplicație", "press_shortcut": "Apasă comanda rapidă", "rename_topic": "Redenumește subiectul", "reset_defaults": "Resetează la implicite", "reset_defaults_confirm": "Ești sigur că vrei să resetezi toate comenzile rapide?", + "reset_defaults_failed": "Nu s-a reușit resetarea comenzilor rapide la valorile implicite", "reset_to_default": "Resetează la implicit", + "save_failed": "Nu s-a putut salva comanda rapidă", + "save_failed_with_name": "Nu s-a putut salva comanda rapidă: {{name}}", "search_message": "Caută mesaj", "search_message_in_chat": "Caută mesaj în chat-ul curent", + "search_placeholder": "Căutare comenzi rapide...", "select_model": "Select Model", "selection_assistant_select_text": "Asistent de selecție: Selectează text", "selection_assistant_toggle": "Comută Asistentul de selecție", @@ -5577,8 +5584,8 @@ "show_settings": "Deschide setările", "title": "Comenzi rapide de la tastatură", "toggle_new_context": "Șterge contextul", - "toggle_show_assistants": "Comută asistenții", "toggle_show_topics": "Comută subiectele", + "toggle_sidebar": "Comutare bară laterală", "zoom_in": "Mărește", "zoom_out": "Micșorează", "zoom_reset": "Resetează zoom-ul" @@ -5847,11 +5854,11 @@ } }, "tab": { - "close": "[to be translated]:Close Tab", - "moveToFirst": "[to be translated]:Move to First", - "new": "[to be translated]:New Tab", - "pin": "[to be translated]:Pin Tab", - "unpin": "[to be translated]:Unpin Tab" + "close": "Închide fila", + "moveToFirst": "Mută la început", + "new": "Filă nouă", + "pin": "Fixează fila", + "unpin": "Anulați fixarea filei" }, "title": { "apps": "Aplicații", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 97cdf822d55..23936b22cbf 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -5554,8 +5554,10 @@ "shortcuts": { "action": "Действие", "actions": "操作", + "bind_first_to_enable": "Сначала назначьте сочетание клавиш, чтобы изменить его состояние включения.", "clear_shortcut": "Очистить сочетание клавиш", "clear_topic": "Очистить все сообщения", + "conflict_with": "Уже используется \"{{name}}\"", "copy_last_message": "Копировать последнее сообщение", "edit_last_user_message": "Редактировать последнее сообщение пользователя", "enabled": "Включить", @@ -5563,13 +5565,18 @@ "label": "Клавиша", "mini_window": "Быстрый помощник", "new_topic": "Новый топик", + "occupied_by_other_application": "Это сочетание клавиш уже используется системой или другим приложением", "press_shortcut": "Нажмите сочетание клавиш", "rename_topic": "Переименовать топик", "reset_defaults": "Сбросить настройки по умолчанию", "reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?", + "reset_defaults_failed": "Не удалось сбросить сочетания клавиш к значениям по умолчанию", "reset_to_default": "Сбросить настройки по умолчанию", + "save_failed": "Не удалось сохранить ярлык", + "save_failed_with_name": "Не удалось сохранить ярлык: {{name}}", "search_message": "Поиск сообщения", "search_message_in_chat": "Поиск сообщения в текущем диалоге", + "search_placeholder": "Поисковые ярлыки...", "select_model": "Select Model", "selection_assistant_select_text": "Помощник выделения: выделить текст", "selection_assistant_toggle": "Переключить помощник выделения", @@ -5577,8 +5584,8 @@ "show_settings": "Открыть настройки", "title": "Горячие клавиши", "toggle_new_context": "Очистить контекст", - "toggle_show_assistants": "Переключить отображение ассистентов", "toggle_show_topics": "Переключить отображение топиков", + "toggle_sidebar": "Свернуть боковую панель", "zoom_in": "Увеличить", "zoom_out": "Уменьшить", "zoom_reset": "Сбросить масштаб" @@ -5847,11 +5854,11 @@ } }, "tab": { - "close": "[to be translated]:Close Tab", - "moveToFirst": "[to be translated]:Move to First", - "new": "[to be translated]:New Tab", - "pin": "[to be translated]:Pin Tab", - "unpin": "[to be translated]:Unpin Tab" + "close": "Закрыть вкладку", + "moveToFirst": "Перейти к первому", + "new": "Новая вкладка", + "pin": "Закрепить вкладку", + "unpin": "Открепить вкладку" }, "title": { "apps": "Приложения", diff --git a/src/renderer/src/pages/agents/AgentChat.tsx b/src/renderer/src/pages/agents/AgentChat.tsx index 3cee232e04d..6644151ed55 100644 --- a/src/renderer/src/pages/agents/AgentChat.tsx +++ b/src/renderer/src/pages/agents/AgentChat.tsx @@ -45,7 +45,7 @@ const AgentChat = () => { const showRightSessions = topicPosition === 'right' && showTopics && !!activeAgentId useShortcut( - 'new_topic', + 'topic.new', () => { void createDefaultSession() }, diff --git a/src/renderer/src/pages/agents/AgentNavbar.tsx b/src/renderer/src/pages/agents/AgentNavbar.tsx index d1fb2b617d4..aedb75cba23 100644 --- a/src/renderer/src/pages/agents/AgentNavbar.tsx +++ b/src/renderer/src/pages/agents/AgentNavbar.tsx @@ -20,7 +20,7 @@ const AgentNavbar = () => { const [narrowMode, setNarrowMode] = usePreference('chat.narrow_mode') const [topicPosition] = usePreference('topic.position') - useShortcut('search_message', () => { + useShortcut('general.search', () => { void SearchPopup.show() }) diff --git a/src/renderer/src/pages/agents/AgentPage.tsx b/src/renderer/src/pages/agents/AgentPage.tsx index 812175403c3..a8cf2f5f9d0 100644 --- a/src/renderer/src/pages/agents/AgentPage.tsx +++ b/src/renderer/src/pages/agents/AgentPage.tsx @@ -32,7 +32,8 @@ const AgentPage = () => { const { apiServerConfig, apiServerRunning, apiServerLoading } = useApiServer() const { t } = useTranslation() - useShortcut('toggle_show_assistants', () => { + // TODO: Replace with sidebar toggle logic once the new sidebar UI is implemented + useShortcut('general.toggle_sidebar', () => { if (topicPosition === 'left') { void toggleShowAssistants() return @@ -41,7 +42,7 @@ const AgentPage = () => { void EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS) }) - useShortcut('toggle_show_topics', () => { + useShortcut('topic.toggle_show_topics', () => { if (topicPosition === 'right') { void toggleShowTopics() } else { diff --git a/src/renderer/src/pages/agents/components/AgentChatNavbar/index.tsx b/src/renderer/src/pages/agents/components/AgentChatNavbar/index.tsx index c20dbc57f43..3ee7bb54f37 100644 --- a/src/renderer/src/pages/agents/components/AgentChatNavbar/index.tsx +++ b/src/renderer/src/pages/agents/components/AgentChatNavbar/index.tsx @@ -12,7 +12,7 @@ interface Props { } const AgentChatNavbar = ({ activeAgent, className }: Props) => { - useShortcut('search_message', () => { + useShortcut('general.search', () => { void SearchPopup.show() }) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 18389a3ac62..625a64effbd 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -60,7 +60,7 @@ const Chat: FC = (props) => { contentSearchRef.current?.disable() }) - useShortcut('search_message_in_chat', () => { + useShortcut('chat.search_message', () => { try { const selectedText = window.getSelection()?.toString().trim() contentSearchRef.current?.enable(selectedText) @@ -69,7 +69,7 @@ const Chat: FC = (props) => { } }) - useShortcut('rename_topic', async () => { + useShortcut('topic.rename', async () => { const topic = props.activeTopic if (!topic) return @@ -87,7 +87,7 @@ const Chat: FC = (props) => { } }) - useShortcut('select_model', async () => { + useShortcut('chat.select_model', async () => { const modelFilter = (m: Model) => !isEmbeddingModel(m) && !isRerankModel(m) const selectedModel = await SelectChatModelPopup.show({ model: assistant?.model, diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index 0fcf81428ea..0a5a0805272 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -45,7 +45,8 @@ const HomePage: FC = () => { _activeAssistant = activeAssistant - useShortcut('toggle_show_assistants', () => { + // TODO: Replace with sidebar toggle logic once the new sidebar UI is implemented + useShortcut('general.toggle_sidebar', () => { if (topicPosition === 'right') { void toggleShowAssistants() return @@ -62,7 +63,7 @@ const HomePage: FC = () => { void EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS) }) - useShortcut('toggle_show_topics', () => { + useShortcut('topic.toggle_show_topics', () => { if (topicPosition === 'right') { void toggleShowTopics() return diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index f4c5c0a0d8d..f73b7540c4b 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -383,7 +383,7 @@ const InputbarInner: FC = ({ assistant: initialAssistant, se }, [resizeTextArea, addNewTopic, clearTopic, onNewContext, setText, handleToggleExpanded, actionsRef]) useShortcut( - 'new_topic', + 'topic.new', () => { void addNewTopic() void EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR) @@ -392,7 +392,7 @@ const InputbarInner: FC = ({ assistant: initialAssistant, se { preventDefault: true, enableOnFormTags: true } ) - useShortcut('clear_topic', clearTopic, { + useShortcut('chat.clear', clearTopic, { preventDefault: true, enableOnFormTags: true }) diff --git a/src/renderer/src/pages/home/Inputbar/tools/clearTopicTool.tsx b/src/renderer/src/pages/home/Inputbar/tools/clearTopicTool.tsx index 69fae5e4255..03d6facbe7c 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/clearTopicTool.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/clearTopicTool.tsx @@ -13,7 +13,7 @@ const clearTopicTool = defineTool({ }, render: function ClearTopicRender(context) { const { actions, t } = context - const clearTopicShortcut = useShortcutDisplay('clear_topic') + const clearTopicShortcut = useShortcutDisplay('chat.clear') return ( = ({ onNewContext }) => { - const newContextShortcut = useShortcutDisplay('toggle_new_context') + const newContextShortcut = useShortcutDisplay('chat.toggle_new_context') const { t } = useTranslation() - useShortcut('toggle_new_context', onNewContext) + useShortcut('chat.toggle_new_context', onNewContext) return ( diff --git a/src/renderer/src/pages/home/Inputbar/tools/createSessionTool.tsx b/src/renderer/src/pages/home/Inputbar/tools/createSessionTool.tsx index a595dd9faf7..fef38717659 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/createSessionTool.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/createSessionTool.tsx @@ -17,7 +17,7 @@ const createSessionTool = defineTool({ render: function CreateSessionRender(context) { const { t, assistant, session } = context - const newTopicShortcut = useShortcutDisplay('new_topic') + const newTopicShortcut = useShortcutDisplay('topic.new') const { apiServer } = useSettings() const sessionAgentId = session?.agentId diff --git a/src/renderer/src/pages/home/Inputbar/tools/newTopicTool.tsx b/src/renderer/src/pages/home/Inputbar/tools/newTopicTool.tsx index 93d18a7c7cd..e260b0a9e0e 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/newTopicTool.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/newTopicTool.tsx @@ -16,7 +16,7 @@ const newTopicTool = defineTool({ render: function NewTopicRender(context) { const { actions, t } = context - const newTopicShortcut = useShortcutDisplay('new_topic') + const newTopicShortcut = useShortcutDisplay('topic.new') return ( = ({ assistant, topic, setActiveTopic, o ) }, [displayMessages.length, hasMore, isLoadingMore, messages, setTimeoutTimer]) - useShortcut('copy_last_message', () => { + useShortcut('chat.copy_last_message', () => { const lastMessage = last(messages) if (lastMessage) { void navigator.clipboard.writeText(getMainTextContent(lastMessage)) @@ -276,7 +276,7 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o } }) - useShortcut('edit_last_user_message', () => { + useShortcut('chat.edit_last_user_message', () => { const lastUserMessage = messagesRef.current.findLast((m) => m.role === 'user' && m.type !== 'clear') if (lastUserMessage) { void EventEmitter.emit(EVENT_NAMES.EDIT_MESSAGE, lastUserMessage.id) diff --git a/src/renderer/src/pages/home/Navbar.tsx b/src/renderer/src/pages/home/Navbar.tsx index f606104df14..4016c82d0a3 100644 --- a/src/renderer/src/pages/home/Navbar.tsx +++ b/src/renderer/src/pages/home/Navbar.tsx @@ -31,7 +31,7 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo const { showAssistants, toggleShowAssistants } = useShowAssistants() const { showTopics, toggleShowTopics } = useShowTopics() - useShortcut('search_message', () => { + useShortcut('general.search', () => { void SearchPopup.show() }) diff --git a/src/renderer/src/pages/home/components/ChatNavBar/index.tsx b/src/renderer/src/pages/home/components/ChatNavBar/index.tsx index 6fc4548ec9b..081b168910a 100644 --- a/src/renderer/src/pages/home/components/ChatNavBar/index.tsx +++ b/src/renderer/src/pages/home/components/ChatNavBar/index.tsx @@ -29,7 +29,7 @@ const HeaderNavbar: FC = ({ activeAssistant, setActiveAssistant, activeTo const { isTopNavbar } = useNavbarPosition() - useShortcut('search_message', () => { + useShortcut('general.search', () => { void SearchPopup.show() }) diff --git a/src/renderer/src/pages/knowledge/KnowledgePage.tsx b/src/renderer/src/pages/knowledge/KnowledgePage.tsx index c267b05d2bf..a30844ce1a0 100644 --- a/src/renderer/src/pages/knowledge/KnowledgePage.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgePage.tsx @@ -93,7 +93,7 @@ const KnowledgePage: FC = () => { [deleteKnowledgeBase, handleEditKnowledgeBase, renameKnowledgeBase, t] ) - useShortcut('search_message', () => { + useShortcut('general.search', () => { if (selectedBase) { void KnowledgeSearchPopup.show({ base: selectedBase }).then() } diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index 4c68865bffa..24a3b46bc1f 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -1,409 +1,362 @@ -import { ClearOutlined, UndoOutlined } from '@ant-design/icons' -import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui' -import { isMac, isWin } from '@renderer/config/constant' +import { UndoOutlined } from '@ant-design/icons' +import { Button, Input, RowFlex, Switch, Tooltip } from '@cherrystudio/ui' +import { preferenceService } from '@data/PreferenceService' +import { loggerService } from '@logger' +import { isMac } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' -import { useShortcuts } from '@renderer/hooks/useShortcuts' +import { getAllShortcutDefaultPreferences, useAllShortcuts } from '@renderer/hooks/useShortcuts' import { useTimer } from '@renderer/hooks/useTimer' -import { getShortcutLabel } from '@renderer/i18n/label' -import { useAppDispatch } from '@renderer/store' -import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts' -import type { Shortcut } from '@renderer/types' -import type { InputRef } from 'antd' -import { Input, Table as AntTable } from 'antd' -import type { ColumnsType } from 'antd/es/table' -import type { FC } from 'react' -import React, { useRef, useState } from 'react' +import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' +import type { ShortcutPreferenceKey } from '@shared/shortcuts/types' +import { + convertKeyToAccelerator, + formatKeyDisplay, + formatShortcutDisplay, + isValidShortcut +} from '@shared/shortcuts/utils' +import type { FC, KeyboardEvent as ReactKeyboardEvent } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' +const logger = loggerService.withContext('ShortcutSettings') + +const isBindingEqual = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((key, index) => key === b[index]) + +const keyCodeToAccelerator: Record = { + Backquote: '`', + Period: '.', + NumpadEnter: 'Enter', + Space: 'Space', + Enter: 'Enter', + Backspace: 'Backspace', + Tab: 'Tab', + Delete: 'Delete' +} + +const passthrough = + /^(Page(Up|Down)|Insert|Home|End|Arrow(Up|Down|Left|Right)|F([1-9]|1[0-9])|Slash|Semicolon|Bracket(Left|Right)|Backslash|Quote|Comma|Minus|Equal)$/ + +const usableEndKeys = (code: string): string | null => { + if (/^Key[A-Z]$/.test(code) || /^(Digit|Numpad)\d$/.test(code)) return code.slice(-1) + if (keyCodeToAccelerator[code]) return keyCodeToAccelerator[code] + if (passthrough.test(code)) return code + return null +} + const ShortcutSettings: FC = () => { const { t } = useTranslation() const { theme } = useTheme() - const dispatch = useAppDispatch() - const { shortcuts: originalShortcuts } = useShortcuts() - const inputRefs = useRef>({}) + const { shortcuts, updatePreference } = useAllShortcuts() + const inputRefs = useRef>({}) const [editingKey, setEditingKey] = useState(null) - const { setTimeoutTimer } = useTimer() + const [pendingKeys, setPendingKeys] = useState([]) + const [conflictLabel, setConflictLabel] = useState(null) + const [systemConflictKey, setSystemConflictKey] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const { setTimeoutTimer, clearTimeoutTimer } = useTimer() + + const visibleShortcuts = useMemo(() => { + const query = searchQuery.toLowerCase() + return shortcuts.filter((record) => { + if (!query) return true + const display = + record.preference.binding.length > 0 + ? formatShortcutDisplay(record.preference.binding, isMac).toLowerCase() + : '' + return record.label.toLowerCase().includes(query) || display.includes(query) + }) + }, [searchQuery, shortcuts]) - //if shortcut is not available on all the platforms, block the shortcut here - const shortcuts = originalShortcuts + const duplicateBindingLabels = useMemo(() => { + const lookup = new Map() - const handleClear = (record: Shortcut) => { - dispatch( - updateShortcut({ - ...record, - shortcut: [] + for (const shortcut of shortcuts) { + if (!shortcut.preference.enabled || !shortcut.preference.binding.length) continue + lookup.set(shortcut.preference.binding.map((key) => key.toLowerCase()).join('+'), { + key: shortcut.key, + label: shortcut.label }) - ) + } + + return lookup + }, [shortcuts]) + + const clearEditingState = () => { + clearTimeoutTimer('conflict-clear') + setEditingKey(null) + setPendingKeys([]) + setConflictLabel(null) } - const handleAddShortcut = (record: Shortcut) => { - setEditingKey(record.key) + const clearSystemConflict = (key?: ShortcutPreferenceKey) => { + setSystemConflictKey((currentKey) => { + if (!key || currentKey === key) { + return null + } + return currentKey + }) + } + + useEffect(() => { + return window.api.shortcut.onRegistrationConflict(({ key, hasConflict }) => { + setSystemConflictKey((currentKey) => { + if (hasConflict) { + return key + } + return currentKey === key ? null : currentKey + }) + + if (hasConflict) { + window.toast.error(t('settings.shortcuts.occupied_by_other_application')) + } + }) + }, [t]) + + const handleAddShortcut = (key: ShortcutPreferenceKey) => { + clearEditingState() + setEditingKey(key) setTimeoutTimer( - 'handleAddShortcut', + `focus-${key}`, () => { - inputRefs.current[record.key]?.focus() + inputRefs.current[key]?.focus() }, 0 ) } - const isShortcutModified = (record: Shortcut) => { - const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key) - return defaultShortcut?.shortcut.join('+') !== record.shortcut.join('+') + const handleUpdateFailure = (record: (typeof shortcuts)[number], error: unknown) => { + logger.error(`Failed to update shortcut preference: ${record.key}`, error as Error) + window.toast.error(t('settings.shortcuts.save_failed_with_name', { name: record.label })) } - const handleResetShortcut = (record: Shortcut) => { - const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key) - if (defaultShortcut) { - dispatch( - updateShortcut({ - ...record, - shortcut: defaultShortcut.shortcut - }) - ) + const handleResetShortcut = async (record: (typeof shortcuts)[number]) => { + try { + clearSystemConflict(record.key) + await updatePreference(record.key, { + binding: record.defaultPreference.binding, + enabled: record.defaultPreference.enabled + }) + clearEditingState() + } catch (error) { + handleUpdateFailure(record, error) } } - const isValidShortcut = (keys: string[]): boolean => { - // OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE - // const hasModifier = keys.some((key) => ['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key)) - // const hasNonModifier = keys.some((key) => !['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key)) - - // NEW WAY FOR MODIFIER KEYS - const hasModifier = keys.some((key) => ['CommandOrControl', 'Ctrl', 'Alt', 'Meta', 'Shift'].includes(key)) - const hasNonModifier = keys.some((key) => !['CommandOrControl', 'Ctrl', 'Alt', 'Meta', 'Shift'].includes(key)) - - const hasFnKey = keys.some((key) => /^F\d+$/.test(key)) - - return (hasModifier && hasNonModifier && keys.length >= 2) || hasFnKey - } - - const isDuplicateShortcut = (newShortcut: string[], currentKey: string): boolean => { - return shortcuts.some( - (s) => s.key !== currentKey && s.shortcut.length > 0 && s.shortcut.join('+') === newShortcut.join('+') - ) + const findDuplicateLabel = (keys: string[], currentKey: ShortcutPreferenceKey): string | null => { + const duplicate = duplicateBindingLabels.get(keys.map((key) => key.toLowerCase()).join('+')) + return duplicate && duplicate.key !== currentKey ? duplicate.label : null } - // how the shortcut is displayed in the UI - const formatShortcut = (shortcut: string[]): string => { - return shortcut - .map((key) => { - switch (key) { - // OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE - // case 'Control': - // return isMac ? '⌃' : 'Ctrl' - // case 'Ctrl': - // return isMac ? '⌃' : 'Ctrl' - // case 'Command': - // return isMac ? '⌘' : isWin ? 'Win' : 'Super' - // case 'Alt': - // return isMac ? '⌥' : 'Alt' - // case 'Shift': - // return isMac ? '⇧' : 'Shift' - // case 'CommandOrControl': - // return isMac ? '⌘' : 'Ctrl' - - // new way for modifier keys - case 'CommandOrControl': - return isMac ? '⌘' : 'Ctrl' - case 'Ctrl': - return isMac ? '⌃' : 'Ctrl' - case 'Alt': - return isMac ? '⌥' : 'Alt' - case 'Meta': - return isMac ? '⌘' : isWin ? 'Win' : 'Super' - case 'Shift': - return isMac ? '⇧' : 'Shift' - - // for backward compatibility with old data - case 'Command': - case 'Cmd': - return isMac ? '⌘' : 'Ctrl' - case 'Control': - return isMac ? '⌃' : 'Ctrl' - - case 'ArrowUp': - return '↑' - case 'ArrowDown': - return '↓' - case 'ArrowLeft': - return '←' - case 'ArrowRight': - return '→' - case 'Slash': - return '/' - case 'Semicolon': - return ';' - case 'BracketLeft': - return '[' - case 'BracketRight': - return ']' - case 'Backslash': - return '\\' - case 'Quote': - return "'" - case 'Comma': - return ',' - case 'Minus': - return '-' - case 'Equal': - return '=' - default: - return key.charAt(0).toUpperCase() + key.slice(1) - } - }) - .join(' + ') - } + const handleKeyDown = async (event: ReactKeyboardEvent, record: (typeof shortcuts)[number]) => { + event.preventDefault() - const usableEndKeys = (event: React.KeyboardEvent): string | null => { - const { code } = event - // No lock keys - // Among the commonly used keys, not including: Escape, NumpadMultiply, NumpadDivide, NumpadSubtract, NumpadAdd, NumpadDecimal - // The react-hotkeys-hook library does not differentiate between `Digit` and `Numpad` - switch (code) { - case 'KeyA': - case 'KeyB': - case 'KeyC': - case 'KeyD': - case 'KeyE': - case 'KeyF': - case 'KeyG': - case 'KeyH': - case 'KeyI': - case 'KeyJ': - case 'KeyK': - case 'KeyL': - case 'KeyM': - case 'KeyN': - case 'KeyO': - case 'KeyP': - case 'KeyQ': - case 'KeyR': - case 'KeyS': - case 'KeyT': - case 'KeyU': - case 'KeyV': - case 'KeyW': - case 'KeyX': - case 'KeyY': - case 'KeyZ': - case 'Digit0': - case 'Digit1': - case 'Digit2': - case 'Digit3': - case 'Digit4': - case 'Digit5': - case 'Digit6': - case 'Digit7': - case 'Digit8': - case 'Digit9': - case 'Numpad0': - case 'Numpad1': - case 'Numpad2': - case 'Numpad3': - case 'Numpad4': - case 'Numpad5': - case 'Numpad6': - case 'Numpad7': - case 'Numpad8': - case 'Numpad9': - return code.slice(-1) - case 'Space': - case 'Enter': - case 'Backspace': - case 'Tab': - case 'Delete': - case 'PageUp': - case 'PageDown': - case 'Insert': - case 'Home': - case 'End': - case 'ArrowUp': - case 'ArrowDown': - case 'ArrowLeft': - case 'ArrowRight': - case 'F1': - case 'F2': - case 'F3': - case 'F4': - case 'F5': - case 'F6': - case 'F7': - case 'F8': - case 'F9': - case 'F10': - case 'F11': - case 'F12': - case 'F13': - case 'F14': - case 'F15': - case 'F16': - case 'F17': - case 'F18': - case 'F19': - return code - case 'Backquote': - return '`' - case 'Period': - return '.' - case 'NumpadEnter': - return 'Enter' - // The react-hotkeys-hook library does not handle the symbol strings for the following keys - case 'Slash': - case 'Semicolon': - case 'BracketLeft': - case 'BracketRight': - case 'Backslash': - case 'Quote': - case 'Comma': - case 'Minus': - case 'Equal': - return code - default: - return null + if (event.code === 'Escape') { + clearEditingState() + return } - } - - const handleKeyDown = (e: React.KeyboardEvent, record: Shortcut) => { - e.preventDefault() const keys: string[] = [] - // OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE - // if (e.ctrlKey) keys.push(isMac ? 'Control' : 'Ctrl') - // if (e.metaKey) keys.push('Command') - // if (e.altKey) keys.push('Alt') - // if (e.shiftKey) keys.push('Shift') - - // NEW WAY FOR MODIFIER KEYS - // for capability across platforms, we transform the modifier keys to the really meaning keys - // mainly consider the habit of users on different platforms - if (e.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl') // for win&linux, ctrl key is almost the same as command key in macOS - if (e.altKey) keys.push('Alt') - if (e.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta') // for macOS, meta(Command) key is almost the same as Ctrl key in win&linux - if (e.shiftKey) keys.push('Shift') - - const endKey = usableEndKeys(e) + if (event.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl') + if (event.altKey) keys.push('Alt') + if (event.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta') + if (event.shiftKey) keys.push('Shift') + + const endKey = usableEndKeys(event.code) if (endKey) { - keys.push(endKey) + keys.push(convertKeyToAccelerator(endKey)) } + // Always show real-time preview of pressed keys + setPendingKeys(keys) + if (!isValidShortcut(keys)) { + // Clear conflict when user is still pressing modifier keys + setConflictLabel(null) return } - if (isDuplicateShortcut(keys, record.key)) { + const duplicate = findDuplicateLabel(keys, record.key) + if (duplicate) { + setConflictLabel(duplicate) + // Clear conflict hint after 2 seconds + clearTimeoutTimer('conflict-clear') + setTimeoutTimer('conflict-clear', () => setConflictLabel(null), 2000) return } - dispatch(updateShortcut({ ...record, shortcut: keys })) - setEditingKey(null) + setConflictLabel(null) + try { + clearSystemConflict(record.key) + await updatePreference(record.key, { binding: keys, enabled: true }) + clearEditingState() + } catch (error) { + handleUpdateFailure(record, error) + } } const handleResetAllShortcuts = () => { window.modal.confirm({ title: t('settings.shortcuts.reset_defaults_confirm'), centered: true, - onOk: () => dispatch(resetShortcuts()) + onOk: async () => { + const updates: Record = getAllShortcutDefaultPreferences() + + try { + clearSystemConflict() + await preferenceService.setMultiple(updates) + } catch (error) { + logger.error('Failed to reset all shortcuts to defaults', error as Error) + window.toast.error(t('settings.shortcuts.reset_defaults_failed')) + } + } }) } - // 由于启用了showHeader = false,不再需要title字段 - const columns: ColumnsType = [ - { - // title: t('settings.shortcuts.action'), - dataIndex: 'name', - key: 'name' - }, - { - // title: t('settings.shortcuts.label'), - dataIndex: 'shortcut', - key: 'shortcut', - align: 'right', - render: (shortcut: string[], record: Shortcut) => { - const isEditing = editingKey === record.key - const shortcutConfig = shortcuts.find((s) => s.key === record.key) - const isEditable = shortcutConfig?.editable !== false - - return ( - - - {isEditing ? ( - { - if (el) { - inputRefs.current[record.key] = el - } - }} - value={formatShortcut(shortcut)} - placeholder={t('settings.shortcuts.press_shortcut')} - onKeyDown={(e) => handleKeyDown(e, record)} - onBlur={(e) => { - const isUndoClick = e.relatedTarget?.closest('.shortcut-undo-icon') - if (!isUndoClick) { - setEditingKey(null) - } + const renderShortcutCell = (record: (typeof shortcuts)[number]) => { + const isEditing = editingKey === record.key + const displayKeys = record.preference.binding + const displayShortcut = displayKeys.length > 0 ? formatShortcutDisplay(displayKeys, isMac) : '' + const isEditable = record.definition.editable !== false + const isBindingModified = !isBindingEqual(displayKeys, record.defaultPreference.binding) + const hasSystemConflict = systemConflictKey === record.key + const conflictMessage = + conflictLabel ?? (hasSystemConflict ? t('settings.shortcuts.occupied_by_other_application') : null) + + if (isEditing) { + const pendingDisplay = pendingKeys.length > 0 ? formatShortcutDisplay(pendingKeys, isMac) : '' + const hasConflict = conflictMessage !== null + + return ( +
+ { + if (el) inputRefs.current[record.key] = el + }} + className={`h-7 w-36 text-center text-xs ${hasConflict ? 'border-red-500 focus-visible:ring-red-500/50' : ''}`} + value={pendingDisplay} + placeholder={t('settings.shortcuts.press_shortcut')} + onKeyDown={(event) => void handleKeyDown(event, record)} + onBlur={(event) => { + const isUndoClick = (event.relatedTarget as HTMLElement)?.closest('.shortcut-undo-icon') + if (!isUndoClick) { + clearEditingState() + } + }} + /> + {hasConflict && ( + + {conflictLabel ? t('settings.shortcuts.conflict_with', { name: conflictLabel }) : conflictMessage} + + )} +
+ ) + } + + if (displayShortcut) { + return ( +
+ + {isBindingModified && ( + + { + void handleResetShortcut(record) }} /> - ) : ( - isEditable && handleAddShortcut(record)}> - {shortcut.length > 0 ? formatShortcut(shortcut) : t('settings.shortcuts.press_shortcut')} - - )} + + )} + isEditable && handleAddShortcut(record.key)}> + {displayKeys.map((key) => ( + + {formatKeyDisplay(key, isMac)} + + ))} - ) - } - }, - { - // title: t('settings.shortcuts.actions'), - key: 'actions', - align: 'right', - width: '70px', - render: (record: Shortcut) => ( - - - - - - - - - ) - }, - { - // title: t('settings.shortcuts.enabled'), - key: 'enabled', - align: 'right', - width: '50px', - render: (record: Shortcut) => ( - dispatch(toggleShortcut(record.key))} /> + {hasSystemConflict && ( + + {conflictMessage} + + )} +
) } - ] + + return ( +
+ isEditable && handleAddShortcut(record.key)}> + {t('settings.shortcuts.press_shortcut')} + + {hasSystemConflict && ( + + {conflictMessage} + + )} +
+ ) + } + + const renderShortcutRow = (record: (typeof shortcuts)[number], isLast: boolean) => { + const switchNode = ( + { + clearSystemConflict(record.key) + updatePreference(record.key, { enabled: !record.preference.enabled }).catch((error) => { + handleUpdateFailure(record, error) + }) + }} + /> + ) + + return ( +
+ {record.label} +
{renderShortcutCell(record)}
+ + {!record.preference.binding.length ? ( + + {switchNode} + + ) : ( + {switchNode} + )} + +
+ ) + } return ( {t('settings.shortcuts.title')} - } - dataSource={shortcuts.map((s) => ({ ...s, name: getShortcutLabel(s.key) }))} - pagination={false} - size="middle" - showHeader={false} - /> +
+ setSearchQuery(e.target.value)} + /> +
+
+ {visibleShortcuts.map((record, index) => renderShortcutRow(record, index === visibleShortcuts.length - 1))} +
@@ -413,30 +366,4 @@ const ShortcutSettings: FC = () => { ) } -const Table = styled(AntTable)` - .ant-table { - background: transparent; - } - - .ant-table-cell { - padding: 14px 0 !important; - background: transparent !important; - } - - .ant-table-tbody > tr:last-child > td { - border-bottom: none; - } -` - -const ShortcutInput = styled(Input)` - width: 120px; - text-align: center; -` - -const ShortcutText = styled.span<{ isEditable: boolean }>` - cursor: ${({ isEditable }) => (isEditable ? 'pointer' : 'not-allowed')}; - padding: 4px 11px; - opacity: ${({ isEditable }) => (isEditable ? 1 : 0.5)}; -` - export default ShortcutSettings diff --git a/src/renderer/src/store/shortcuts.ts b/src/renderer/src/store/shortcuts.ts index fc2009100ba..4063a1d6661 100644 --- a/src/renderer/src/store/shortcuts.ts +++ b/src/renderer/src/store/shortcuts.ts @@ -1,3 +1,4 @@ +// @ts-nocheck /** * @deprecated Scheduled for removal in v2.0.0 * -------------------------------------------------------------------------- diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md new file mode 100644 index 00000000000..99c73d0804a --- /dev/null +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -0,0 +1,302 @@ +# Cherry Studio 快捷键系统重构设计 + +> 版本:v2.0 +> 更新日期:2026-04-14 +> 分支:`refactor/v2/shortcuts` + +## 背景 + +v1 快捷键系统的主要问题有 5 个: + +- 数据源分散在 Redux 和 `configManager` +- 主进程与渲染进程靠手动 IPC 同步 +- 新增快捷键需要改多处 `switch-case` +- 定义分散,缺少统一入口 +- 类型约束弱,运行时容易出现脏数据 + +v2 的目标很明确: + +- 用 `SHORTCUT_DEFINITIONS` 统一描述所有快捷键元数据 +- 用 Preference 存储用户配置,不再维护第二套状态 +- main / renderer 各自按职责注册,但共用同一套定义和工具 +- 通过 `resolveShortcutPreference` 保证读取结果始终完整可用 +- 让新增快捷键尽量收敛为“定义 + 默认值 + 使用”三步 + +## 核心模型 + +系统由 4 层组成: + +1. 定义层:`packages/shared/shortcuts/definitions.ts` +2. 工具层:`packages/shared/shortcuts/utils.ts` +3. 存储层:`packages/shared/data/preference/preferenceSchemas.ts` +4. 消费层:`ShortcutService` 与 `useShortcuts.ts` + +它们各自负责的事情很简单: + +| 层 | 作用 | +| --- | --- | +| 定义层 | 描述快捷键是什么 | +| 工具层 | 做格式转换、校验、默认值和归一化 | +| 存储层 | 保存用户真正可变的配置 | +| 消费层 | 在 main / renderer 中注册并使用快捷键 | + +### 1. 定义层 + +`SHORTCUT_DEFINITIONS` 是快捷键系统的单一真相源。每条定义描述一个快捷键的静态元数据,例如: + +```ts +{ + key: 'shortcut.feature.quick_assistant.toggle_window', + scope: 'main', + category: 'general', + labelKey: 'mini_window', + global: true, + supportedPlatforms: ['darwin', 'win32'] +} +``` + +常用字段: + +| 字段 | 说明 | +| --- | --- | +| `key` | Preference key,格式通常为 `shortcut.{category}.{name}` | +| `scope` | `main`、`renderer` 或 `both` | +| `category` | 设置页分组,如 `general`、`chat`、`topic`、`feature.selection` | +| `labelKey` | i18n 文案 key | +| `editable` | 是否允许用户修改绑定 | +| `global` | 是否在窗口失焦后仍需保留注册 | +| `variants` | 同一快捷键的额外绑定 | +| `supportedPlatforms` | 平台限制 | + +### 2. 存储层 + +快捷键偏好只保存用户真正会改的部分: + +```ts +type PreferenceShortcutType = { + binding: string[] + enabled: boolean +} +``` + +默认值定义在 `preferenceSchemas.ts`。例如: + +```ts +'shortcut.chat.clear': { enabled: true, binding: ['CommandOrControl', 'L'] } +'shortcut.general.show_main_window': { enabled: false, binding: [] } +``` + +这里不存 `editable`、`scope`、`labelKey` 这类静态信息,它们始终从 `SHORTCUT_DEFINITIONS` 读取。 + +### 3. 工具层 + +`utils.ts` 里最重要的是 4 类能力: + +- 格式转换:Electron accelerator 与 renderer hotkey 之间互转 +- 显示格式化:把绑定转成 `⌘L` / `Ctrl+L` +- 合法性校验:限制无效单键 +- 偏好归一化:把任意原始值整理成稳定的 `ResolvedShortcut` + +核心函数是 `resolveShortcutPreference(definition, value)`。它负责: + +- 值缺失时回退到 schema 默认值 +- 保留用户显式清空的 `binding: []` +- 当 `enabled` 字段异常时回退到默认值 + +对调用侧来说,拿到的始终是: + +```ts +type ResolvedShortcut = { + binding: string[] + enabled: boolean +} +``` + +## 运行方式 + +### 主进程 + +`ShortcutService` 负责所有 `scope !== 'renderer'` 的快捷键。 + +它的实现重点只有 3 件事: + +1. 注册内置 handler:用 `Map` 代替 `switch-case` +2. 监听 Preference 变化:配置变更后重算当前应注册的快捷键 +3. 管理窗口生命周期:窗口 focus / blur 时切换“全部快捷键”与“仅全局快捷键” + +当前注册逻辑是增量 diff: + +- 先根据定义、平台、功能开关和偏好算出目标 accelerator 集合 +- 再和已注册集合比较 +- 只卸载或重绑真正变化的项 + +这样可以避免无意义的全量 unregister / register。 + +### 渲染进程 + +`useShortcuts.ts` 提供 3 个 Hook: + +| Hook | 作用 | +| --- | --- | +| `useShortcut` | 注册单个 renderer 快捷键 | +| `useShortcutDisplay` | 返回格式化后的展示文案 | +| `useAllShortcuts` | 为设置页提供完整快捷键列表和统一更新入口 | + +其中 `useAllShortcuts()` 现在会直接处理: + +- 平台过滤 +- 依赖功能开关的过滤 +- label 生成 +- “无绑定不算启用”的展示态收敛 + +设置页只需要继续做搜索、录制、冲突提示和渲染。 + +## 关键规则 + +有几条规则是这个系统稳定运行的关键: + +### 单一真相源 + +快捷键的静态信息只来自 `SHORTCUT_DEFINITIONS`,不要在页面、服务或迁移代码里再维护另一份描述。 + +### Preference 优先 + +运行时状态只从 Preference 读写。不要再引入 Redux、临时配置文件或额外 IPC 作为第二数据源。 + +### 无绑定即不可触发 + +即使某项 `enabled` 为 `true`,只要 `binding` 为空,它也不应被注册,也不应在设置页显示为启用。 + +### 功能关闭时不显示对应快捷键 + +当前有两类依赖功能状态的快捷键: + +- `shortcut.feature.quick_assistant.toggle_window` 依赖 `feature.quick_assistant.enabled` +- `feature.selection.*` 依赖 `feature.selection.enabled` + +功能关闭时,这些快捷键不会注册,也不会在设置页显示。 + +### 平台限制要同时作用于注册与展示 + +`supportedPlatforms` 不只是 UI 过滤条件,也决定快捷键是否会在当前系统上注册。 + +## 默认快捷键 + +下表只保留最常用的信息:key、默认绑定、scope、默认启用状态。 + +### general + +| Key | 默认绑定 | Scope | 默认启用 | +| --- | --- | --- | --- | +| `shortcut.general.show_main_window` | *(无)* | main | 否 | +| `shortcut.feature.quick_assistant.toggle_window` | `Cmd/Ctrl+E` | main | 否 | +| `shortcut.general.show_settings` | `Cmd/Ctrl+,` | main | 是 | +| `shortcut.general.toggle_sidebar` | `Cmd/Ctrl+[` | renderer | 是 | +| `shortcut.general.exit_fullscreen` | `Escape` | renderer | 是 | +| `shortcut.general.zoom_in` | `Cmd/Ctrl+=` | main | 是 | +| `shortcut.general.zoom_out` | `Cmd/Ctrl+-` | main | 是 | +| `shortcut.general.zoom_reset` | `Cmd/Ctrl+0` | main | 是 | +| `shortcut.general.search` | `Cmd/Ctrl+Shift+F` | renderer | 是 | + +### chat + +| Key | 默认绑定 | Scope | 默认启用 | +| --- | --- | --- | --- | +| `shortcut.chat.clear` | `Cmd/Ctrl+L` | renderer | 是 | +| `shortcut.chat.search_message` | `Cmd/Ctrl+F` | renderer | 是 | +| `shortcut.chat.toggle_new_context` | `Cmd/Ctrl+K` | renderer | 是 | +| `shortcut.chat.copy_last_message` | `Cmd/Ctrl+Shift+C` | renderer | 否 | +| `shortcut.chat.edit_last_user_message` | `Cmd/Ctrl+Shift+E` | renderer | 否 | +| `shortcut.chat.select_model` | `Cmd/Ctrl+Shift+M` | renderer | 是 | + +### topic + +| Key | 默认绑定 | Scope | 默认启用 | +| --- | --- | --- | --- | +| `shortcut.topic.new` | `Cmd/Ctrl+N` | renderer | 是 | +| `shortcut.topic.rename` | `Cmd/Ctrl+T` | renderer | 否 | +| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl+]` | renderer | 是 | + +### feature.selection + +| Key | 默认绑定 | Scope | 默认启用 | +| --- | --- | --- | --- | +| `shortcut.feature.selection.toggle_enabled` | *(无)* | main | 否 | +| `shortcut.feature.selection.get_text` | *(无)* | main | 否 | + +## 扩展方式 + +新增一个快捷键,原则上只需要 3 步。 + +### 1. 添加默认值 + +在 `preferenceSchemas.ts` 中增加 schema 默认值: + +```ts +'shortcut.chat.regenerate': { + enabled: true, + binding: ['CommandOrControl', 'Shift', 'R'] +} +``` + +### 2. 添加定义 + +在 `definitions.ts` 中加入静态元数据: + +```ts +{ + key: 'shortcut.chat.regenerate', + scope: 'renderer', + category: 'chat', + labelKey: 'regenerate' +} +``` + +### 3. 在目标位置使用 + +渲染进程: + +```ts +useShortcut('chat.regenerate', () => regenerateLastMessage()) +``` + +主进程: + +```ts +this.handlers.set('shortcut.chat.regenerate', () => { + // ... +}) +``` + +如果是条件型快捷键,不要把条件写进定义层,应该在消费层做过滤或在 handler 内做早返回。 + +## 迁移与测试 + +### 迁移现状 + +- 旧的 Redux `shortcuts` slice 只保留为迁移输入 +- `IpcChannel.Shortcuts_Update`、旧 preload bridge、`configManager` 快捷键接口都已移除 +- 旧数据通过 `PreferenceMigrator` 映射到新的 `shortcut.*` key + +### 当前测试重点 + +现有测试主要覆盖: + +- `utils.ts` 的格式转换、校验和归一化 +- 旧 key 到新 key 的迁移映射 +- `ShortcutService` 的重注册行为 + +后续如果继续扩展,优先补下面两类测试: + +- 设置页的录制、冲突和显示逻辑 +- 主进程全局快捷键的端到端行为 + +## 总结 + +这套重构的核心不是“把快捷键做复杂”,而是把复杂度收拢到共享定义、统一归一化和清晰分层里。 + +对日常开发来说,只需要记住 3 件事: + +1. 静态信息看 `SHORTCUT_DEFINITIONS` +2. 用户配置看 Preference +3. main / renderer 按各自职责消费同一套定义 diff --git a/v2-refactor-temp/tools/data-classify/data/classification.json b/v2-refactor-temp/tools/data-classify/data/classification.json index 18d5ed2a7d2..4ff98f32dff 100644 --- a/v2-refactor-temp/tools/data-classify/data/classification.json +++ b/v2-refactor-temp/tools/data-classify/data/classification.json @@ -1,10 +1,10 @@ { "metadata": { "version": "2.0.0", - "lastUpdated": "2026-03-23T08:30:00.000Z", - "totalItems": 406, - "classified": 278, - "pending": 122, + "lastUpdated": "2026-04-07T11:48:08.913Z", + "totalItems": 407, + "classified": 300, + "pending": 101, "deleted": 0 }, "classifications": { @@ -653,6 +653,14 @@ "category": null, "targetKey": null }, + { + "originalKey": "apiServerRunning", + "type": "boolean", + "defaultValue": false, + "status": "pending", + "category": null, + "targetKey": null + }, { "originalKey": "placeHolder", "type": "string", @@ -2469,236 +2477,276 @@ { "category": "preferences", "defaultValue": { - "editable": false, - "enabled": true, - "key": ["CommandOrControl", "="], - "system": true + "binding": [ + "CommandOrControl", + "=" + ], + "enabled": true }, "originalKey": "zoom_in", "status": "classified", - "targetKey": "shortcut.app.zoom_in", - "type": "object" + "targetKey": "shortcut.general.zoom_in", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": false, - "enabled": true, - "key": ["CommandOrControl", "-"], - "system": true + "binding": [ + "CommandOrControl", + "-" + ], + "enabled": true }, "originalKey": "zoom_out", "status": "classified", - "targetKey": "shortcut.app.zoom_out", - "type": "object" + "targetKey": "shortcut.general.zoom_out", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": false, - "enabled": true, - "key": ["CommandOrControl", "0"], - "system": true + "binding": [ + "CommandOrControl", + "0" + ], + "enabled": true }, "originalKey": "zoom_reset", "status": "classified", - "targetKey": "shortcut.app.zoom_reset", - "type": "object" + "targetKey": "shortcut.general.zoom_reset", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": false, - "enabled": true, - "key": ["CommandOrControl", ","], - "system": true + "binding": [ + "CommandOrControl", + "," + ], + "enabled": true }, "originalKey": "show_settings", "status": "classified", - "targetKey": "shortcut.app.show_settings", - "type": "object" + "targetKey": "shortcut.general.show_settings", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": true, - "key": [], - "system": true + "binding": [], + "enabled": false }, "originalKey": "show_app", "status": "classified", - "targetKey": "shortcut.app.show_main_window", - "type": "object" + "targetKey": "shortcut.general.show_main_window", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": false, - "key": ["CommandOrControl", "E"], - "system": true + "binding": [ + "CommandOrControl", + "E" + ], + "enabled": false }, "originalKey": "mini_window", "status": "classified", - "targetKey": "shortcut.app.show_mini_window", - "type": "object" + "targetKey": "shortcut.feature.quick_assistant.toggle_window", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": false, - "key": [], - "system": true + "binding": [], + "enabled": false }, "originalKey": "selection_assistant_toggle", "status": "classified", - "targetKey": "shortcut.selection.toggle_enabled", - "type": "object" + "targetKey": "shortcut.feature.selection.toggle_enabled", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": false, - "key": [], - "system": true + "binding": [], + "enabled": false }, "originalKey": "selection_assistant_select_text", "status": "classified", - "targetKey": "shortcut.selection.get_text", - "type": "object" + "targetKey": "shortcut.feature.selection.get_text", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": true, - "key": ["CommandOrControl", "N"], - "system": false + "binding": [ + "CommandOrControl", + "N" + ], + "enabled": true }, "originalKey": "new_topic", "status": "classified", "targetKey": "shortcut.topic.new", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { - "category": null, + "category": "preferences", "defaultValue": { - "editable": true, - "enabled": false, - "key": ["CommandOrControl", "T"], - "system": false + "binding": [ + "CommandOrControl", + "T" + ], + "enabled": false }, "originalKey": "rename_topic", - "status": "pending", - "targetKey": null, - "type": "object" + "status": "classified", + "targetKey": "shortcut.topic.rename", + "type": "PreferenceTypes.PreferenceShortcutType" + }, + { + "category": "preferences", + "defaultValue": { + "binding": [ + "CommandOrControl", + "]" + ], + "enabled": true + }, + "originalKey": "toggle_show_topics", + "status": "classified", + "targetKey": "shortcut.topic.toggle_show_topics", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": true, - "key": ["CommandOrControl", "["], - "system": false + "binding": [ + "CommandOrControl", + "[" + ], + "enabled": true }, "originalKey": "toggle_show_assistants", "status": "classified", - "targetKey": "shortcut.app.toggle_show_assistants", - "type": "object" + "targetKey": "shortcut.general.toggle_sidebar", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": false, - "key": ["CommandOrControl", "Shift", "C"], - "system": false + "binding": [ + "CommandOrControl", + "Shift", + "C" + ], + "enabled": false }, "originalKey": "copy_last_message", "status": "classified", "targetKey": "shortcut.chat.copy_last_message", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { - "category": null, + "category": "preferences", "defaultValue": { - "editable": true, - "enabled": false, - "key": ["CommandOrControl", "Shift", "E"], - "system": false + "binding": [ + "CommandOrControl", + "Shift", + "E" + ], + "enabled": false }, "originalKey": "edit_last_user_message", - "status": "pending", - "targetKey": null, - "type": "object" + "status": "classified", + "targetKey": "shortcut.chat.edit_last_user_message", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": true, - "key": ["CommandOrControl", "F"], - "system": false + "binding": [ + "CommandOrControl", + "F" + ], + "enabled": true }, "originalKey": "search_message_in_chat", "status": "classified", "targetKey": "shortcut.chat.search_message", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": true, - "key": ["CommandOrControl", "Shift", "F"], - "system": false + "binding": [ + "CommandOrControl", + "Shift", + "F" + ], + "enabled": true }, "originalKey": "search_message", "status": "classified", - "targetKey": "shortcut.app.search_message", - "type": "object" + "targetKey": "shortcut.general.search", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": true, - "key": ["CommandOrControl", "L"], - "system": false + "binding": [ + "CommandOrControl", + "L" + ], + "enabled": true }, "originalKey": "clear_topic", "status": "classified", "targetKey": "shortcut.chat.clear", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, - "enabled": true, - "key": ["CommandOrControl", "K"], - "system": false + "binding": [ + "CommandOrControl", + "K" + ], + "enabled": true }, "originalKey": "toggle_new_context", "status": "classified", "targetKey": "shortcut.chat.toggle_new_context", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" + }, + { + "category": "preferences", + "defaultValue": { + "binding": [ + "CommandOrControl", + "Shift", + "M" + ], + "enabled": true + }, + "originalKey": "select_model", + "status": "classified", + "targetKey": "shortcut.chat.select_model", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": false, - "enabled": true, - "key": ["Escape"], - "system": true + "binding": [ + "Escape" + ], + "enabled": true }, "originalKey": "exit_fullscreen", "status": "classified", - "targetKey": "shortcut.app.exit_fullscreen", - "type": "object" + "targetKey": "shortcut.general.exit_fullscreen", + "type": "PreferenceTypes.PreferenceShortcutType" } ] } @@ -2759,6 +2807,10 @@ { "originalKey": "settings", "type": "object", + "defaultValue": null, + "status": "pending", + "category": null, + "targetKey": null, "children": [ { "originalKey": "autoCopy", @@ -2769,6 +2821,14 @@ "targetKey": "feature.translate.page.auto_copy" } ] + }, + { + "originalKey": "autoCopy", + "type": "boolean", + "defaultValue": false, + "status": "classified", + "category": "preferences", + "targetKey": "feature.translate.page.auto_copy" } ], "websearch": [ @@ -2852,8 +2912,7 @@ "defaultValue": "codeTools.qwenCode", "status": "classified", "category": "preferences", - "targetKey": null, - "note": "complex mapping: determines which tool gets enabled: true" + "targetKey": null }, { "originalKey": "selectedModels", @@ -2862,7 +2921,6 @@ "status": "classified", "category": "preferences", "targetKey": null, - "note": "complex mapping: Model objects → modelId strings per tool", "children": [ { "category": "preferences", @@ -2912,7 +2970,6 @@ "status": "classified", "category": "preferences", "targetKey": null, - "note": "complex mapping: per-tool env vars → overrides.envVars", "children": [ { "category": "preferences", @@ -2946,8 +3003,7 @@ "defaultValue": [], "status": "classified", "category": "preferences", - "targetKey": null, - "note": "complex mapping: global dirs → assigned to selected tool override" + "targetKey": null }, { "originalKey": "currentDirectory", @@ -2955,8 +3011,7 @@ "defaultValue": "", "status": "classified", "category": "preferences", - "targetKey": null, - "note": "complex mapping: global currentDir → assigned to selected tool override" + "targetKey": null }, { "originalKey": "selectedTerminal", @@ -2964,8 +3019,7 @@ "defaultValue": null, "status": "classified", "category": "preferences", - "targetKey": null, - "note": "complex mapping: global terminal → assigned to selected tool override (non-default only)" + "targetKey": null } ], "ocr": [ @@ -3172,16 +3226,6 @@ "targetKey": "app.zoom_factor" } ], - "Shortcuts": [ - { - "originalKey": "Shortcuts", - "type": "unknown", - "defaultValue": null, - "status": "pending", - "category": "preferences", - "targetKey": null - } - ], "ClickTrayToShowQuickAssistant": [ { "originalKey": "ClickTrayToShowQuickAssistant", @@ -3374,9 +3418,19 @@ ] }, "localStorage": { - "memory_currentUserId": [ + "persist:cherry-studio": [ { - "originalKey": "memory_currentUserId", + "originalKey": "persist:cherry-studio", + "type": "string", + "defaultValue": "data.localStorage['persist:cherry-studio']", + "status": "pending", + "category": null, + "targetKey": null + } + ], + "onboarding-completed": [ + { + "originalKey": "onboarding-completed", "type": "string", "defaultValue": null, "status": "pending", @@ -3384,11 +3438,11 @@ "targetKey": null } ], - "persist:cherry-studio": [ + "memory_currentUserId": [ { - "originalKey": "persist:cherry-studio", + "originalKey": "memory_currentUserId", "type": "string", - "defaultValue": "data.localStorage['persist:cherry-studio']", + "defaultValue": null, "status": "pending", "category": null, "targetKey": null @@ -3440,7 +3494,10 @@ { "originalKey": "translate:bidirectional:pair", "type": "PreferenceTypes.TranslateBidirectionalPair", - "defaultValue": ["zh-cn", "en-us"], + "defaultValue": [ + "zh-cn", + "en-us" + ], "status": "classified", "category": "preferences", "targetKey": "feature.translate.page.bidirectional_pair" @@ -3527,9 +3584,7 @@ "defaultValue": null, "status": "classified", "category": "user_data", - "targetTable": "translate_history", - "targetKey": null, - "notes": "Migrated via TranslateMigrator to SQLite translate_history table" + "targetKey": null } ], "quick_phrases": [ @@ -3559,9 +3614,7 @@ "defaultValue": null, "status": "classified", "category": "user_data", - "targetTable": "translate_language", - "targetKey": null, - "notes": "Migrated via TranslateMigrator to SQLite translate_language table" + "targetKey": null } ] }