From 76f4f64a934ae01fcb25b460963e7363c9b2e14f Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 7 Apr 2026 11:04:29 +0800 Subject: [PATCH 01/37] refactor: migrate shortcut management to PreferenceService - Removed serialization logic from shortcuts slice as shortcuts are now managed via PreferenceService. - Updated comments to reflect the migration and the purpose of the shortcuts slice. - Added a new design document detailing the refactor of the shortcut system, including architecture, data flow, and extension guidelines. --- packages/shared/IpcChannel.ts | 2 - .../shared/__tests__/shortcutUtils.test.ts | 176 ++++++ .../data/preference/preferenceSchemas.ts | 108 ++-- .../shared/data/preference/preferenceTypes.ts | 2 - packages/shared/shortcuts/definitions.ts | 155 +++++ packages/shared/shortcuts/types.ts | 35 ++ packages/shared/shortcuts/utils.ts | 137 +++++ .../migrators/mappings/PreferencesMappings.ts | 20 +- src/main/services/ConfigManager.ts | 14 - src/main/services/ShortcutService.ts | 431 +++++--------- src/preload/index.ts | 4 - src/renderer/src/components/TopView/index.tsx | 7 +- .../src/handler/NavigationHandler.tsx | 16 +- src/renderer/src/hooks/useShortcuts.ts | 226 ++++--- src/renderer/src/i18n/locales/en-us.json | 2 + src/renderer/src/i18n/locales/zh-cn.json | 2 + src/renderer/src/i18n/locales/zh-tw.json | 2 + src/renderer/src/i18n/translate/de-de.json | 4 +- src/renderer/src/i18n/translate/el-gr.json | 4 +- src/renderer/src/i18n/translate/es-es.json | 4 +- src/renderer/src/i18n/translate/fr-fr.json | 4 +- src/renderer/src/i18n/translate/ja-jp.json | 4 +- src/renderer/src/i18n/translate/pt-pt.json | 4 +- src/renderer/src/i18n/translate/ro-ro.json | 5 + src/renderer/src/i18n/translate/ru-ru.json | 4 +- src/renderer/src/pages/agents/AgentChat.tsx | 2 +- src/renderer/src/pages/agents/AgentNavbar.tsx | 2 +- src/renderer/src/pages/agents/AgentPage.tsx | 4 +- .../components/AgentChatNavbar/index.tsx | 2 +- src/renderer/src/pages/home/Chat.tsx | 6 +- src/renderer/src/pages/home/HomePage.tsx | 4 +- .../src/pages/home/Inputbar/Inputbar.tsx | 4 +- .../home/Inputbar/tools/clearTopicTool.tsx | 2 +- .../tools/components/NewContextButton.tsx | 4 +- .../home/Inputbar/tools/createSessionTool.tsx | 2 +- .../home/Inputbar/tools/newTopicTool.tsx | 2 +- .../src/pages/home/Messages/Messages.tsx | 4 +- src/renderer/src/pages/home/Navbar.tsx | 2 +- .../home/components/ChatNavBar/index.tsx | 2 +- .../src/pages/knowledge/KnowledgePage.tsx | 2 +- .../src/pages/settings/ShortcutSettings.tsx | 448 ++++++++------ src/renderer/src/store/migrate.ts | 399 ++++++------- src/renderer/src/store/shortcuts.ts | 16 +- .../shortcuts/shortcut-system-refactor.md | 553 ++++++++++++++++++ 44 files changed, 1952 insertions(+), 880 deletions(-) create mode 100644 packages/shared/__tests__/shortcutUtils.test.ts create mode 100644 packages/shared/shortcuts/definitions.ts create mode 100644 packages/shared/shortcuts/types.ts create mode 100644 packages/shared/shortcuts/utils.ts create mode 100644 v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 564fa1638cf..6fc78402c90 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -249,8 +249,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/__tests__/shortcutUtils.test.ts b/packages/shared/__tests__/shortcutUtils.test.ts new file mode 100644 index 00000000000..47bf969eb77 --- /dev/null +++ b/packages/shared/__tests__/shortcutUtils.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' + +import type { ShortcutDefinition } from '../shortcuts/types' +import { + coerceShortcutPreference, + convertAcceleratorToHotkey, + convertKeyToAccelerator, + formatShortcutDisplay, + getDefaultShortcutPreference, + isValidShortcut +} from '../shortcuts/utils' + +const makeDefinition = (overrides: Partial = {}): ShortcutDefinition => ({ + key: 'shortcut.chat.clear', + defaultKey: ['CommandOrControl', 'L'], + scope: 'renderer', + category: 'chat', + ...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('getDefaultShortcutPreference', () => { + it('returns default preference from schema defaults', () => { + const def = makeDefinition() + const result = getDefaultShortcutPreference(def) + + expect(result.binding).toEqual(['CommandOrControl', 'L']) + expect(result.hasCustomBinding).toBe(false) + expect(result.enabled).toBe(true) + expect(result.editable).toBe(true) + expect(result.system).toBe(false) + }) + + it('respects editable: false', () => { + const def = makeDefinition({ editable: false }) + expect(getDefaultShortcutPreference(def).editable).toBe(false) + }) + + it('respects system: true', () => { + const def = makeDefinition({ system: true }) + expect(getDefaultShortcutPreference(def).system).toBe(true) + }) +}) + +describe('coerceShortcutPreference', () => { + it('returns fallback when value is undefined', () => { + const def = makeDefinition() + const result = coerceShortcutPreference(def, undefined) + + expect(result.binding).toEqual(['CommandOrControl', 'L']) + expect(result.hasCustomBinding).toBe(false) + expect(result.enabled).toBe(true) + }) + + it('returns fallback when value is null', () => { + const def = makeDefinition() + const result = coerceShortcutPreference(def, null) + + expect(result.binding).toEqual(['CommandOrControl', 'L']) + expect(result.hasCustomBinding).toBe(false) + }) + + it('uses custom key when provided', () => { + const def = makeDefinition() + const result = coerceShortcutPreference(def, { + key: ['Alt', 'L'], + enabled: true + }) + + expect(result.binding).toEqual(['Alt', 'L']) + expect(result.rawBinding).toEqual(['Alt', 'L']) + expect(result.hasCustomBinding).toBe(true) + }) + + it('respects user-cleared binding (empty array)', () => { + const def = makeDefinition() + const result = coerceShortcutPreference(def, { + key: [], + enabled: true + }) + + expect(result.binding).toEqual([]) + expect(result.rawBinding).toEqual([]) + expect(result.hasCustomBinding).toBe(true) + }) + + it('respects enabled: false from preference', () => { + const def = makeDefinition() + const result = coerceShortcutPreference(def, { + key: ['CommandOrControl', 'L'], + enabled: false + }) + + expect(result.enabled).toBe(false) + }) +}) diff --git a/packages/shared/data/preference/preferenceSchemas.ts b/packages/shared/data/preference/preferenceSchemas.ts index 5e0d4d1d954..64e1ceabb2a 100644 --- a/packages/shared/data/preference/preferenceSchemas.ts +++ b/packages/shared/data/preference/preferenceSchemas.ts @@ -424,38 +424,26 @@ 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.exit_fullscreen - 'shortcut.app.exit_fullscreen': Record - // redux/shortcuts/shortcuts.search_message - 'shortcut.app.search_message': Record - // redux/shortcuts/shortcuts.show_app - 'shortcut.app.show_main_window': Record - // redux/shortcuts/shortcuts.mini_window - 'shortcut.app.show_mini_window': Record - // redux/shortcuts/shortcuts.show_settings - 'shortcut.app.show_settings': Record - // redux/shortcuts/shortcuts.toggle_show_assistants - 'shortcut.app.toggle_show_assistants': Record - // redux/shortcuts/shortcuts.zoom_in - 'shortcut.app.zoom_in': Record - // redux/shortcuts/shortcuts.zoom_out - 'shortcut.app.zoom_out': Record - // 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 - // redux/shortcuts/shortcuts.new_topic - 'shortcut.topic.new': Record + 'shortcut.app.exit_fullscreen': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.search_message': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.show_main_window': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.show_mini_window': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.show_settings': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.toggle_show_assistants': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.zoom_in': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.zoom_out': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.zoom_reset': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.clear': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.copy_last_message': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.edit_last_user_message': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.search_message': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.select_model': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.toggle_new_context': PreferenceTypes.PreferenceShortcutType + 'shortcut.selection.get_text': PreferenceTypes.PreferenceShortcutType + 'shortcut.selection.toggle_enabled': PreferenceTypes.PreferenceShortcutType + 'shortcut.topic.new': PreferenceTypes.PreferenceShortcutType + 'shortcut.topic.rename': PreferenceTypes.PreferenceShortcutType + 'shortcut.topic.toggle_show_topics': PreferenceTypes.PreferenceShortcutType // redux/settings/enableTopicNaming 'topic.naming.enabled': boolean // redux/settings/topicNamingPrompt @@ -713,42 +701,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.app.exit_fullscreen': { enabled: true, key: ['Escape'] }, + 'shortcut.app.search_message': { enabled: true, key: ['CommandOrControl', 'Shift', 'F'] }, + 'shortcut.app.show_main_window': { enabled: true, key: [] }, + 'shortcut.app.show_mini_window': { enabled: false, key: ['CommandOrControl', 'E'] }, + 'shortcut.app.show_settings': { enabled: true, key: ['CommandOrControl', ','] }, + 'shortcut.app.toggle_show_assistants': { enabled: true, key: ['CommandOrControl', '['] }, + 'shortcut.app.zoom_in': { enabled: true, key: ['CommandOrControl', '='] }, + 'shortcut.app.zoom_out': { enabled: true, key: ['CommandOrControl', '-'] }, + 'shortcut.app.zoom_reset': { enabled: true, key: ['CommandOrControl', '0'] }, + 'shortcut.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, + 'shortcut.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, + 'shortcut.chat.edit_last_user_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'E'] }, + 'shortcut.chat.search_message': { enabled: true, key: ['CommandOrControl', 'F'] }, + 'shortcut.chat.select_model': { enabled: true, key: ['CommandOrControl', 'Shift', 'M'] }, + 'shortcut.chat.toggle_new_context': { enabled: true, key: ['CommandOrControl', 'K'] }, + 'shortcut.selection.get_text': { enabled: false, key: [] }, + 'shortcut.selection.toggle_enabled': { enabled: false, key: [] }, + 'shortcut.topic.new': { enabled: true, key: ['CommandOrControl', 'N'] }, + 'shortcut.topic.rename': { enabled: true, key: ['CommandOrControl', 'T'] }, + 'shortcut.topic.toggle_show_topics': { enabled: true, key: ['CommandOrControl', ']'] }, 'topic.naming.enabled': true, 'topic.naming_prompt': '', 'topic.position': 'left', diff --git a/packages/shared/data/preference/preferenceTypes.ts b/packages/shared/data/preference/preferenceTypes.ts index 139d0533760..ede1d0d0a67 100644 --- a/packages/shared/data/preference/preferenceTypes.ts +++ b/packages/shared/data/preference/preferenceTypes.ts @@ -23,9 +23,7 @@ export type PreferenceUpdateOptions = { export type PreferenceShortcutType = { key: string[] - editable: boolean enabled: boolean - system: boolean } export enum SelectionTriggerMode { diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts new file mode 100644 index 00000000000..b0e1d5309b8 --- /dev/null +++ b/packages/shared/shortcuts/definitions.ts @@ -0,0 +1,155 @@ +import type { ShortcutDefinition } from './types' + +export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ + // ==================== 应用级快捷键 ==================== + { + key: 'shortcut.app.show_main_window', + defaultKey: [], + scope: 'main', + category: 'app', + system: true, + persistOnBlur: true + }, + { + key: 'shortcut.app.show_mini_window', + defaultKey: ['CommandOrControl', 'E'], + scope: 'main', + category: 'selection', + system: true, + persistOnBlur: true, + enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled') + }, + { + key: 'shortcut.app.show_settings', + defaultKey: ['CommandOrControl', ','], + scope: 'both', + category: 'app', + editable: false, + system: true + }, + { + key: 'shortcut.app.toggle_show_assistants', + defaultKey: ['CommandOrControl', '['], + scope: 'renderer', + category: 'app' + }, + { + key: 'shortcut.app.exit_fullscreen', + defaultKey: ['Escape'], + scope: 'renderer', + category: 'app', + editable: false, + system: true + }, + { + key: 'shortcut.app.zoom_in', + defaultKey: ['CommandOrControl', '='], + scope: 'main', + category: 'app', + editable: false, + system: true, + variants: [['CommandOrControl', 'numadd']] + }, + { + key: 'shortcut.app.zoom_out', + defaultKey: ['CommandOrControl', '-'], + scope: 'main', + category: 'app', + editable: false, + system: true, + variants: [['CommandOrControl', 'numsub']] + }, + { + key: 'shortcut.app.zoom_reset', + defaultKey: ['CommandOrControl', '0'], + scope: 'main', + category: 'app', + editable: false, + system: true + }, + { + key: 'shortcut.app.search_message', + defaultKey: ['CommandOrControl', 'Shift', 'F'], + scope: 'renderer', + category: 'topic' + }, + // ==================== 聊天相关快捷键 ==================== + { + key: 'shortcut.chat.clear', + defaultKey: ['CommandOrControl', 'L'], + scope: 'renderer', + category: 'chat' + }, + { + key: 'shortcut.chat.search_message', + defaultKey: ['CommandOrControl', 'F'], + scope: 'renderer', + category: 'chat' + }, + { + key: 'shortcut.chat.toggle_new_context', + defaultKey: ['CommandOrControl', 'K'], + scope: 'renderer', + category: 'chat' + }, + { + key: 'shortcut.chat.copy_last_message', + defaultKey: ['CommandOrControl', 'Shift', 'C'], + scope: 'renderer', + category: 'chat' + }, + { + key: 'shortcut.chat.edit_last_user_message', + defaultKey: ['CommandOrControl', 'Shift', 'E'], + scope: 'renderer', + category: 'chat' + }, + { + key: 'shortcut.chat.select_model', + defaultKey: ['CommandOrControl', 'Shift', 'M'], + scope: 'renderer', + category: 'chat' + }, + // ==================== 话题管理快捷键 ==================== + { + key: 'shortcut.topic.new', + defaultKey: ['CommandOrControl', 'N'], + scope: 'renderer', + category: 'topic' + }, + { + key: 'shortcut.topic.rename', + defaultKey: ['CommandOrControl', 'T'], + scope: 'renderer', + category: 'topic' + }, + { + key: 'shortcut.topic.toggle_show_topics', + defaultKey: ['CommandOrControl', ']'], + scope: 'renderer', + category: 'topic' + }, + // ==================== 划词助手快捷键 ==================== + { + key: 'shortcut.selection.toggle_enabled', + defaultKey: [], + scope: 'main', + category: 'selection', + system: true, + persistOnBlur: true, + supportedPlatforms: ['darwin', 'win32'] + }, + { + key: 'shortcut.selection.get_text', + defaultKey: [], + scope: 'main', + category: 'selection', + system: true, + persistOnBlur: true, + supportedPlatforms: ['darwin', 'win32'] + } +] as const + +export const findShortcutDefinition = (key: string): ShortcutDefinition | undefined => { + return SHORTCUT_DEFINITIONS.find((definition) => definition.key === key) +} diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts new file mode 100644 index 00000000000..ca8550b03f2 --- /dev/null +++ b/packages/shared/shortcuts/types.ts @@ -0,0 +1,35 @@ +import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/preference/preferenceTypes' + +export type ShortcutScope = 'main' | 'renderer' | 'both' + +export type ShortcutCategory = 'app' | 'chat' | 'topic' | 'selection' + +export type ShortcutPreferenceKey = Extract + +export type ShortcutKey = ShortcutPreferenceKey extends `shortcut.${infer Rest}` ? Rest : never + +export type GetPreferenceFn = (key: K) => PreferenceDefaultScopeType[K] + +export type ShortcutEnabledPredicate = (getPreference: GetPreferenceFn) => boolean + +export interface ShortcutDefinition { + key: ShortcutPreferenceKey + defaultKey: string[] + scope: ShortcutScope + category: ShortcutCategory + editable?: boolean + system?: boolean + persistOnBlur?: boolean + variants?: string[][] + enabledWhen?: ShortcutEnabledPredicate + supportedPlatforms?: NodeJS.Platform[] +} + +export interface ShortcutPreferenceValue { + binding: string[] + rawBinding: string[] + hasCustomBinding: boolean + enabled: boolean + editable: boolean + system: boolean +} diff --git a/packages/shared/shortcuts/utils.ts b/packages/shared/shortcuts/utils.ts new file mode 100644 index 00000000000..1d51909b5a2 --- /dev/null +++ b/packages/shared/shortcuts/utils.ts @@ -0,0 +1,137 @@ +import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas' +import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' + +import type { ShortcutDefinition, ShortcutPreferenceValue } 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 formatShortcutDisplay = (keys: string[], isMac: boolean): string => { + return keys + .map((key) => { + 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() + } + }) + .join(isMac ? '' : '+') +} + +export const isValidShortcut = (keys: string[]): boolean => { + if (!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 getDefaultShortcutPreference = (definition: ShortcutDefinition): ShortcutPreferenceValue => { + const fallback = DefaultPreferences.default[definition.key] + + const rawBinding = ensureArray(fallback?.key) + const binding = rawBinding.length ? rawBinding : definition.defaultKey + + return { + binding, + rawBinding: binding, + hasCustomBinding: false, + enabled: ensureBoolean(fallback?.enabled, true), + editable: definition.editable !== false, + system: definition.system === true + } +} + +export const coerceShortcutPreference = ( + definition: ShortcutDefinition, + value?: PreferenceShortcutType | null +): ShortcutPreferenceValue => { + const fallback = getDefaultShortcutPreference(definition) + const hasCustomBinding = Array.isArray((value as PreferenceShortcutType | undefined)?.key) + const rawBinding = hasCustomBinding ? ensureArray((value as PreferenceShortcutType).key) : fallback.binding + // When user explicitly cleared the binding (hasCustomBinding + empty array), respect it — don't fallback + const binding = hasCustomBinding ? rawBinding : rawBinding.length > 0 ? rawBinding : fallback.binding + + return { + binding, + rawBinding, + hasCustomBinding, + enabled: ensureBoolean(value?.enabled, fallback.enabled), + editable: definition.editable !== false, + system: definition.system === true + } +} diff --git a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts index ecfe778e82b..6a34a3da5c2 100644 --- a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts @@ -784,14 +784,26 @@ export const REDUX_STORE_MAPPINGS = { originalKey: 'shortcuts.new_topic', targetKey: 'shortcut.topic.new' }, + { + originalKey: 'shortcuts.rename_topic', + targetKey: 'shortcut.topic.rename' + }, { originalKey: 'shortcuts.toggle_show_assistants', targetKey: 'shortcut.app.toggle_show_assistants' }, + { + originalKey: 'shortcuts.toggle_show_topics', + targetKey: 'shortcut.topic.toggle_show_topics' + }, { 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' @@ -808,6 +820,10 @@ 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' @@ -937,11 +953,11 @@ export const LOCALSTORAGE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetK /** * 映射统计: * - ElectronStore项: 1 - * - Redux Store项: 208 + * - Redux Store项: 212 * - Redux分类: settings, selectionStore, memory, nutstore, preprocess, shortcuts, translate, websearch, ocr, note * - DexieSettings项: 7 * - localStorage项: 0 - * - 总配置项: 216 + * - 总配置项: 220 * * 使用说明: * 1. ElectronStore读取: configManager.get(mapping.originalKey) diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 9a5d0395d9d..74808ea9c85 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -14,8 +14,6 @@ * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 * -------------------------------------------------------------------------- */ -import { ZOOM_SHORTCUTS } from '@shared/config/constant' -import type { Shortcut } from '@types' import Store from 'electron-store' export enum ConfigKeys { @@ -25,7 +23,6 @@ export enum ConfigKeys { Tray = 'tray', TrayOnClose = 'trayOnClose', ZoomFactor = 'ZoomFactor', - Shortcuts = 'shortcuts', ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant', EnableQuickAssistant = 'enableQuickAssistant', AutoUpdate = 'autoUpdate', @@ -128,17 +125,6 @@ export class ConfigManager { } } - getShortcuts() { - return this.get(ConfigKeys.Shortcuts, ZOOM_SHORTCUTS) as Shortcut[] | [] - } - - setShortcuts(shortcuts: Shortcut[]) { - this.setAndNotify( - ConfigKeys.Shortcuts, - shortcuts.filter((shortcut) => shortcut.system) - ) - } - // getClickTrayToShowQuickAssistant(): boolean { // return this.get(ConfigKeys.ClickTrayToShowQuickAssistant, false) // } diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 154309a5f3f..fc7dae3a48b 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,348 +1,233 @@ -/** - * @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 { loggerService } from '@logger' import { application } from '@main/core/application' import { BaseService, DependsOn, Injectable, Phase, ServicePhase } from '@main/core/lifecycle' import { handleZoomFactor } from '@main/utils/zoom' -import { IpcChannel } from '@shared/IpcChannel' -import type { Shortcut } from '@types' +import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' +import { SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions' +import type { ShortcutPreferenceKey } from '@shared/shortcuts/types' +import { coerceShortcutPreference } 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 + +const toAccelerator = (keys: string[]): string => keys.join('+') + +const relevantDefinitions = SHORTCUT_DEFINITIONS.filter( + (d) => d.scope !== 'renderer' && (!d.supportedPlatforms || d.supportedPlatforms.includes(process.platform)) +) + @Injectable('ShortcutService') @ServicePhase(Phase.WhenReady) @DependsOn(['WindowService', 'SelectionService', 'PreferenceService']) 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 windowOnHandlers = new Map void; onBlur: () => void }>() private isRegisterOnBoot = true - // store the focus and blur handlers for each window to unregister them later - private windowOnHandlers = new Map void; onBlurHandler: () => void }>() + private preferenceUnsubscribers: Array<() => void> = [] + private registeredAccelerators = new Set() 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.unregisterAll() + this.preferenceUnsubscribers.forEach((unsub) => unsub()) + this.preferenceUnsubscribers = [] this.mainWindow = null } - 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.app.show_main_window', () => { + application.get('WindowService').toggleMainWindow() + }) + + this.handlers.set('shortcut.app.show_settings', () => { + let targetWindow = application.get('WindowService').getMainWindow() + + if ( + !targetWindow || + targetWindow.isDestroyed() || + targetWindow.isMinimized() || + !targetWindow.isVisible() || + !targetWindow.isFocused() + ) { + application.get('WindowService').showMainWindow() + targetWindow = application.get('WindowService').getMainWindow() } + + if (!targetWindow || targetWindow.isDestroyed()) return + + void targetWindow.webContents + .executeJavaScript(`typeof window.navigate === 'function' && window.navigate('/settings/provider')`, true) + .catch((error) => { + logger.warn('Failed to navigate to settings from shortcut:', error as Error) + }) + }) + + this.handlers.set('shortcut.app.show_mini_window', () => { + application.get('WindowService').toggleMiniWindow() + }) + + this.handlers.set('shortcut.app.zoom_in', (window) => { + if (window) handleZoomFactor([window], 0.1) + }) + + this.handlers.set('shortcut.app.zoom_out', (window) => { + if (window) handleZoomFactor([window], -0.1) + }) + + this.handlers.set('shortcut.app.zoom_reset', (window) => { + if (window) handleZoomFactor([window], 0, true) + }) + + this.handlers.set('shortcut.selection.toggle_enabled', () => { + application.get('SelectionService').toggleEnabled() + }) + + this.handlers.set('shortcut.selection.get_text', () => { + application.get('SelectionService').processSelectTextByShortcut() }) } - private registerShortcutsForWindow(window: BrowserWindow) { + private subscribeToPreferenceChanges(): void { + const preferenceService = application.get('PreferenceService') + this.preferenceUnsubscribers = relevantDefinitions.map((definition) => + preferenceService.subscribeChange(definition.key, () => { + logger.debug(`Shortcut preference changed: ${definition.key}`) + this.reregisterShortcuts() + }) + ) + } + + private registerForWindow(window: BrowserWindow): void { this.mainWindow = window if (this.isRegisterOnBoot) { window.once('ready-to-show', () => { if (application.get('PreferenceService').get('app.tray.on_launch')) { - registerOnlyUniversalShortcuts() + this.registerShortcuts(window, true) } }) this.isRegisterOnBoot = false } - //only for clearer code - const registerOnlyUniversalShortcuts = () => { - register(true) + if (undefined === this.windowOnHandlers.get(window)) { + const onFocus = () => this.registerShortcuts(window, false) + const onBlur = () => this.registerShortcuts(window, true) + window.on('focus', onFocus) + window.on('blur', onBlur) + this.windowOnHandlers.set(window, { onFocus, onBlur }) } - //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 (!window.isDestroyed() && window.isFocused()) { + this.registerShortcuts(window, false) + } + } - const shortcuts = configManager.getShortcuts() - if (!shortcuts) return + private registerShortcuts(window: BrowserWindow, onlyPersistent: boolean): void { + if (window.isDestroyed()) return - shortcuts.forEach((shortcut) => { - try { - if (shortcut.shortcut.length === 0) { - return - } + const preferenceService = application.get('PreferenceService') + const getPreference = (key: K) => preferenceService.get(key as any) - if (!shortcut.enabled) { - return - } + // Build the desired set of accelerators + const desired = new Map() - // only register universal shortcuts when needed - if ( - onlyUniversalShortcuts && - !['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes( - shortcut.key - ) - ) { - return - } + for (const definition of relevantDefinitions) { + if (onlyPersistent && !definition.persistOnBlur) continue - const handler = this.getShortcutHandler(shortcut) - if (!handler) { - return - } + const rawPref = preferenceService.get(definition.key) as PreferenceShortcutType | undefined + const pref = coerceShortcutPreference(definition, rawPref) + if (!pref.enabled || !pref.binding.length) continue + if (definition.enabledWhen && !definition.enabledWhen(getPreference as any)) continue - 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 - } + const handler = this.handlers.get(definition.key) + if (!handler) continue - const accelerator = this.convertShortcutFormat(shortcut.shortcut) + const accelerator = toAccelerator(pref.binding) + if (accelerator) { + desired.set(accelerator, { handler, window }) + } - globalShortcut.register(accelerator, () => handler(window)) - } catch (error) { - logger.warn(`Failed to register shortcut ${shortcut.key}`) + if (definition.variants) { + for (const variant of definition.variants) { + const variantAccelerator = toAccelerator(variant) + if (variantAccelerator) { + desired.set(variantAccelerator, { handler, window }) + } } - }) + } } - const unregister = () => { - if (window.isDestroyed()) return - - try { - globalShortcut.unregisterAll() - - if (this.showAppAccelerator) { - const handler = this.getShortcutHandler({ key: 'show_app' } as Shortcut) - const accelerator = this.convertShortcutFormat(this.showAppAccelerator) - handler && globalShortcut.register(accelerator, () => 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 (this.selectionAssistantToggleAccelerator) { - const handler = this.getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut) - const accelerator = this.convertShortcutFormat(this.selectionAssistantToggleAccelerator) - handler && globalShortcut.register(accelerator, () => handler(window)) - } - - 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 + for (const accelerator of this.registeredAccelerators) { + if (!desired.has(accelerator)) { + try { + globalShortcut.unregister(accelerator) + } catch { + // ignore } - } 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 shortcuts or re-register changed ones + for (const [accelerator, { handler, window: win }] of desired) { + if (!this.registeredAccelerators.has(accelerator)) { + try { + globalShortcut.register(accelerator, () => { + const targetWindow = win?.isDestroyed?.() ? undefined : win + handler(targetWindow) + }) + this.registeredAccelerators.add(accelerator) + } catch (error) { + logger.warn(`Failed to register shortcut ${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() { + private unregisterAll(): void { 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) + window.off('focus', handlers.onFocus) + window.off('blur', handlers.onBlur) }) this.windowOnHandlers.clear() - globalShortcut.unregisterAll() + for (const accelerator of this.registeredAccelerators) { + try { + globalShortcut.unregister(accelerator) + } catch { + // ignore + } + } + this.registeredAccelerators.clear() } catch (error) { logger.warn('Failed to unregister all shortcuts') } } - - 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 - } - - 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 formatShortcutKey(shortcut: string[]): string { - return shortcut.join('+') - } - - // 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('+') - } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 6925b7f77b6..65910f48c74 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -48,7 +48,6 @@ import type { Provider, RestartApiServerStatusResult, S3Config, - Shortcut, StartApiServerStatusResult, StopApiServerStatusResult, SupportedOcrFile, @@ -299,9 +298,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), diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx index 7d60ad17a40..20fabd7dd01 100644 --- a/src/renderer/src/components/TopView/index.tsx +++ b/src/renderer/src/components/TopView/index.tsx @@ -1,8 +1,9 @@ // 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 type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' import { message, Modal } from 'antd' import type { PropsWithChildren } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react' @@ -37,8 +38,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.app.exit_fullscreen') + const enableQuitFullScreen = (exitFullscreenPref as PreferenceShortcutType | undefined)?.enabled !== false useAppInit() diff --git a/src/renderer/src/handler/NavigationHandler.tsx b/src/renderer/src/handler/NavigationHandler.tsx index 9f7cb2bace6..33826555eac 100644 --- a/src/renderer/src/handler/NavigationHandler.tsx +++ b/src/renderer/src/handler/NavigationHandler.tsx @@ -1,29 +1,23 @@ -import { useAppSelector } from '@renderer/store' +import { useShortcut } from '@renderer/hooks/useShortcuts' import { IpcChannel } from '@shared/IpcChannel' import { useLocation, 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 () { + useShortcut( + 'app.show_settings', + () => { if (location.pathname.startsWith('/settings')) { return } void navigate({ to: '/settings/provider' }) }, { - splitKey: '!', - enableOnContentEditable: true, enableOnFormTags: true, - enabled: showSettingsShortcutEnabled + enableOnContentEditable: true } ) diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index ea1c0cab67e..3919de2fbce 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -1,23 +1,20 @@ -/** - * @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 } from '@renderer/config/constant' +import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' +import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions' +import type { + ShortcutDefinition, + ShortcutKey, + ShortcutPreferenceKey, + ShortcutPreferenceValue +} from '@shared/shortcuts/types' +import { + coerceShortcutPreference, + convertAcceleratorToHotkey, + formatShortcutDisplay, + getDefaultShortcutPreference +} from '@shared/shortcuts/utils' +import { useCallback, useMemo } from 'react' import { useHotkeys } from 'react-hotkeys-hook' interface UseShortcutOptions { @@ -25,85 +22,164 @@ interface UseShortcutOptions { enableOnFormTags?: boolean enabled?: boolean description?: string + enableOnContentEditable?: boolean } const defaultOptions: UseShortcutOptions = { preventDefault: true, enableOnFormTags: true, - enabled: true + enabled: true, + enableOnContentEditable: false +} + +const toFullKey = (key: ShortcutKey | ShortcutPreferenceKey): ShortcutPreferenceKey => + (key.startsWith('shortcut.') ? key : `shortcut.${key}`) as ShortcutPreferenceKey + +const resolvePreferenceValue = ( + definition: ShortcutDefinition | undefined, + preference: PreferenceShortcutType | Record | undefined +): ShortcutPreferenceValue | null => { + if (!definition) { + return null + } + return coerceShortcutPreference(definition, preference as PreferenceShortcutType | undefined) } 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 = useMemo(() => findShortcutDefinition(fullKey), [fullKey]) + const [preference] = usePreference(fullKey) + const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference]) + + const hotkey = useMemo(() => { + if (!definition || !preferenceState) { + return 'none' + } + + if (definition.scope === 'main') { + return 'none' + } + + if (!preferenceState.enabled) { + return 'none' + } + + if (!preferenceState.binding.length) { + return 'none' + } - const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey) + return convertAcceleratorToHotkey(preferenceState.binding) + }, [definition, preferenceState]) useHotkeys( - shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none', - (e) => { + hotkey, + (event) => { if (options.preventDefault) { - e.preventDefault() + event.preventDefault() } if (options.enabled !== false) { - callback(e) + callback(event) } }, { enableOnFormTags: options.enableOnFormTags, - description: options.description || shortcutConfig?.key, - enabled: !!shortcutConfig?.enabled - } + description: options.description ?? fullKey, + enabled: hotkey !== 'none', + enableOnContentEditable: options.enableOnContentEditable + }, + [hotkey, callback, options] ) } -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 = useMemo(() => findShortcutDefinition(fullKey), [fullKey]) + const [preference] = usePreference(fullKey) + const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference]) + + return useMemo(() => { + if (!definition || !preferenceState || !preferenceState.enabled) { + return '' + } + + const displayBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey + + if (!displayBinding.length) { + return '' + } + + return formatShortcutDisplay(displayBinding, isMac) + }, [definition, preferenceState]) +} + +export interface ShortcutListItem { + definition: ShortcutDefinition + preference: ShortcutPreferenceValue + defaultPreference: ShortcutPreferenceValue + updatePreference: (patch: Partial) => Promise } -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 const useAllShortcuts = (): ShortcutListItem[] => { + const keyMap = useMemo( + () => + SHORTCUT_DEFINITIONS.reduce>((acc, definition) => { + acc[definition.key] = definition.key + return acc + }, {}), + [] + ) + + const [values, setValues] = useMultiplePreferences(keyMap) + + const buildNextPreference = useCallback( + ( + state: ShortcutPreferenceValue, + currentValue: PreferenceShortcutType | undefined, + patch: Partial + ): PreferenceShortcutType => { + const current = (currentValue ?? {}) as PreferenceShortcutType + + const nextKey = Array.isArray(patch.key) ? patch.key : Array.isArray(current.key) ? current.key : state.rawBinding + + const nextEnabled = + typeof patch.enabled === 'boolean' + ? patch.enabled + : typeof current.enabled === 'boolean' + ? current.enabled + : state.enabled + + return { + key: nextKey, + enabled: nextEnabled + } + }, + [] + ) + + return useMemo( + () => + SHORTCUT_DEFINITIONS.map((definition) => { + const rawValue = values[definition.key] as PreferenceShortcutType | undefined + const preference = coerceShortcutPreference(definition, rawValue) + const defaultPreference = getDefaultShortcutPreference(definition) + + const updatePreference = async (patch: Partial) => { + const currentValue = values[definition.key] as PreferenceShortcutType | undefined + const nextValue = buildNextPreference(preference, currentValue, patch) + await setValues({ [definition.key]: nextValue } as Partial>) + } + + return { + definition, + preference, + defaultPreference, + updatePreference } - }) - .join('+') - }, []) - const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) - const shortcutConfig = shortcuts.find((s) => s.key === key) - return shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : '' + }), + [buildNextPreference, setValues, values] + ) } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 0cc4137fb2a..50e7b32df30 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -5555,6 +5555,7 @@ "actions": "operation", "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", @@ -5569,6 +5570,7 @@ "reset_to_default": "Reset to Default", "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", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 5c669609c56..03bfdbd1a01 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -5555,6 +5555,7 @@ "actions": "操作", "clear_shortcut": "清除快捷键", "clear_topic": "清空消息", + "conflict_with": "已被「{{name}}」使用", "copy_last_message": "复制上一条消息", "edit_last_user_message": "编辑最后一条用户消息", "enabled": "启用", @@ -5569,6 +5570,7 @@ "reset_to_default": "重置为默认", "search_message": "搜索消息", "search_message_in_chat": "在当前对话中搜索消息", + "search_placeholder": "搜索快捷键...", "select_model": "选择模型", "selection_assistant_select_text": "划词助手:取词", "selection_assistant_toggle": "开关划词助手", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 7f717471ba0..7cb825dd2a8 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -5555,6 +5555,7 @@ "actions": "操作", "clear_shortcut": "清除快捷鍵", "clear_topic": "清除所有訊息", + "conflict_with": "已被「{{name}}」使用", "copy_last_message": "複製上一則訊息", "edit_last_user_message": "編輯最後一則使用者訊息", "enabled": "啟用", @@ -5569,6 +5570,7 @@ "reset_to_default": "重設為預設", "search_message": "搜尋訊息", "search_message_in_chat": "在目前對話中搜尋訊息", + "search_placeholder": "搜尋捷徑...", "select_model": "選擇模型", "selection_assistant_select_text": "劃詞助手:取詞", "selection_assistant_toggle": "開關劃詞助手", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index cf3e31cc4d1..67ed5570daa 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -5555,6 +5555,7 @@ "actions": "Aktionen", "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", @@ -5569,6 +5570,7 @@ "reset_to_default": "Auf Standard zurücksetzen", "search_message": "Nachricht suchen", "search_message_in_chat": "In aktuellem Chat suchen", + "search_placeholder": "Suchverknüpfungen durchsuchen...", "select_model": "Select Model", "selection_assistant_select_text": "Textauswahl-Assistent: Text erfassen", "selection_assistant_toggle": "Textauswahl-Assistent umschalten", @@ -5846,7 +5848,7 @@ } }, "tab": { - "new": "[to be translated]:New Tab" + "new": "Neuer Tab" }, "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 eb321b0b775..3664b5854cf 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -5555,6 +5555,7 @@ "actions": "Λειτουργία", "clear_shortcut": "Καθαρισμός συντομού πλήκτρου", "clear_topic": "Άδειασμα μηνυμάτων", + "conflict_with": "Ήδη χρησιμοποιείται από τον \"{{name}}\"", "copy_last_message": "Αντιγραφή του τελευταίου μηνύματος", "edit_last_user_message": "Επεξεργασία του τελευταίου μηνύματος χρήστη", "enabled": "ενεργοποίηση", @@ -5569,6 +5570,7 @@ "reset_to_default": "Επαναφορά στις προεπιλεγμένες", "search_message": "Αναζήτηση μηνυμάτων", "search_message_in_chat": "Αναζήτηση μηνύματος στην τρέχουσα συνομιλία", + "search_placeholder": "Συντομεύσεις αναζήτησης...", "select_model": "Select Model", "selection_assistant_select_text": "Βοηθός επιλογής κειμένου: επιλογή λέξης", "selection_assistant_toggle": "Εναλλαγή βοηθού επιλογής κειμένου", @@ -5846,7 +5848,7 @@ } }, "tab": { - "new": "[to be translated]:New Tab" + "new": "Νέα Καρτέλα" }, "title": { "apps": "Εφαρμογές", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 80ea549a13e..5b6c370766d 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -5555,6 +5555,7 @@ "actions": "operación", "clear_shortcut": "Borrar atajo", "clear_topic": "Vaciar mensaje", + "conflict_with": "Ya usado por \"{{name}}\"", "copy_last_message": "Copiar el último mensaje", "edit_last_user_message": "Editar último mensaje de usuario", "enabled": "habilitar", @@ -5569,6 +5570,7 @@ "reset_to_default": "Restablecer a predeterminado", "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", @@ -5846,7 +5848,7 @@ } }, "tab": { - "new": "[to be translated]:New Tab" + "new": "Nueva 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 922ecc71740..c98716d920c 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -5555,6 +5555,7 @@ "actions": "操作", "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", @@ -5569,6 +5570,7 @@ "reset_to_default": "Réinitialiser aux valeurs par défaut", "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", @@ -5846,7 +5848,7 @@ } }, "tab": { - "new": "[to be translated]:New Tab" + "new": "Nouvel 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 640b982bc8d..4d75887af51 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -5555,6 +5555,7 @@ "actions": "操作", "clear_shortcut": "ショートカットをクリア", "clear_topic": "メッセージを消去", + "conflict_with": "既に「{{name}}」によって使用されています", "copy_last_message": "最後のメッセージをコピー", "edit_last_user_message": "最後のユーザーメッセージを編集", "enabled": "有効化", @@ -5569,6 +5570,7 @@ "reset_to_default": "デフォルトにリセット", "search_message": "メッセージを検索", "search_message_in_chat": "現在のチャットでメッセージを検索", + "search_placeholder": "検索ショートカット...", "select_model": "Select Model", "selection_assistant_select_text": "選択アシスタント:テキストを選択", "selection_assistant_toggle": "選択アシスタントを切り替え", @@ -5846,7 +5848,7 @@ } }, "tab": { - "new": "[to be translated]:New Tab" + "new": "新しいタブ" }, "title": { "apps": "アプリ", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 631faaa9b03..56b0264e1eb 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -5555,6 +5555,7 @@ "actions": "operação", "clear_shortcut": "Limpar atalho", "clear_topic": "Limpar mensagem", + "conflict_with": "Já utilizado por \"{{name}}\"", "copy_last_message": "Copiar a última mensagem", "edit_last_user_message": "Editar última mensagem do usuário", "enabled": "ativar", @@ -5569,6 +5570,7 @@ "reset_to_default": "Redefinir para padrão", "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", @@ -5846,7 +5848,7 @@ } }, "tab": { - "new": "[to be translated]:New Tab" + "new": "Nova 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 726768626b8..80fe92446d4 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -5555,6 +5555,7 @@ "actions": "Comandă", "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ă", @@ -5569,6 +5570,7 @@ "reset_to_default": "Resetează la implicit", "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", @@ -5845,6 +5847,9 @@ "title": "Zoom pagină" } }, + "tab": { + "new": "Filă nouă" + }, "title": { "apps": "Aplicații", "code": "Cod", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index c430a549c8a..551957bc952 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -5555,6 +5555,7 @@ "actions": "操作", "clear_shortcut": "Очистить сочетание клавиш", "clear_topic": "Очистить все сообщения", + "conflict_with": "Уже используется \"{{name}}\"", "copy_last_message": "Копировать последнее сообщение", "edit_last_user_message": "Редактировать последнее сообщение пользователя", "enabled": "Включить", @@ -5569,6 +5570,7 @@ "reset_to_default": "Сбросить настройки по умолчанию", "search_message": "Поиск сообщения", "search_message_in_chat": "Поиск сообщения в текущем диалоге", + "search_placeholder": "Поиск ярлыков...", "select_model": "Select Model", "selection_assistant_select_text": "Помощник выделения: выделить текст", "selection_assistant_toggle": "Переключить помощник выделения", @@ -5846,7 +5848,7 @@ } }, "tab": { - "new": "[to be translated]:New Tab" + "new": "Новая вкладка" }, "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..36450d82ecf 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('app.search_message', () => { void SearchPopup.show() }) diff --git a/src/renderer/src/pages/agents/AgentPage.tsx b/src/renderer/src/pages/agents/AgentPage.tsx index 812175403c3..83fd6a50d38 100644 --- a/src/renderer/src/pages/agents/AgentPage.tsx +++ b/src/renderer/src/pages/agents/AgentPage.tsx @@ -32,7 +32,7 @@ const AgentPage = () => { const { apiServerConfig, apiServerRunning, apiServerLoading } = useApiServer() const { t } = useTranslation() - useShortcut('toggle_show_assistants', () => { + useShortcut('app.toggle_show_assistants', () => { if (topicPosition === 'left') { void toggleShowAssistants() return @@ -41,7 +41,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..c397c7d69bf 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('app.search_message', () => { void SearchPopup.show() }) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 72e5b487bca..8e37ed0511a 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, filter: modelFilter }) if (selectedModel) { diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index 0fcf81428ea..97625cd89ba 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -45,7 +45,7 @@ const HomePage: FC = () => { _activeAssistant = activeAssistant - useShortcut('toggle_show_assistants', () => { + useShortcut('app.toggle_show_assistants', () => { if (topicPosition === 'right') { void toggleShowAssistants() return @@ -62,7 +62,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..efcf77cb0f8 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('app.search_message', () => { 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..ed0f2e932ac 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('app.search_message', () => { void SearchPopup.show() }) diff --git a/src/renderer/src/pages/knowledge/KnowledgePage.tsx b/src/renderer/src/pages/knowledge/KnowledgePage.tsx index c267b05d2bf..4441dc11de3 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('app.search_message', () => { 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..0521a7ccf3c 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -1,168 +1,190 @@ import { ClearOutlined, UndoOutlined } from '@ant-design/icons' import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui' -import { isMac, isWin } from '@renderer/config/constant' +import { preferenceService } from '@data/PreferenceService' +import { isMac, platform } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' -import { useShortcuts } from '@renderer/hooks/useShortcuts' +import { 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 { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' +import type { ShortcutPreferenceKey } from '@shared/shortcuts/types' +import { convertKeyToAccelerator, formatShortcutDisplay, isValidShortcut } from '@shared/shortcuts/utils' 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 { FC, KeyboardEvent as ReactKeyboardEvent } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' +const labelKeyMap: Record = { + 'shortcut.app.show_main_window': 'show_app', + 'shortcut.app.show_mini_window': 'mini_window', + 'shortcut.app.show_settings': 'show_settings', + 'shortcut.app.toggle_show_assistants': 'toggle_show_assistants', + 'shortcut.app.exit_fullscreen': 'exit_fullscreen', + 'shortcut.app.zoom_in': 'zoom_in', + 'shortcut.app.zoom_out': 'zoom_out', + 'shortcut.app.zoom_reset': 'zoom_reset', + 'shortcut.app.search_message': 'search_message', + 'shortcut.chat.clear': 'clear_topic', + 'shortcut.chat.search_message': 'search_message_in_chat', + 'shortcut.chat.toggle_new_context': 'toggle_new_context', + 'shortcut.chat.copy_last_message': 'copy_last_message', + 'shortcut.chat.edit_last_user_message': 'edit_last_user_message', + 'shortcut.chat.select_model': 'select_model', + 'shortcut.topic.new': 'new_topic', + 'shortcut.topic.rename': 'rename_topic', + 'shortcut.topic.toggle_show_topics': 'toggle_show_topics', + 'shortcut.selection.toggle_enabled': 'selection_assistant_toggle', + 'shortcut.selection.get_text': 'selection_assistant_select_text' +} + +type ShortcutRecord = { + id: string + label: string + key: ShortcutPreferenceKey + enabled: boolean + editable: boolean + displayKeys: string[] + rawKeys: string[] + hasCustomBinding: boolean + system: boolean + updatePreference: (patch: Partial) => Promise + defaultPreference: { + binding: string[] + enabled: boolean + } +} + const ShortcutSettings: FC = () => { const { t } = useTranslation() const { theme } = useTheme() - const dispatch = useAppDispatch() - const { shortcuts: originalShortcuts } = useShortcuts() + const shortcuts = useAllShortcuts() const inputRefs = useRef>({}) const [editingKey, setEditingKey] = useState(null) - const { setTimeoutTimer } = useTimer() + const [pendingKeys, setPendingKeys] = useState([]) + const [conflictLabel, setConflictLabel] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const { setTimeoutTimer, clearTimeoutTimer } = useTimer() + + const displayedShortcuts = useMemo(() => { + const filtered = shortcuts.filter((item) => { + const supported = item.definition.supportedPlatforms + if (supported && platform && !supported.includes(platform as NodeJS.Platform)) { + return false + } + return true + }) - //if shortcut is not available on all the platforms, block the shortcut here - const shortcuts = originalShortcuts + return filtered.map((item) => { + const labelKey = labelKeyMap[item.definition.key] ?? item.definition.key + const label = getShortcutLabel(labelKey) + + const displayKeys = item.preference.hasCustomBinding + ? item.preference.rawBinding + : item.preference.binding.length > 0 + ? item.preference.binding + : item.definition.defaultKey + + return { + id: item.definition.key, + label, + key: item.definition.key, + enabled: item.preference.enabled, + editable: item.preference.editable, + displayKeys, + rawKeys: item.preference.rawBinding, + hasCustomBinding: item.preference.hasCustomBinding, + system: item.preference.system, + updatePreference: item.updatePreference, + defaultPreference: { + binding: item.defaultPreference.binding, + enabled: item.defaultPreference.enabled + } + } + }) + }, [shortcuts]) - const handleClear = (record: Shortcut) => { - dispatch( - updateShortcut({ - ...record, - shortcut: [] - }) - ) + const filteredShortcuts = useMemo(() => { + if (!searchQuery.trim()) { + return displayedShortcuts + } + const query = searchQuery.toLowerCase() + return displayedShortcuts.filter((record) => { + if (record.label.toLowerCase().includes(query)) { + return true + } + if (record.displayKeys.length > 0) { + const display = formatShortcutDisplay(record.displayKeys, isMac).toLowerCase() + if (display.includes(query)) { + return true + } + } + return false + }) + }, [displayedShortcuts, searchQuery]) + + const handleClear = (record: ShortcutRecord) => { + void record.updatePreference({ key: [] }) } - const handleAddShortcut = (record: Shortcut) => { - setEditingKey(record.key) + const handleAddShortcut = (record: ShortcutRecord) => { + setEditingKey(record.id) + setPendingKeys([]) + setConflictLabel(null) setTimeoutTimer( - 'handleAddShortcut', + `focus-${record.id}`, () => { - inputRefs.current[record.key]?.focus() + inputRefs.current[record.id]?.focus() }, 0 ) } - const isShortcutModified = (record: Shortcut) => { - const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key) - return defaultShortcut?.shortcut.join('+') !== record.shortcut.join('+') - } + const isShortcutModified = (record: ShortcutRecord) => { + const bindingChanged = record.hasCustomBinding + ? record.rawKeys.length !== record.defaultPreference.binding.length || + record.rawKeys.some((key, index) => key !== record.defaultPreference.binding[index]) + : false - const handleResetShortcut = (record: Shortcut) => { - const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key) - if (defaultShortcut) { - dispatch( - updateShortcut({ - ...record, - shortcut: defaultShortcut.shortcut - }) - ) - } - } - - 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 enabledChanged = record.enabled !== record.defaultPreference.enabled - const hasFnKey = keys.some((key) => /^F\d+$/.test(key)) - - return (hasModifier && hasNonModifier && keys.length >= 2) || hasFnKey + return bindingChanged || enabledChanged } - const isDuplicateShortcut = (newShortcut: string[], currentKey: string): boolean => { - return shortcuts.some( - (s) => s.key !== currentKey && s.shortcut.length > 0 && s.shortcut.join('+') === newShortcut.join('+') - ) + const handleResetShortcut = (record: ShortcutRecord) => { + void record.updatePreference({ + key: record.defaultPreference.binding, + enabled: record.defaultPreference.enabled + }) + setEditingKey(null) + setPendingKeys([]) + setConflictLabel(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) + const findDuplicateLabel = useCallback( + (keys: string[], currentKey: ShortcutPreferenceKey): string | null => { + const normalized = keys.map((key) => key.toLowerCase()).join('+') + + for (const record of displayedShortcuts) { + if (record.key === currentKey) continue + if (!record.enabled) continue + const binding = record.displayKeys + if (!binding.length) continue + if (binding.map((key) => key.toLowerCase()).join('+') === normalized) { + return record.label } - }) - .join(' + ') - } + } + return null + }, + [displayedShortcuts] + ) - const usableEndKeys = (event: React.KeyboardEvent): string | null => { + const usableEndKeys = (event: ReactKeyboardEvent): 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': @@ -212,10 +234,15 @@ const ShortcutSettings: FC = () => { case 'Numpad9': return code.slice(-1) case 'Space': + return 'Space' case 'Enter': + return 'Enter' case 'Backspace': + return 'Backspace' case 'Tab': + return 'Tab' case 'Delete': + return 'Delete' case 'PageUp': case 'PageDown': case 'Insert': @@ -251,7 +278,6 @@ const ShortcutSettings: FC = () => { 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': @@ -267,103 +293,127 @@ const ShortcutSettings: FC = () => { } } - const handleKeyDown = (e: React.KeyboardEvent, record: Shortcut) => { - e.preventDefault() + const handleKeyDown = (event: ReactKeyboardEvent, record: ShortcutRecord) => { + event.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) 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 })) + setConflictLabel(null) + void record.updatePreference({ key: keys }) setEditingKey(null) + setPendingKeys([]) } const handleResetAllShortcuts = () => { window.modal.confirm({ title: t('settings.shortcuts.reset_defaults_confirm'), centered: true, - onOk: () => dispatch(resetShortcuts()) + onOk: async () => { + const updates: Record = {} + + shortcuts.forEach((item) => { + updates[item.definition.key] = { + key: item.defaultPreference.binding, + enabled: item.defaultPreference.enabled + } + }) + + await preferenceService.setMultiple(updates) + } }) } - // 由于启用了showHeader = false,不再需要title字段 - const columns: ColumnsType = [ + const columns: ColumnsType = [ { - // title: t('settings.shortcuts.action'), - dataIndex: 'name', - key: 'name' + dataIndex: 'label', + key: 'label' }, { - // title: t('settings.shortcuts.label'), - dataIndex: 'shortcut', + dataIndex: 'displayKeys', 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 + render: (_value, record) => { + const isEditing = editingKey === record.id + const displayShortcut = record.displayKeys.length > 0 ? formatShortcutDisplay(record.displayKeys, isMac) : '' - return ( - - - {isEditing ? ( + if (isEditing) { + const pendingDisplay = pendingKeys.length > 0 ? formatShortcutDisplay(pendingKeys, isMac) : '' + const hasConflict = conflictLabel !== null + + return ( + +
{ - if (el) { - inputRefs.current[record.key] = el + ref={(element) => { + if (element) { + inputRefs.current[record.id] = element } }} - value={formatShortcut(shortcut)} + value={pendingDisplay} placeholder={t('settings.shortcuts.press_shortcut')} - onKeyDown={(e) => handleKeyDown(e, record)} - onBlur={(e) => { - const isUndoClick = e.relatedTarget?.closest('.shortcut-undo-icon') + onKeyDown={(event) => handleKeyDown(event, record)} + onBlur={(event) => { + const isUndoClick = event.relatedTarget?.closest('.shortcut-undo-icon') if (!isUndoClick) { setEditingKey(null) + setPendingKeys([]) + setConflictLabel(null) } }} + status={hasConflict ? 'error' : undefined} /> - ) : ( - isEditable && handleAddShortcut(record)}> - {shortcut.length > 0 ? formatShortcut(shortcut) : t('settings.shortcuts.press_shortcut')} - - )} + {hasConflict && ( + {t('settings.shortcuts.conflict_with', { name: conflictLabel })} + )} +
+
+ ) + } + + return ( + + + record.editable && handleAddShortcut(record)}> + {displayShortcut || t('settings.shortcuts.press_shortcut')} + ) } }, { - // title: t('settings.shortcuts.actions'), key: 'actions', align: 'right', - width: '70px', - render: (record: Shortcut) => ( + width: 70, + render: (record) => ( @@ -382,12 +432,15 @@ const ShortcutSettings: FC = () => { ) }, { - // title: t('settings.shortcuts.enabled'), key: 'enabled', align: 'right', - width: '50px', - render: (record: Shortcut) => ( - dispatch(toggleShortcut(record.key))} /> + width: 50, + render: (record) => ( + void record.updatePreference({ enabled: !record.enabled })} + /> ) } ] @@ -397,12 +450,21 @@ const ShortcutSettings: FC = () => { {t('settings.shortcuts.title')} +
+ setSearchQuery(e.target.value)} + allowClear + /> +
} - dataSource={shortcuts.map((s) => ({ ...s, name: getShortcutLabel(s.key) }))} + dataSource={filteredShortcuts} pagination={false} size="middle" showHeader={false} + rowKey="id" /> @@ -429,14 +491,28 @@ const Table = styled(AntTable)` ` const ShortcutInput = styled(Input)` - width: 120px; + width: 140px; text-align: center; ` +const SearchInput = styled(Input)` + max-width: 260px; +` + const ShortcutText = styled.span<{ isEditable: boolean }>` cursor: ${({ isEditable }) => (isEditable ? 'pointer' : 'not-allowed')}; padding: 4px 11px; opacity: ${({ isEditable }) => (isEditable ? 1 : 0.5)}; ` +const ConflictHint = styled.span` + position: absolute; + top: 100%; + right: 0; + margin-top: 2px; + font-size: 12px; + color: var(--color-error, #ff4d4f); + white-space: nowrap; +` + export default ShortcutSettings diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index f79013452cd..f90a1d9a124 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -63,12 +63,15 @@ import { mcpSlice } from './mcp' import { initialState as notesInitialState } from './note' // import { defaultActionItems } from './selectionStore' import { initialState as settingsInitialState } from './settings' -import { initialState as shortcutsInitialState } from './shortcuts' +import { initialState as shortcutsInitialState, type ShortcutsState } from './shortcuts' import { defaultWebSearchProviders } from './websearch' const logger = loggerService.withContext('Migrate') +// Legacy state type for migrations — includes `shortcuts` which was removed from the active store +type MigrationState = RootState & { shortcuts?: ShortcutsState } + // remove logo base64 data to reduce the size of the state -function removeMiniAppIconsFromState(state: RootState) { +function removeMiniAppIconsFromState(state: MigrationState) { if (state.minapps) { state.minapps.enabled = state.minapps.enabled.map((app) => ({ ...app, @@ -115,7 +118,7 @@ function addProvider(state: RootState, id: string) { } // Fix missing provider -function fixMissingProvider(state: RootState) { +function fixMissingProvider(state: MigrationState) { SYSTEM_PROVIDERS.forEach((p) => { if (!state.llm.providers.find((provider) => provider.id === p.id)) { state.llm.providers.push(p) @@ -184,7 +187,7 @@ function addSelectionAction(state: RootState, id: string) { * if afterId is 'first', add to the first * if afterId is 'last', add to the last */ -function addShortcuts(state: RootState, ids: string[], afterId: string) { +function addShortcuts(state: MigrationState, ids: string[], afterId: string) { const defaultShortcuts = shortcutsInitialState.shortcuts // 确保 state.shortcuts 存在 @@ -234,7 +237,7 @@ function addPreprocessProviders(state: RootState, id: string) { } const migrateConfig = { - '2': (state: RootState) => { + '2': (state: MigrationState) => { try { addProvider(state, 'yi') return state @@ -242,7 +245,7 @@ const migrateConfig = { return state } }, - '3': (state: RootState) => { + '3': (state: MigrationState) => { try { addProvider(state, 'zhipu') return state @@ -250,7 +253,7 @@ const migrateConfig = { return state } }, - '4': (state: RootState) => { + '4': (state: MigrationState) => { try { addProvider(state, 'ollama') return state @@ -258,7 +261,7 @@ const migrateConfig = { return state } }, - '5': (state: RootState) => { + '5': (state: MigrationState) => { try { addProvider(state, 'moonshot') return state @@ -266,7 +269,7 @@ const migrateConfig = { return state } }, - '6': (state: RootState) => { + '6': (state: MigrationState) => { try { addProvider(state, 'openrouter') return state @@ -274,7 +277,7 @@ const migrateConfig = { return state } }, - '7': (state: RootState) => { + '7': (state: MigrationState) => { try { return { ...state, @@ -287,7 +290,7 @@ const migrateConfig = { return state } }, - '8': (state: RootState) => { + '8': (state: MigrationState) => { try { const fixAssistantName = (assistant: Assistant) => { // 2025/07/25 这俩键早没了,从远古版本迁移包出错的 @@ -317,7 +320,7 @@ const migrateConfig = { return state } }, - '9': (state: RootState) => { + '9': (state: MigrationState) => { try { return { ...state, @@ -335,7 +338,7 @@ const migrateConfig = { return state } }, - '10': (state: RootState) => { + '10': (state: MigrationState) => { try { addProvider(state, 'baichuan') return state @@ -343,7 +346,7 @@ const migrateConfig = { return state } }, - '11': (state: RootState) => { + '11': (state: MigrationState) => { try { addProvider(state, 'dashscope') addProvider(state, 'anthropic') @@ -352,7 +355,7 @@ const migrateConfig = { return state } }, - '12': (state: RootState) => { + '12': (state: MigrationState) => { try { addProvider(state, 'aihubmix') return state @@ -360,7 +363,7 @@ const migrateConfig = { return state } }, - '13': (state: RootState) => { + '13': (state: MigrationState) => { try { return { ...state, @@ -378,7 +381,7 @@ const migrateConfig = { return state } }, - '14': (state: RootState) => { + '14': (state: MigrationState) => { try { return { ...state, @@ -392,7 +395,7 @@ const migrateConfig = { return state } }, - '15': (state: RootState) => { + '15': (state: MigrationState) => { try { return { ...state, @@ -406,7 +409,7 @@ const migrateConfig = { return state } }, - '16': (state: RootState) => { + '16': (state: MigrationState) => { try { return { ...state, @@ -420,7 +423,7 @@ const migrateConfig = { return state } }, - '17': (state: RootState) => { + '17': (state: MigrationState) => { try { return { ...state, @@ -433,7 +436,7 @@ const migrateConfig = { return state } }, - '19': (state: RootState) => { + '19': (state: MigrationState) => { try { return { ...state, @@ -453,7 +456,7 @@ const migrateConfig = { return state } }, - '20': (state: RootState) => { + '20': (state: MigrationState) => { try { return { ...state, @@ -466,7 +469,7 @@ const migrateConfig = { return state } }, - '21': (state: RootState) => { + '21': (state: MigrationState) => { try { addProvider(state, 'gemini') addProvider(state, 'stepfun') @@ -476,7 +479,7 @@ const migrateConfig = { return state } }, - '22': (state: RootState) => { + '22': (state: MigrationState) => { try { addProvider(state, 'minimax') return state @@ -484,7 +487,7 @@ const migrateConfig = { return state } }, - '23': (state: RootState) => { + '23': (state: MigrationState) => { try { return { ...state, @@ -498,7 +501,7 @@ const migrateConfig = { return state } }, - '24': (state: RootState) => { + '24': (state: MigrationState) => { try { return { ...state, @@ -522,7 +525,7 @@ const migrateConfig = { return state } }, - '25': (state: RootState) => { + '25': (state: MigrationState) => { try { addProvider(state, 'github') return state @@ -530,7 +533,7 @@ const migrateConfig = { return state } }, - '26': (state: RootState) => { + '26': (state: MigrationState) => { try { addProvider(state, 'ocoolai') return state @@ -538,7 +541,7 @@ const migrateConfig = { return state } }, - '27': (state: RootState) => { + '27': (state: MigrationState) => { try { return { ...state, @@ -551,7 +554,7 @@ const migrateConfig = { return state } }, - '28': (state: RootState) => { + '28': (state: MigrationState) => { try { addProvider(state, 'together') addProvider(state, 'fireworks') @@ -563,7 +566,7 @@ const migrateConfig = { return state } }, - '29': (state: RootState) => { + '29': (state: MigrationState) => { try { return { ...state, @@ -582,7 +585,7 @@ const migrateConfig = { return state } }, - '30': (state: RootState) => { + '30': (state: MigrationState) => { try { addProvider(state, 'azure-openai') return state @@ -590,7 +593,7 @@ const migrateConfig = { return state } }, - '31': (state: RootState) => { + '31': (state: MigrationState) => { try { return { ...state, @@ -611,7 +614,7 @@ const migrateConfig = { return state } }, - '32': (state: RootState) => { + '32': (state: MigrationState) => { try { addProvider(state, 'hunyuan') return state @@ -619,7 +622,7 @@ const migrateConfig = { return state } }, - '33': (state: RootState) => { + '33': (state: MigrationState) => { try { state.assistants.defaultAssistant.type = 'assistant' @@ -649,7 +652,7 @@ const migrateConfig = { return state } }, - '34': (state: RootState) => { + '34': (state: MigrationState) => { try { state.assistants.assistants.forEach((assistant) => { assistant.topics.forEach((topic) => { @@ -671,7 +674,7 @@ const migrateConfig = { return state } }, - '35': (state: RootState) => { + '35': (state: MigrationState) => { try { state.settings.mathEngine = 'KaTeX' return state @@ -679,7 +682,7 @@ const migrateConfig = { return state } }, - '36': (state: RootState) => { + '36': (state: MigrationState) => { try { state.settings.topicPosition = 'left' return state @@ -687,7 +690,7 @@ const migrateConfig = { return state } }, - '37': (state: RootState) => { + '37': (state: MigrationState) => { try { state.settings.messageStyle = 'plain' return state @@ -695,7 +698,7 @@ const migrateConfig = { return state } }, - '38': (state: RootState) => { + '38': (state: MigrationState) => { try { addProvider(state, 'grok') addProvider(state, 'hyperbolic') @@ -705,7 +708,7 @@ const migrateConfig = { return state } }, - '39': (state: RootState) => { + '39': (state: MigrationState) => { try { // @ts-ignore eslint-disable-next-line state.settings.codeStyle = 'auto' @@ -714,7 +717,7 @@ const migrateConfig = { return state } }, - '40': (state: RootState) => { + '40': (state: MigrationState) => { try { state.settings.tray = true return state @@ -722,7 +725,7 @@ const migrateConfig = { return state } }, - '41': (state: RootState) => { + '41': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'gemini') { @@ -738,7 +741,7 @@ const migrateConfig = { return state } }, - '42': (state: RootState) => { + '42': (state: MigrationState) => { try { state.settings.proxyMode = state.settings.proxyUrl ? 'custom' : 'none' return state @@ -746,7 +749,7 @@ const migrateConfig = { return state } }, - '43': (state: RootState) => { + '43': (state: MigrationState) => { try { if (state.settings.proxyMode === 'none') { state.settings.proxyMode = 'system' @@ -756,7 +759,7 @@ const migrateConfig = { return state } }, - '44': (state: RootState) => { + '44': (state: MigrationState) => { try { state.settings.translateModelPrompt = TRANSLATE_PROMPT return state @@ -764,11 +767,11 @@ const migrateConfig = { return state } }, - '45': (state: RootState) => { + '45': (state: MigrationState) => { state.settings.enableTopicNaming = true return state }, - '46': (state: RootState) => { + '46': (state: MigrationState) => { try { if ( state.settings?.translateModelPrompt?.includes( @@ -782,7 +785,7 @@ const migrateConfig = { return state } }, - '47': (state: RootState) => { + '47': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { provider.models.forEach((model) => { @@ -794,7 +797,7 @@ const migrateConfig = { return state } }, - '48': (state: RootState) => { + '48': (state: MigrationState) => { try { if (state.shortcuts) { state.shortcuts.shortcuts.forEach((shortcut) => { @@ -820,7 +823,7 @@ const migrateConfig = { return state } }, - '49': (state: RootState) => { + '49': (state: MigrationState) => { try { state.settings.pasteLongTextThreshold = 1500 if (state.shortcuts) { @@ -840,7 +843,7 @@ const migrateConfig = { return state } }, - '50': (state: RootState) => { + '50': (state: MigrationState) => { try { addProvider(state, 'jina') return state @@ -848,11 +851,11 @@ const migrateConfig = { return state } }, - '51': (state: RootState) => { + '51': (state: MigrationState) => { state.settings.topicNamingPrompt = '' return state }, - '54': (state: RootState) => { + '54': (state: MigrationState) => { try { if (state.shortcuts) { state.shortcuts.shortcuts.push({ @@ -872,7 +875,7 @@ const migrateConfig = { return state } }, - '55': (state: RootState) => { + '55': (state: MigrationState) => { try { if (!state.settings.sidebarIcons) { state.settings.sidebarIcons = { @@ -885,7 +888,7 @@ const migrateConfig = { return state } }, - '57': (state: RootState) => { + '57': (state: MigrationState) => { try { if (state.shortcuts) { state.shortcuts.shortcuts.push({ @@ -912,7 +915,7 @@ const migrateConfig = { return state } }, - '58': (state: RootState) => { + '58': (state: MigrationState) => { try { if (state.shortcuts) { state.shortcuts.shortcuts.push( @@ -937,7 +940,7 @@ const migrateConfig = { return state } }, - '59': (state: RootState) => { + '59': (state: MigrationState) => { try { addMiniApp(state, 'flowith') return state @@ -945,7 +948,7 @@ const migrateConfig = { return state } }, - '60': (state: RootState) => { + '60': (state: MigrationState) => { try { state.settings.multiModelMessageStyle = 'fold' return state @@ -953,7 +956,7 @@ const migrateConfig = { return state } }, - '61': (state: RootState) => { + '61': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'qwenlm') { @@ -966,7 +969,7 @@ const migrateConfig = { return state } }, - '62': (state: RootState) => { + '62': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'azure-openai') { @@ -979,7 +982,7 @@ const migrateConfig = { return state } }, - '63': (state: RootState) => { + '63': (state: MigrationState) => { try { addMiniApp(state, '3mintop') return state @@ -987,7 +990,7 @@ const migrateConfig = { return state } }, - '64': (state: RootState) => { + '64': (state: MigrationState) => { try { state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'qwenlm') addProvider(state, 'baidu-cloud') @@ -996,7 +999,7 @@ const migrateConfig = { return state } }, - '65': (state: RootState) => { + '65': (state: MigrationState) => { try { // @ts-ignore expect error state.settings.targetLanguage = 'english' @@ -1005,7 +1008,7 @@ const migrateConfig = { return state } }, - '66': (state: RootState) => { + '66': (state: MigrationState) => { try { addProvider(state, 'gitee-ai') addProvider(state, 'ppio') @@ -1017,7 +1020,7 @@ const migrateConfig = { return state } }, - '67': (state: RootState) => { + '67': (state: MigrationState) => { try { addMiniApp(state, 'xiaoyi') addProvider(state, 'modelscope') @@ -1035,7 +1038,7 @@ const migrateConfig = { return state } }, - '68': (state: RootState) => { + '68': (state: MigrationState) => { try { addMiniApp(state, 'notebooklm') addProvider(state, 'modelscope') @@ -1045,7 +1048,7 @@ const migrateConfig = { return state } }, - '69': (state: RootState) => { + '69': (state: MigrationState) => { try { addMiniApp(state, 'coze') state.settings.gridColumns = 2 @@ -1055,7 +1058,7 @@ const migrateConfig = { return state } }, - '70': (state: RootState) => { + '70': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'dmxapi') { @@ -1067,7 +1070,7 @@ const migrateConfig = { return state } }, - '71': (state: RootState) => { + '71': (state: MigrationState) => { try { const appIds = ['dify', 'wpslingxi', 'lechat', 'abacus', 'lambdachat', 'baidu-ai-search'] @@ -1090,7 +1093,7 @@ const migrateConfig = { return state } }, - '72': (state: RootState) => { + '72': (state: MigrationState) => { try { addMiniApp(state, 'monica') @@ -1108,7 +1111,7 @@ const migrateConfig = { return state } }, - '73': (state: RootState) => { + '73': (state: MigrationState) => { try { if (state.websearch) { state.websearch.searchWithTime = true @@ -1151,7 +1154,7 @@ const migrateConfig = { return state } }, - '74': (state: RootState) => { + '74': (state: MigrationState) => { try { addProvider(state, 'xirang') return state @@ -1159,7 +1162,7 @@ const migrateConfig = { return state } }, - '75': (state: RootState) => { + '75': (state: MigrationState) => { try { addMiniApp(state, 'you') addMiniApp(state, 'cici') @@ -1169,7 +1172,7 @@ const migrateConfig = { return state } }, - '76': (state: RootState) => { + '76': (state: MigrationState) => { try { addProvider(state, 'tencent-cloud-ti') return state @@ -1177,7 +1180,7 @@ const migrateConfig = { return state } }, - '77': (state: RootState) => { + '77': (state: MigrationState) => { try { addWebSearchProvider(state, 'searxng') addWebSearchProvider(state, 'exa') @@ -1192,7 +1195,7 @@ const migrateConfig = { return state } }, - '78': (state: RootState) => { + '78': (state: MigrationState) => { try { state.llm.providers = moveProvider(state.llm.providers, 'ppio', 9) state.llm.providers = moveProvider(state.llm.providers, 'infini', 10) @@ -1202,7 +1205,7 @@ const migrateConfig = { return state } }, - '79': (state: RootState) => { + '79': (state: MigrationState) => { try { addProvider(state, 'gpustack') return state @@ -1210,7 +1213,7 @@ const migrateConfig = { return state } }, - '80': (state: RootState) => { + '80': (state: MigrationState) => { try { addProvider(state, 'alayanew') state.llm.providers = moveProvider(state.llm.providers, 'alayanew', 10) @@ -1219,7 +1222,7 @@ const migrateConfig = { return state } }, - '81': (state: RootState) => { + '81': (state: MigrationState) => { try { addProvider(state, 'copilot') return state @@ -1227,7 +1230,7 @@ const migrateConfig = { return state } }, - '82': (state: RootState) => { + '82': (state: MigrationState) => { try { const runtimeState = state.runtime as any if (runtimeState?.webdavSync) { @@ -1247,7 +1250,7 @@ const migrateConfig = { return state } }, - '83': (state: RootState) => { + '83': (state: MigrationState) => { try { state.settings.messageNavigation = 'buttons' state.settings.launchOnBoot = false @@ -1259,7 +1262,7 @@ const migrateConfig = { return state } }, - '84': (state: RootState) => { + '84': (state: MigrationState) => { try { addProvider(state, 'voyageai') return state @@ -1268,7 +1271,7 @@ const migrateConfig = { return state } }, - '85': (state: RootState) => { + '85': (state: MigrationState) => { try { // @ts-ignore eslint-disable-next-line state.settings.autoCheckUpdate = !state.settings.manualUpdateCheck @@ -1280,7 +1283,7 @@ const migrateConfig = { return state } }, - '86': (state: RootState) => { + '86': (state: MigrationState) => { try { if (state?.mcp?.servers) { state.mcp.servers = state.mcp.servers.map((server) => ({ @@ -1293,7 +1296,7 @@ const migrateConfig = { } return state }, - '87': (state: RootState) => { + '87': (state: MigrationState) => { try { state.settings.maxKeepAliveMinapps = 3 state.settings.showOpenedMinappsInSidebar = true @@ -1302,7 +1305,7 @@ const migrateConfig = { return state } }, - '88': (state: RootState) => { + '88': (state: MigrationState) => { try { if (state?.mcp?.servers) { const hasAutoInstall = state.mcp.servers.some((server) => server.name === '@cherry/mcp-auto-install') @@ -1316,7 +1319,7 @@ const migrateConfig = { return state } }, - '89': (state: RootState) => { + '89': (state: MigrationState) => { try { removeMiniAppFromState(state, 'aistudio') return state @@ -1324,7 +1327,7 @@ const migrateConfig = { return state } }, - '90': (state: RootState) => { + '90': (state: MigrationState) => { try { state.settings.enableDataCollection = true return state @@ -1332,7 +1335,7 @@ const migrateConfig = { return state } }, - '91': (state: RootState) => { + '91': (state: MigrationState) => { try { // @ts-ignore eslint-disable-next-line state.settings.codeCacheable = false @@ -1348,7 +1351,7 @@ const migrateConfig = { return state } }, - '92': (state: RootState) => { + '92': (state: MigrationState) => { try { addMiniApp(state, 'dangbei') state.llm.providers = moveProvider(state.llm.providers, 'qiniu', 12) @@ -1357,7 +1360,7 @@ const migrateConfig = { return state } }, - '93': (state: RootState) => { + '93': (state: MigrationState) => { try { if (!state?.settings?.exportMenuOptions) { state.settings.exportMenuOptions = settingsInitialState.exportMenuOptions @@ -1368,7 +1371,7 @@ const migrateConfig = { return state } }, - '94': (state: RootState) => { + '94': (state: MigrationState) => { try { state.settings.enableQuickPanelTriggers = false return state @@ -1376,7 +1379,7 @@ const migrateConfig = { return state } }, - '95': (state: RootState) => { + '95': (state: MigrationState) => { try { addWebSearchProvider(state, 'local-google') addWebSearchProvider(state, 'local-bing') @@ -1397,7 +1400,7 @@ const migrateConfig = { return state } }, - '96': (state: RootState) => { + '96': (state: MigrationState) => { try { // @ts-ignore eslint-disable-next-line state.settings.assistantIconType = state.settings?.showAssistantIcon ? 'model' : 'emoji' @@ -1408,7 +1411,7 @@ const migrateConfig = { return state } }, - '97': (state: RootState) => { + '97': (state: MigrationState) => { try { addMiniApp(state, 'zai') state.settings.webdavMaxBackups = 0 @@ -1423,7 +1426,7 @@ const migrateConfig = { return state } }, - '98': (state: RootState) => { + '98': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.type === 'openai' && provider.id !== 'openai') { @@ -1436,7 +1439,7 @@ const migrateConfig = { return state } }, - '99': (state: RootState) => { + '99': (state: MigrationState) => { try { state.settings.showPrompt = true @@ -1468,7 +1471,7 @@ const migrateConfig = { return state } }, - '100': (state: RootState) => { + '100': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { // @ts-ignore eslint-disable-next-line @@ -1488,7 +1491,7 @@ const migrateConfig = { return state } }, - '101': (state: RootState) => { + '101': (state: MigrationState) => { try { state.assistants.assistants.forEach((assistant) => { if (assistant.settings) { @@ -1516,7 +1519,7 @@ const migrateConfig = { return state } }, - '102': (state: RootState) => { + '102': (state: MigrationState) => { try { state.settings.openAI = { // @ts-expect-error it's a removed type. migrated on 177 @@ -1568,7 +1571,7 @@ const migrateConfig = { return state } }, - '103': (state: RootState) => { + '103': (state: MigrationState) => { try { if (state.shortcuts) { if (!state.shortcuts.shortcuts.find((shortcut) => shortcut.key === 'search_message_in_chat')) { @@ -1597,7 +1600,7 @@ const migrateConfig = { return state } }, - '104': (state: RootState) => { + '104': (state: MigrationState) => { try { addProvider(state, 'burncloud') state.llm.providers = moveProvider(state.llm.providers, 'burncloud', 10) @@ -1607,7 +1610,7 @@ const migrateConfig = { return state } }, - '105': (state: RootState) => { + '105': (state: MigrationState) => { try { state.settings.notification = settingsInitialState.notification addMiniApp(state, 'google') @@ -1625,7 +1628,7 @@ const migrateConfig = { return state } }, - '106': (state: RootState) => { + '106': (state: MigrationState) => { try { addProvider(state, 'tokenflux') state.llm.providers = moveProvider(state.llm.providers, 'tokenflux', 15) @@ -1635,7 +1638,7 @@ const migrateConfig = { return state } }, - '107': (state: RootState) => { + '107': (state: MigrationState) => { try { if (state.paintings && !state.paintings.dmxapi_paintings) { state.paintings.dmxapi_paintings = [] @@ -1646,7 +1649,7 @@ const migrateConfig = { return state } }, - '108': (state: RootState) => { + '108': (state: MigrationState) => { try { // @ts-ignore state.inputTools.toolOrder = DEFAULT_TOOL_ORDER @@ -1657,7 +1660,7 @@ const migrateConfig = { return state } }, - '109': (state: RootState) => { + '109': (state: MigrationState) => { try { state.settings.userTheme = settingsInitialState.userTheme return state @@ -1666,7 +1669,7 @@ const migrateConfig = { return state } }, - '110': (state: RootState) => { + '110': (state: MigrationState) => { try { if (state.paintings && !state.paintings.tokenflux_paintings) { state.paintings.tokenflux_paintings = [] @@ -1678,7 +1681,7 @@ const migrateConfig = { return state } }, - '111': (state: RootState) => { + '111': (state: MigrationState) => { try { addSelectionAction(state, 'quote') if ( @@ -1697,7 +1700,7 @@ const migrateConfig = { return state } }, - '112': (state: RootState) => { + '112': (state: MigrationState) => { try { addProvider(state, 'cephalon') addProvider(state, '302ai') @@ -1711,7 +1714,7 @@ const migrateConfig = { return state } }, - '113': (state: RootState) => { + '113': (state: MigrationState) => { try { addProvider(state, 'vertexai') if (!state.llm.settings.vertexai) { @@ -1729,7 +1732,7 @@ const migrateConfig = { return state } }, - '114': (state: RootState) => { + '114': (state: MigrationState) => { try { if (state.settings && state.settings.exportMenuOptions) { if (typeof state.settings.exportMenuOptions.plain_text === 'undefined') { @@ -1746,7 +1749,7 @@ const migrateConfig = { return state } }, - '115': (state: RootState) => { + '115': (state: MigrationState) => { try { state.assistants.assistants.forEach((assistant) => { if (!assistant.settings) { @@ -1767,7 +1770,7 @@ const migrateConfig = { return state } }, - '116': (state: RootState) => { + '116': (state: MigrationState) => { try { if (state.websearch) { // migrate contentLimit to cutoffLimit @@ -1799,7 +1802,7 @@ const migrateConfig = { return state } }, - '117': (state: RootState) => { + '117': (state: MigrationState) => { try { const ppioProvider = state.llm.providers.find((provider) => provider.id === 'ppio') const modelsToRemove = [ @@ -1837,7 +1840,7 @@ const migrateConfig = { return state } }, - '118': (state: RootState) => { + '118': (state: MigrationState) => { try { addProvider(state, 'ph8') state.llm.providers = moveProvider(state.llm.providers, 'ph8', 14) @@ -1858,7 +1861,7 @@ const migrateConfig = { return state } }, - '119': (state: RootState) => { + '119': (state: MigrationState) => { try { addProvider(state, 'new-api') state.llm.providers = moveProvider(state.llm.providers, 'new-api', 16) @@ -1876,7 +1879,7 @@ const migrateConfig = { return state } }, - '120': (state: RootState) => { + '120': (state: MigrationState) => { try { // migrate to remove memory feature from sidebar (moved to settings) if (state.settings && state.settings.sidebarIcons) { @@ -1924,7 +1927,7 @@ const migrateConfig = { return state } }, - '121': (state: RootState) => { + '121': (state: MigrationState) => { try { const { toolOrder } = state.inputTools const urlContextKey = 'url_context' @@ -1962,7 +1965,7 @@ const migrateConfig = { return state } }, - '122': (state: RootState) => { + '122': (state: MigrationState) => { try { state.settings.navbarPosition = 'left' return state @@ -1972,7 +1975,7 @@ const migrateConfig = { } }, - '123': (state: RootState) => { + '123': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { provider.models.forEach((model) => { @@ -1997,7 +2000,7 @@ const migrateConfig = { return state } }, // 1.5.4 - '124': (state: RootState) => { + '124': (state: MigrationState) => { try { state.assistants.assistants.forEach((assistant) => { if (assistant.settings && !assistant.settings.toolUseMode) { @@ -2046,7 +2049,7 @@ const migrateConfig = { return state } }, - '125': (state: RootState) => { + '125': (state: MigrationState) => { try { // Initialize API server configuration if not present if (!state.settings.apiServer) { @@ -2063,7 +2066,7 @@ const migrateConfig = { return state } }, - '126': (state: RootState) => { + '126': (state: MigrationState) => { try { state.knowledge.bases.forEach((base) => { // @ts-ignore eslint-disable-next-line @@ -2085,7 +2088,7 @@ const migrateConfig = { return state } }, - '127': (state: RootState) => { + '127': (state: MigrationState) => { try { addProvider(state, 'poe') @@ -2126,7 +2129,7 @@ const migrateConfig = { return state } }, - '128': (state: RootState) => { + '128': (state: MigrationState) => { try { // 迁移 service tier 设置 const openai = state.llm.providers.find((provider) => provider.id === SystemProviderIds.openai) @@ -2152,7 +2155,7 @@ const migrateConfig = { return state } }, - '129': (state: RootState) => { + '129': (state: MigrationState) => { try { // 聚合 api options state.llm.providers.forEach((p) => { @@ -2174,7 +2177,7 @@ const migrateConfig = { return state } }, - '130': (state: RootState) => { + '130': (state: MigrationState) => { try { if (state.settings && state.settings.openAI && !state.settings.openAI.verbosity) { state.settings.openAI.verbosity = 'medium' @@ -2189,7 +2192,7 @@ const migrateConfig = { return state } }, - '131': (state: RootState) => { + '131': (state: MigrationState) => { try { state.settings.mathEnableSingleDollar = true return state @@ -2198,7 +2201,7 @@ const migrateConfig = { return state } }, - '132': (state: RootState) => { + '132': (state: MigrationState) => { try { state.llm.providers.forEach((p) => { // 如果原本是undefined则不做改动,静默从默认支持改为默认不支持 @@ -2215,7 +2218,7 @@ const migrateConfig = { return state } }, - '133': (state: RootState) => { + '133': (state: MigrationState) => { try { state.settings.sidebarIcons.visible.push('code_tools') if (state.codeTools) { @@ -2231,7 +2234,7 @@ const migrateConfig = { return state } }, - '134': (state: RootState) => { + '134': (state: MigrationState) => { try { state.llm.quickModel = state.llm.topicNamingModel @@ -2241,7 +2244,7 @@ const migrateConfig = { return state } }, - '135': (state: RootState) => { + '135': (state: MigrationState) => { try { if (!state.assistants.defaultAssistant.settings) { state.assistants.defaultAssistant.settings = DEFAULT_ASSISTANT_SETTINGS @@ -2254,7 +2257,7 @@ const migrateConfig = { return state } }, - '136': (state: RootState) => { + '136': (state: MigrationState) => { try { state.settings.sidebarIcons.visible = [...new Set(state.settings.sidebarIcons.visible)].filter((icon) => DefaultPreferences.default['ui.sidebar.icons.visible'].includes(icon) @@ -2268,7 +2271,7 @@ const migrateConfig = { return state } }, - '137': (state: RootState) => { + '137': (state: MigrationState) => { try { state.ocr = { providers: BUILTIN_OCR_PROVIDERS, @@ -2281,7 +2284,7 @@ const migrateConfig = { return state } }, - '138': (state: RootState) => { + '138': (state: MigrationState) => { try { addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.system) return state @@ -2290,7 +2293,7 @@ const migrateConfig = { return state } }, - '139': (state: RootState) => { + '139': (state: MigrationState) => { try { addProvider(state, 'cherryin') state.llm.providers = moveProvider(state.llm.providers, 'cherryin', 1) @@ -2330,7 +2333,7 @@ const migrateConfig = { return state } }, - '140': (state: RootState) => { + '140': (state: MigrationState) => { try { // @ts-ignore state.paintings = { @@ -2360,7 +2363,7 @@ const migrateConfig = { return state } }, - '141': (state: RootState) => { + '141': (state: MigrationState) => { try { if (state.settings && state.settings.sidebarIcons) { // Check if 'notes' is not already in visible icons @@ -2374,7 +2377,7 @@ const migrateConfig = { return state } }, - '142': (state: RootState) => { + '142': (state: MigrationState) => { try { // Initialize notes settings if not present if (!state.note) { @@ -2386,7 +2389,7 @@ const migrateConfig = { return state } }, - '143': (state: RootState) => { + '143': (state: MigrationState) => { try { addMiniApp(state, 'longcat') return state @@ -2394,7 +2397,7 @@ const migrateConfig = { return state } }, - '144': (state: RootState) => { + '144': (state: MigrationState) => { try { if (state.settings) { state.settings.confirmDeleteMessage = settingsInitialState.confirmDeleteMessage @@ -2406,7 +2409,7 @@ const migrateConfig = { return state } }, - '145': (state: RootState) => { + '145': (state: MigrationState) => { try { if (state.settings) { if (state.settings.showMessageOutline === undefined || state.settings.showMessageOutline === null) { @@ -2419,7 +2422,7 @@ const migrateConfig = { return state } }, - '146': (state: RootState) => { + '146': (state: MigrationState) => { try { // Migrate showWorkspace from settings to note store if (state.settings && state.note) { @@ -2442,7 +2445,7 @@ const migrateConfig = { return state } }, - '147': (state: RootState) => { + '147': (state: MigrationState) => { try { state.knowledge.bases.forEach((base) => { if ((base as any).framework) { @@ -2455,7 +2458,7 @@ const migrateConfig = { return state } }, - '148': (state: RootState) => { + '148': (state: MigrationState) => { try { addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.paddleocr) return state @@ -2464,7 +2467,7 @@ const migrateConfig = { return state } }, - '149': (state: RootState) => { + '149': (state: MigrationState) => { try { state.knowledge.bases.forEach((base) => { if ((base as any).framework) { @@ -2477,7 +2480,7 @@ const migrateConfig = { return state } }, - '150': (state: RootState) => { + '150': (state: MigrationState) => { try { addShortcuts(state, ['rename_topic'], 'new_topic') addShortcuts(state, ['edit_last_user_message'], 'copy_last_message') @@ -2487,7 +2490,7 @@ const migrateConfig = { return state } }, - '151': (state: RootState) => { + '151': (state: MigrationState) => { try { if (state.settings) { state.settings.codeFancyBlock = true @@ -2498,7 +2501,7 @@ const migrateConfig = { return state } }, - '152': (state: RootState) => { + '152': (state: MigrationState) => { try { state.translate.settings = { autoCopy: false @@ -2509,7 +2512,7 @@ const migrateConfig = { return state } }, - '153': (state: RootState) => { + '153': (state: MigrationState) => { try { if (state.note.settings) { state.note.settings.fontSize = notesInitialState.settings.fontSize @@ -2521,7 +2524,7 @@ const migrateConfig = { return state } }, - '154': (state: RootState) => { + '154': (state: MigrationState) => { try { if (state.settings.userTheme) { state.settings.userTheme.userFontFamily = settingsInitialState.userTheme.userFontFamily @@ -2533,7 +2536,7 @@ const migrateConfig = { return state } }, - '155': (state: RootState) => { + '155': (state: MigrationState) => { try { state.knowledge.bases.forEach((base) => { if ((base as any).framework) { @@ -2546,7 +2549,7 @@ const migrateConfig = { return state } }, - '156': (state: RootState) => { + '156': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.anthropic) { @@ -2561,7 +2564,7 @@ const migrateConfig = { return state } }, - '157': (state: RootState) => { + '157': (state: MigrationState) => { try { addProvider(state, 'aionly') state.llm.providers = moveProvider(state.llm.providers, 'aionly', 10) @@ -2613,7 +2616,7 @@ const migrateConfig = { return state } }, - '158': (state: RootState) => { + '158': (state: MigrationState) => { try { state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'cherryin') addProvider(state, 'longcat') @@ -2623,7 +2626,7 @@ const migrateConfig = { return state } }, - '159': (state: RootState) => { + '159': (state: MigrationState) => { try { addProvider(state, 'ovms') fixMissingProvider(state) @@ -2633,7 +2636,7 @@ const migrateConfig = { return state } }, - '161': (state: RootState) => { + '161': (state: MigrationState) => { try { removeMiniAppFromState(state, 'nm-search') removeMiniAppFromState(state, 'hika') @@ -2646,7 +2649,7 @@ const migrateConfig = { return state } }, - '167': (state: RootState) => { + '167': (state: MigrationState) => { try { addProvider(state, 'huggingface') return state @@ -2655,7 +2658,7 @@ const migrateConfig = { return state } }, - '168': (state: RootState) => { + '168': (state: MigrationState) => { try { addPreprocessProviders(state, 'open-mineru') return state @@ -2664,7 +2667,7 @@ const migrateConfig = { return state } }, - '169': (state: RootState) => { + '169': (state: MigrationState) => { try { if (state?.mcp?.servers) { state.mcp.servers = state.mcp.servers.map((server) => { @@ -2681,7 +2684,7 @@ const migrateConfig = { return state } }, - '170': (state: RootState) => { + '170': (state: MigrationState) => { try { addProvider(state, 'sophnet') state.llm.providers = moveProvider(state.llm.providers, 'sophnet', 17) @@ -2692,7 +2695,7 @@ const migrateConfig = { return state } }, - '171': (state: RootState) => { + '171': (state: MigrationState) => { try { // Ensure aws-bedrock provider exists addProvider(state, 'aws-bedrock') @@ -2715,7 +2718,7 @@ const migrateConfig = { return state } }, - '172': (state: RootState) => { + '172': (state: MigrationState) => { try { // Add ling and huggingchat mini apps addMiniApp(state, 'ling') @@ -2807,7 +2810,7 @@ const migrateConfig = { return state } }, - '173': (state: RootState) => { + '173': (state: MigrationState) => { try { // Migrate toolOrder from global state to scope-based state if (state.inputTools && !state.inputTools.sessionToolOrder) { @@ -2819,7 +2822,7 @@ const migrateConfig = { return state } }, - '174': (state: RootState) => { + '174': (state: MigrationState) => { try { addProvider(state, SystemProviderIds.longcat) @@ -2836,7 +2839,7 @@ const migrateConfig = { return state } }, - '175': (state: RootState) => { + '175': (state: MigrationState) => { try { state.assistants.assistants.forEach((assistant) => { // @ts-ignore @@ -2857,7 +2860,7 @@ const migrateConfig = { return state } }, - '176': (state: RootState) => { + '176': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.qiniu) { @@ -2873,7 +2876,7 @@ const migrateConfig = { return state } }, - '177': (state: RootState) => { + '177': (state: MigrationState) => { try { // @ts-expect-error it's a removed type if (state.settings.openAI.summaryText === 'off') { @@ -2886,7 +2889,7 @@ const migrateConfig = { return state } }, - '178': (state: RootState) => { + '178': (state: MigrationState) => { try { const groq = state.llm.providers.find((p) => p.id === SystemProviderIds.groq) if (groq) { @@ -2899,7 +2902,7 @@ const migrateConfig = { return state } }, - '179': (state: RootState) => { + '179': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { switch (provider.id) { @@ -2921,7 +2924,7 @@ const migrateConfig = { return state } }, - '181': (state: RootState) => { + '181': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'ai-gateway') { @@ -2945,7 +2948,7 @@ const migrateConfig = { return state } }, - '182': (state: RootState) => { + '182': (state: MigrationState) => { try { // Initialize streamOptions in settings.openAI if not exists if (!state.settings.openAI.streamOptions) { @@ -2960,7 +2963,7 @@ const migrateConfig = { return state } }, - '183': (state: RootState) => { + '183': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.cherryin) { @@ -2976,7 +2979,7 @@ const migrateConfig = { return state } }, - '184': (state: RootState) => { + '184': (state: MigrationState) => { try { // Add exa-mcp (free) web search provider if not exists const exaMcpExists = state.websearch.providers.some((p) => p.id === 'exa-mcp') @@ -3001,7 +3004,7 @@ const migrateConfig = { return state } }, - '185': (state: RootState) => { + '185': (state: MigrationState) => { try { // Reset toolUseMode to function for default assistant if (state.assistants.defaultAssistant.settings?.toolUseMode) { @@ -3022,7 +3025,7 @@ const migrateConfig = { return state } }, - '186': (state: RootState) => { + '186': (state: MigrationState) => { try { if (state.settings.apiServer) { state.settings.apiServer.host = API_SERVER_DEFAULTS.HOST @@ -3047,7 +3050,7 @@ const migrateConfig = { return state } }, - '187': (state: RootState) => { + '187': (state: MigrationState) => { try { state.assistants.assistants.forEach((assistant) => { if (assistant.settings && assistant.settings.reasoning_effort === undefined) { @@ -3063,7 +3066,7 @@ const migrateConfig = { } }, // 1.7.7 - '188': (state: RootState) => { + '188': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.openrouter) { @@ -3078,7 +3081,7 @@ const migrateConfig = { } }, // 1.7.7 - '189': (state: RootState) => { + '189': (state: MigrationState) => { try { void window.api.memory.migrateMemoryDb() // @ts-ignore @@ -3107,7 +3110,7 @@ const migrateConfig = { } }, // 1.7.8 - '190': (state: RootState) => { + '190': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.ollama) { @@ -3121,7 +3124,7 @@ const migrateConfig = { return state } }, - '191': (state: RootState) => { + '191': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'tokenflux') { @@ -3136,7 +3139,7 @@ const migrateConfig = { return state } }, - '192': (state: RootState) => { + '192': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === '302ai') { @@ -3151,7 +3154,7 @@ const migrateConfig = { return state } }, - '193': (state: RootState) => { + '193': (state: MigrationState) => { try { addPreprocessProviders(state, 'paddleocr') logger.info('migrate 193 success') @@ -3161,7 +3164,7 @@ const migrateConfig = { return state } }, - '194': (state: RootState) => { + '194': (state: MigrationState) => { try { const GLM_4_5_FLASH_MODEL = 'glm-4.5-flash' if (state.llm.defaultModel?.provider === 'cherryai' && state.llm.defaultModel?.id === GLM_4_5_FLASH_MODEL) { @@ -3189,7 +3192,7 @@ const migrateConfig = { return state } }, - '195': (state: RootState) => { + '195': (state: MigrationState) => { try { if (state.settings && state.settings.sidebarIcons) { // Add 'openclaw' to visible icons if not already present @@ -3204,7 +3207,7 @@ const migrateConfig = { return state } }, - '196': (state: RootState) => { + '196': (state: MigrationState) => { try { if (state.paintings && !state.paintings.ppio_draw) { state.paintings.ppio_draw = [] @@ -3219,7 +3222,7 @@ const migrateConfig = { return state } }, - '197': (state: RootState) => { + '197': (state: MigrationState) => { try { if (state.openclaw?.gatewayPort === 18789) { state.openclaw.gatewayPort = 18790 @@ -3231,7 +3234,7 @@ const migrateConfig = { return state } }, - '198': (state: RootState) => { + '198': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'minimax') { @@ -3245,7 +3248,7 @@ const migrateConfig = { return state } }, - '199': (state: RootState) => { + '199': (state: MigrationState) => { try { addShortcuts(state, ['select_model'], 'toggle_new_context') return state @@ -3254,7 +3257,7 @@ const migrateConfig = { return state } }, - '200': (state: RootState) => { + '200': (state: MigrationState) => { try { state.llm.providers.forEach((provider) => { if (provider.type === 'ollama') { @@ -3291,7 +3294,7 @@ const migrateConfig = { return state } }, - '201': (state: RootState) => { + '201': (state: MigrationState) => { try { addWebSearchProvider(state, 'querit') return state @@ -3300,7 +3303,7 @@ const migrateConfig = { return state } }, - '202': (state: RootState) => { + '202': (state: MigrationState) => { try { const filesystemServer = state.mcp?.servers?.find((s: any) => s.name === '@cherry/filesystem') if (filesystemServer && filesystemServer.disabledAutoApproveTools === undefined) { @@ -3312,7 +3315,7 @@ const migrateConfig = { return state } }, - '203': (state: RootState) => { + '203': (state: MigrationState) => { try { if (state.settings && state.settings.sidebarIcons) { // Add 'agents' to visible icons if not already present @@ -3345,7 +3348,7 @@ const migrateConfig = { return state } }, - '204': (state: RootState) => { + '204': (state: MigrationState) => { try { if (state.llm.defaultModel?.provider === 'cherryai') { state.llm.defaultModel = qwenModel @@ -3371,7 +3374,7 @@ const migrateConfig = { return state } }, - '205': (state: RootState) => { + '205': (state: MigrationState) => { try { localStorage.setItem('onboarding-completed', 'true') @@ -3392,7 +3395,7 @@ const migrateConfig = { return state } }, - '206': (state: RootState) => { + '206': (state: MigrationState) => { try { const { sessionToolOrder } = state.inputTools const permissionModeKey = 'permission_mode' diff --git a/src/renderer/src/store/shortcuts.ts b/src/renderer/src/store/shortcuts.ts index fc2009100ba..1d2af71b608 100644 --- a/src/renderer/src/store/shortcuts.ts +++ b/src/renderer/src/store/shortcuts.ts @@ -151,31 +151,21 @@ const initialState: ShortcutsState = { ] } -const getSerializableShortcuts = (shortcuts: Shortcut[]) => { - return shortcuts.map((shortcut) => ({ - key: shortcut.key, - shortcut: [...shortcut.shortcut], - enabled: shortcut.enabled, - system: shortcut.system, - editable: shortcut.editable - })) -} - const shortcutsSlice = createSlice({ name: 'shortcuts', initialState, reducers: { updateShortcut: (state, action: PayloadAction) => { state.shortcuts = state.shortcuts.map((s) => (s.key === action.payload.key ? action.payload : s)) - void window.api.shortcuts.update(getSerializableShortcuts(state.shortcuts)) + // Shortcuts are now managed via PreferenceService — this slice is kept only for migration }, toggleShortcut: (state, action: PayloadAction) => { state.shortcuts = state.shortcuts.map((s) => (s.key === action.payload ? { ...s, enabled: !s.enabled } : s)) - void window.api.shortcuts.update(getSerializableShortcuts(state.shortcuts)) + // Shortcuts are now managed via PreferenceService — this slice is kept only for migration }, resetShortcuts: (state) => { state.shortcuts = initialState.shortcuts - void window.api.shortcuts.update(getSerializableShortcuts(state.shortcuts)) + // Shortcuts are now managed via PreferenceService — this slice is kept only for migration } } }) 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..3966654c33c --- /dev/null +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -0,0 +1,553 @@ +# Cherry Studio 快捷键系统重构设计文档 + +> 版本:v3.0(v2 Preference 架构) +> 更新日期:2026-04-03 +> 分支:`refactor/shortcut` + +## 目录 + +- [背景与目标](#背景与目标) +- [核心设计原则](#核心设计原则) +- [架构总览](#架构总览) +- [分层详解](#分层详解) +- [类型系统](#类型系统) +- [数据流](#数据流) +- [默认快捷键一览](#默认快捷键一览) +- [扩展指南](#扩展指南) +- [迁移清单](#迁移清单) +- [测试覆盖](#测试覆盖) +- [后续演进方向](#后续演进方向) + +--- + +## 背景与目标 + +### 旧版问题 + +v1 快捷键系统存在以下架构缺陷: + +| 问题 | 影响 | +|------|------| +| 双数据源(Redux store + `configManager`)| 主/渲染进程状态不一致 | +| `IpcChannel.Shortcuts_Update` 手动同步 | 多窗口场景下丢失更新 | +| `switch-case` 硬编码处理器 | 可维护性差,新增快捷键需改动多处 | +| 定义分散在多个文件 | 缺乏单一真相源 | +| 弱类型(`Record`)| 运行时类型不安全 | + +### 新版目标 + +- **单一真相源**:`SHORTCUT_DEFINITIONS` 数组为所有快捷键元数据的唯一来源 +- **Preference 优先**:运行时状态完全托管于 `preferenceService`(SQLite + 内存缓存 + IPC 广播) +- **全链路类型安全**:从定义到存储到消费,TypeScript 严格约束 +- **处理器注册表**:`Map` 替代 `switch-case` +- **三步扩展**:新增快捷键仅需「定义 → Schema 默认值 → 注册使用」 +- **多窗口自动同步**:借助 `preferenceService` 的 IPC 广播机制 +- **平台感知**:`supportedPlatforms` 字段过滤不支持的系统快捷键 + +--- + +## 核心设计原则 + +1. **关注点分离** — 定义层(静态元数据)、偏好层(用户配置)、服务层(注册与生命周期)、UI 层(展示与编辑)各司其职 +2. **复用基础设施** — 所有持久化依赖 `preferenceService`,不引入新的存储通道 +3. **防御性 coerce** — 所有偏好读取均经过 `coerceShortcutPreference` 归一化,保证缺失字段有合理 fallback +4. **声明式驱动** — 注册逻辑遍历 `SHORTCUT_DEFINITIONS`,不硬编码具体快捷键 + +--- + +## 架构总览 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Shortcut System v3 │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 📋 Shared Definition Layer │ │ +│ │ packages/shared/shortcuts/ │ │ +│ │ ├── types.ts 类型定义 │ │ +│ │ ├── definitions.ts SHORTCUT_DEFINITIONS (真相之源) │ │ +│ │ └── utils.ts 转换 / 校验 / coerce 工具 │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 💾 Preference Layer │ │ +│ │ packages/shared/data/preference/ │ │ +│ │ ├── preferenceSchemas.ts 默认值 (enabled + key) │ │ +│ │ └── preferenceTypes.ts PreferenceShortcutType │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌────────────────────┐ ┌──────────────────────────┐ │ +│ │ ⚙️ Main Process │ │ 🖥️ Renderer Process │ │ +│ │ ShortcutService │ │ useShortcut │ │ +│ │ ├ Handler Map │ │ useShortcutDisplay │ │ +│ │ ├ Focus/Blur 生命周期│ │ useAllShortcuts │ │ +│ │ ├ Preference 订阅 │ │ (react-hotkeys-hook) │ │ +│ │ └ globalShortcut │ └──────────────────────────┘ │ +│ └────────────────────┘ │ │ +│ ▼ │ +│ ┌──────────────────────┐ │ +│ │ 🎨 UI Layer │ │ +│ │ ShortcutSettings │ │ +│ │ ├ 录制 / 清空 / 重置 │ │ +│ │ ├ 冲突检测 │ │ +│ │ └ 启用 / 禁用 │ │ +│ └──────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 分层详解 + +### 1. 共享定义层 (`packages/shared/shortcuts/`) + +#### `definitions.ts` — 单一真相源 + +所有快捷键以 `ShortcutDefinition[]` 数组集中声明,每条定义包含完整的静态元数据: + +```typescript +{ + key: 'shortcut.app.show_mini_window', // Preference key + defaultKey: ['CommandOrControl', 'E'], // Electron accelerator 格式 + scope: 'main', // main | renderer | both + category: 'app', // app | chat | topic | selection + system: true, // 系统级(不可删除绑定) + persistOnBlur: true, // 窗口失焦后仍然生效 + enabledWhen: (get) => !!get('feature.quick_assistant.enabled'), + supportedPlatforms: ['darwin', 'win32'] +} +``` + +**关键字段说明:** + +| 字段 | 用途 | +|------|------| +| `scope` | 决定快捷键注册在哪个进程:`main`(globalShortcut)、`renderer`(react-hotkeys-hook)、`both`(两者都注册) | +| `persistOnBlur` | 窗口失焦时是否保留注册(如 `show_main_window` 需要在任何时候响应) | +| `enabledWhen` | 动态启用条件,接收 `getPreference` 函数,在注册时求值 | +| `supportedPlatforms` | 限制快捷键仅在指定操作系统上注册和显示 | +| `editable` | 设为 `false` 表示用户不可修改绑定(如 Escape 退出全屏) | +| `variants` | 同一快捷键的多组绑定(如 zoom_in 同时绑定 `=` 和小键盘 `+`) | + +#### `types.ts` — 类型体系 + +```typescript +// 从 PreferenceKeyType 推导出所有 shortcut.* 前缀的 key +type ShortcutPreferenceKey = Extract + +// 去掉 shortcut. 前缀的短 key,用于调用侧简化 +type ShortcutKey = ShortcutPreferenceKey extends `shortcut.${infer Rest}` ? Rest : never + +// 运行时归一化后的完整状态 +interface ShortcutPreferenceValue { + binding: string[] // 生效的绑定(有值则用,否则 fallback 到 defaultKey) + rawBinding: string[] // 用户原始设置值(可能为空数组) + hasCustomBinding: boolean // 是否有用户自定义绑定 + enabled: boolean + editable: boolean // 来自 definition.editable + system: boolean // 来自 definition.system +} +``` + +`ShortcutKey` 类型使得调用侧可以使用短 key: + +```typescript +// 两种写法等价,均有类型补全 +useShortcut('chat.clear', callback) +useShortcut('shortcut.chat.clear', callback) +``` + +#### `utils.ts` — 纯函数工具集 + +| 函数 | 职责 | +|------|------| +| `convertKeyToAccelerator` | DOM `event.code` → Electron accelerator 格式 | +| `convertAcceleratorToHotkey` | Electron accelerator → `react-hotkeys-hook` 字符串 | +| `formatShortcutDisplay` | accelerator → 用户友好的显示字符串(Mac 用符号,其他用文字) | +| `isValidShortcut` | 校验快捷键有效性(须含修饰键,或为特殊单键如 F1-F12、Escape) | +| `getDefaultShortcutPreference` | 从 `DefaultPreferences` 读取 schema 默认值并归一化 | +| `coerceShortcutPreference` | **核心归一化函数**:将任意偏好值 + 定义 → 完整的 `ShortcutPreferenceValue` | + +`coerceShortcutPreference` 的防御逻辑: + +``` +输入值为 null/undefined → 使用 schema 默认值 +输入的 key 为空数组 → binding 回退到 defaultKey,rawBinding 保留空数组 +输入的 enabled 非布尔 → 使用默认 enabled +editable/system → 始终从 definition 读取(不存储在偏好中) +``` + +### 2. 偏好层 (`preferenceSchemas.ts` + `preferenceTypes.ts`) + +偏好值的存储结构经过精简,只持久化用户可变部分: + +```typescript +// PreferenceShortcutType — 存储在 SQLite 中的数据结构 +type PreferenceShortcutType = { + key: string[] // 用户自定义的键位绑定 + enabled: boolean // 启用/禁用 +} +``` + +**设计决策**:`editable` 和 `system` 不存储在偏好中,而是在运行时从 `ShortcutDefinition` 注入。这样修改定义不需要数据迁移。 + +`preferenceSchemas.ts` 中为每个快捷键声明默认值: + +```typescript +'shortcut.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, +'shortcut.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, +``` + +### 3. 主进程服务层 (`ShortcutService`) + +基于 v2 Lifecycle 架构实现,使用 `@Injectable`、`BaseService`、`@DependsOn` 等装饰器: + +```typescript +@Injectable('ShortcutService') +@ServicePhase(Phase.WhenReady) +@DependsOn(['WindowService', 'SelectionService', 'PreferenceService']) +export class ShortcutService extends BaseService { ... } +``` + +#### 处理器注册表 + +使用 `Map` 存储处理器,在 `onInit` 时注册所有内置处理器: + +```typescript +private handlers = new Map() + +// 注册示例 +this.handlers.set('shortcut.app.zoom_in', (window) => { + if (window) handleZoomFactor([window], 0.1) +}) +``` + +#### 窗口生命周期管理 + +``` +窗口创建 → registerForWindow(window) + ├── 首次创建 + tray_on_launch → ready-to-show 时仅注册 persistOnBlur 快捷键 + ├── focus 事件 → registerShortcuts(window, false) 注册全部 + └── blur 事件 → registerShortcuts(window, true) 仅保留 persistOnBlur +``` + +`registerShortcuts` 的核心流程: + +1. `globalShortcut.unregisterAll()` 清空所有注册 +2. 遍历 `relevantDefinitions`(预过滤 `scope !== 'renderer'` 和 `supportedPlatforms`) +3. 对每个定义:读取偏好 → `coerceShortcutPreference` 归一化 → 检查 `enabled` + `enabledWhen` → 注册 handler +4. 如果定义有 `variants`,额外注册变体绑定 + +#### 偏好变更订阅 + +```typescript +this.preferenceUnsubscribers = relevantDefinitions.map((definition) => + preferenceService.subscribeChange(definition.key, () => { + this.reregisterShortcuts() // 整体重注册 + }) +) +``` + +### 4. 渲染进程 Hook 层 (`useShortcuts.ts`) + +提供三个核心 Hook: + +#### `useShortcut(key, callback, options)` + +注册渲染进程快捷键,核心逻辑: + +1. `toFullKey()` 支持短 key 和完整 key 两种写法 +2. `findShortcutDefinition()` 查找定义 +3. `usePreference()` 读取当前偏好值 +4. `coerceShortcutPreference()` 归一化 +5. 检查 `scope === 'main'` → 跳过(主进程快捷键不在渲染进程注册) +6. 检查 `enabled` → 禁用则 hotkey 设为 `'none'` +7. `convertAcceleratorToHotkey()` 转换格式 +8. 传递给 `react-hotkeys-hook` 的 `useHotkeys` + +```typescript +// 调用侧简洁用法 +useShortcut('chat.clear', () => clearChat()) +useShortcut('topic.new', () => createTopic(), { enableOnFormTags: false }) +``` + +**Options:** + +| 选项 | 默认值 | 说明 | +|------|--------|------| +| `preventDefault` | `true` | 阻止浏览器默认行为 | +| `enableOnFormTags` | `true` | 在 input/textarea 中是否生效 | +| `enabled` | `true` | 外部控制启用/禁用 | +| `enableOnContentEditable` | `false` | 在 contentEditable 元素中是否生效 | + +#### `useShortcutDisplay(key)` + +返回格式化后的快捷键显示字符串,用于 UI 提示(如 Tooltip): + +```typescript +const display = useShortcutDisplay('chat.clear') +// Mac: "⌘L" Windows: "Ctrl+L" +``` + +#### `useAllShortcuts()` + +供设置页使用,批量读取所有快捷键配置: + +- 使用 `useMultiplePreferences()` 一次性读取所有 `shortcut.*` 偏好 +- 返回 `ShortcutListItem[]`,每项包含 `definition`、`preference`、`defaultPreference`、`updatePreference` +- `updatePreference` 内部使用 `buildNextPreference` 合并 patch,仅写入 `{ key, enabled }` + +### 5. UI 层 (`ShortcutSettings.tsx`) + +设置页面直接消费 `useAllShortcuts()` Hook,支持以下操作: + +| 功能 | 实现 | +|------|------| +| **平台过滤** | 根据 `supportedPlatforms` 过滤不支持的快捷键 | +| **快捷键录制** | `handleKeyDown` 捕获键盘事件 → `convertKeyToAccelerator` → `isValidShortcut` 校验 | +| **冲突检测** | `isDuplicateShortcut` 检查已显示快捷键中是否存在相同绑定 | +| **清空绑定** | `updatePreference({ key: [] })` | +| **重置单项** | 写入 `defaultPreference` 的 `binding` + `enabled` | +| **重置全部** | `preferenceService.setMultiple()` 批量写入所有默认值 | +| **启用/禁用** | `updatePreference({ enabled: !current })` | +| **修改标记** | `isShortcutModified` 比对当前值与默认值,决定重置按钮是否可用 | + +--- + +## 类型系统 + +### 类型推导链 + +``` +preferenceSchemas.ts 中声明 key + ↓ 代码生成 +PreferenceKeyType(所有偏好 key 的联合类型) + ↓ Extract<..., `shortcut.${string}`> +ShortcutPreferenceKey(如 'shortcut.chat.clear') + ↓ Template literal infer +ShortcutKey(如 'chat.clear') +``` + +### 调用侧类型安全 + +```typescript +// ✅ 编译通过 — 'chat.clear' 是合法的 ShortcutKey +useShortcut('chat.clear', callback) + +// ✅ 编译通过 — 完整 key 也被接受 +useShortcut('shortcut.chat.clear', callback) + +// ❌ 编译报错 — 'chat.invalid' 不在 ShortcutKey 联合类型中 +useShortcut('chat.invalid', callback) +``` + +--- + +## 数据流 + +### 启动阶段 + +``` +PreferenceService.initialize() + ↓ SQLite → 内存缓存 +ShortcutService.onInit() + ├── registerBuiltInHandlers() 注册 Map + ├── subscribeToPreferenceChanges() 订阅每个 shortcut.* key + └── registerForWindow(mainWindow) + ├── focus → registerShortcuts(window, false) + └── blur → registerShortcuts(window, true) +``` + +### 用户修改快捷键 + +``` +用户在设置页按下新快捷键 + ↓ handleKeyDown +convertKeyToAccelerator() + isValidShortcut() + isDuplicateShortcut() + ↓ 通过校验 +updatePreference({ key: newKeys }) + ↓ useMultiplePreferences.setValues() +preferenceService.set('shortcut.chat.clear', { key: [...], enabled: true }) + ├── SQLite 持久化 + ├── IPC 广播 → 所有渲染窗口自动更新 + └── subscribeChange 回调 → ShortcutService.reregisterShortcuts() + ↓ + globalShortcut.unregisterAll() → 按新配置重注册 +``` + +### 渲染进程快捷键触发 + +``` +用户按下 Cmd+L + ↓ react-hotkeys-hook +useHotkeys('mod+l', callback) + ↓ 匹配成功 +callback(event) // 如 clearChat() +``` + +### 主进程快捷键触发 + +``` +用户按下 Cmd+E(窗口失焦状态) + ↓ Electron globalShortcut +handlers.get('shortcut.app.show_mini_window') + ↓ +toggleMiniWindow() +``` + +--- + +## 默认快捷键一览 + +### 应用级 (`app`) + +| Preference Key | 默认绑定 | 作用域 | 备注 | +|---|---|---|---| +| `shortcut.app.show_main_window` | *(无)* | main | 失焦持久,系统级 | +| `shortcut.app.show_mini_window` | `Cmd/Ctrl+E` | main | 关联 quick_assistant 开关 | +| `shortcut.app.show_settings` | `Cmd/Ctrl+,` | both | 不可编辑 | +| `shortcut.app.toggle_show_assistants` | `Cmd/Ctrl+[` | renderer | | +| `shortcut.app.exit_fullscreen` | `Escape` | renderer | 不可编辑,系统级 | +| `shortcut.app.zoom_in` | `Cmd/Ctrl+=` | main | 含小键盘变体 | +| `shortcut.app.zoom_out` | `Cmd/Ctrl+-` | main | 含小键盘变体 | +| `shortcut.app.zoom_reset` | `Cmd/Ctrl+0` | main | | +| `shortcut.app.search_message` | `Cmd/Ctrl+Shift+F` | renderer | | + +### 聊天 (`chat`) + +| Preference Key | 默认绑定 | 默认启用 | 备注 | +|---|---|---|---| +| `shortcut.chat.clear` | `Cmd/Ctrl+L` | 是 | | +| `shortcut.chat.search_message` | `Cmd/Ctrl+F` | 是 | | +| `shortcut.chat.toggle_new_context` | `Cmd/Ctrl+K` | 是 | | +| `shortcut.chat.copy_last_message` | `Cmd/Ctrl+Shift+C` | 否 | | +| `shortcut.chat.edit_last_user_message` | `Cmd/Ctrl+Shift+E` | 否 | | +| `shortcut.chat.select_model` | `Cmd/Ctrl+Shift+M` | 是 | | + +### 话题 (`topic`) + +| Preference Key | 默认绑定 | +|---|---| +| `shortcut.topic.new` | `Cmd/Ctrl+N` | +| `shortcut.topic.rename` | `Cmd/Ctrl+T` | +| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl+]` | + +### 划词助手 (`selection`) + +| Preference Key | 默认绑定 | 支持平台 | +|---|---|---| +| `shortcut.selection.toggle_enabled` | *(无)* | macOS, Windows | +| `shortcut.selection.get_text` | *(无)* | macOS, Windows | + +--- + +## 扩展指南 + +### 新增一个快捷键(三步) + +**Step 1:声明 Schema 默认值** + +```typescript +// packages/shared/data/preference/preferenceSchemas.ts +'shortcut.chat.regenerate': { enabled: true, key: ['CommandOrControl', 'Shift', 'R'] }, +``` + +> 注意:类型声明区也需要添加对应的类型声明行。 + +**Step 2:添加静态定义** + +```typescript +// packages/shared/shortcuts/definitions.ts +{ + key: 'shortcut.chat.regenerate', + defaultKey: ['CommandOrControl', 'Shift', 'R'], + scope: 'renderer', + category: 'chat' +} +``` + +**Step 3:在目标模块使用** + +```typescript +// 渲染进程 +useShortcut('chat.regenerate', () => regenerateLastMessage()) + +// 或主进程(在 ShortcutService.registerBuiltInHandlers 中) +this.handlers.set('shortcut.chat.regenerate', () => { ... }) +``` + +### 条件启用 + +使用 `enabledWhen` 让快捷键根据其他偏好动态启用/禁用: + +```typescript +{ + key: 'shortcut.app.show_mini_window', + enabledWhen: (get) => !!get('feature.quick_assistant.enabled'), + // 当 quick_assistant 关闭时,此快捷键不会被注册 +} +``` + +### 平台限制 + +```typescript +{ + key: 'shortcut.selection.toggle_enabled', + supportedPlatforms: ['darwin', 'win32'], + // Linux 上不会注册,设置页也不会显示 +} +``` + +--- + +## 迁移清单 + +### 已移除的旧组件 + +| 旧组件 | 状态 | +|--------|------| +| Redux `shortcuts` slice | 从 `combineReducers` 移除,文件保留供数据迁移 `initialState` 使用 | +| `IpcChannel.Shortcuts_Update` | 已删除 | +| `window.api.shortcuts.update` (preload bridge) | 已删除 | +| `configManager.getShortcuts()` / `setShortcuts()` | 已删除 | +| `ConfigKeys.Shortcuts` | 已删除 | + +### 数据迁移 + +- `store/migrate.ts` 中引入 `MigrationState` 类型(`RootState & { shortcuts?: ShortcutsState }`),兼容旧 Redux 状态结构 +- 已有用户偏好通过 `PreferenceMigrator` 从旧 key 映射到新 `shortcut.*` key +- 未持久化的快捷键自动继承 `preferenceSchemas.ts` 中的默认值 + +--- + +## 测试覆盖 + +### 单元测试 (`packages/shared/__tests__/shortcutUtils.test.ts`) + +覆盖 `utils.ts` 中所有导出函数,共 19 个测试用例: + +| 测试组 | 覆盖内容 | +|--------|----------| +| `convertKeyToAccelerator` | 已知 key 映射、未知 key 透传 | +| `convertAcceleratorToHotkey` | 修饰键转换(CommandOrControl→mod, Ctrl→ctrl 等) | +| `formatShortcutDisplay` | Mac 符号格式(⌘⇧⌥⌃)、非 Mac 文字格式 | +| `isValidShortcut` | 空数组、含修饰键、特殊单键、普通单键 | +| `getDefaultShortcutPreference` | 默认值读取、`editable`/`system` 继承 | +| `coerceShortcutPreference` | null/undefined 回退、自定义 key、空数组回退、enabled 覆盖 | + +--- + +## 后续演进方向 + +1. **跨进程冲突检测** — 主进程与渲染进程联动校验绑定冲突并在设置页提示 +2. **导入/导出** — 允许用户批量备份和恢复自定义快捷键配置 +3. **多作用域绑定** — 同一逻辑按窗口类型或上下文切换不同绑定 +4. **i18n label 自动化** — 消除 `labelKeyMap` 硬编码,从 definition key 自动推导 i18n key +5. **E2E 快捷键测试** — 通过 Playwright 验证主进程 globalShortcut 的端到端行为 + +--- + +> 如需扩展或有疑问,请在仓库中提交 Issue。 From f101f42b4868605edace4eaaecd02d5a525681d8 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 7 Apr 2026 11:35:44 +0800 Subject: [PATCH 02/37] refactor: enhance shortcut management and zoom functionality in AppMenuService --- src/main/services/AppMenuService.ts | 51 +++++++++++++++++-- src/main/services/ShortcutService.ts | 15 +++--- .../src/pages/settings/ShortcutSettings.tsx | 32 ++++++------ 3 files changed, 71 insertions(+), 27 deletions(-) diff --git a/src/main/services/AppMenuService.ts b/src/main/services/AppMenuService.ts index 8a69de37f75..ebb8d030b23 100644 --- a/src/main/services/AppMenuService.ts +++ b/src/main/services/AppMenuService.ts @@ -1,10 +1,31 @@ import { application } from '@main/core/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 { coerceShortcutPreference } from '@shared/shortcuts/utils' import type { MenuItemConstructorOptions } from 'electron' import { app, Menu, shell } from 'electron' +const zoomShortcutKeys: ShortcutPreferenceKey[] = [ + 'shortcut.app.zoom_in', + 'shortcut.app.zoom_out', + 'shortcut.app.zoom_reset' +] + +const isShortcutEnabled = (key: ShortcutPreferenceKey): boolean => { + const definition = findShortcutDefinition(key) + if (!definition) return true + const rawPref = application.get('PreferenceService').get(key) as PreferenceShortcutType | undefined + return coerceShortcutPreference(definition, rawPref).enabled +} + +const getMainWindows = (): Electron.BrowserWindow[] => + [application.get('WindowService').getMainWindow()].filter(Boolean) as Electron.BrowserWindow[] + @Injectable('AppMenuService') @ServicePhase(Phase.WhenReady) @Conditional(onPlatform('darwin')) @@ -12,6 +33,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 +45,10 @@ export class AppMenuService extends BaseService { const locale = locales[getAppLanguage()] const { appMenu } = locale.translation + const zoomInEnabled = isShortcutEnabled('shortcut.app.zoom_in') + const zoomOutEnabled = isShortcutEnabled('shortcut.app.zoom_out') + const zoomResetEnabled = isShortcutEnabled('shortcut.app.zoom_reset') + const template: MenuItemConstructorOptions[] = [ { label: app.name, @@ -67,9 +97,24 @@ 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: zoomResetEnabled ? 'CommandOrControl+0' : undefined, + enabled: zoomResetEnabled, + click: () => handleZoomFactor(getMainWindows(), 0, true) + }, + { + label: appMenu.zoomIn, + accelerator: zoomInEnabled ? 'CommandOrControl+=' : undefined, + enabled: zoomInEnabled, + click: () => handleZoomFactor(getMainWindows(), 0.1) + }, + { + label: appMenu.zoomOut, + accelerator: zoomOutEnabled ? 'CommandOrControl+-' : undefined, + enabled: zoomOutEnabled, + 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 fc7dae3a48b..3284ef53519 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -28,7 +28,7 @@ export class ShortcutService extends BaseService { private windowOnHandlers = new Map void; onBlur: () => void }>() private isRegisterOnBoot = true private preferenceUnsubscribers: Array<() => void> = [] - private registeredAccelerators = new Set() + private registeredAccelerators = new Map() protected async onInit() { this.registerBuiltInHandlers() @@ -173,9 +173,10 @@ export class ShortcutService extends BaseService { } } - // Unregister shortcuts that are no longer needed - for (const accelerator of this.registeredAccelerators) { - if (!desired.has(accelerator)) { + // Unregister shortcuts that are no longer needed or have a different handler + for (const [accelerator, prevHandler] of this.registeredAccelerators) { + const entry = desired.get(accelerator) + if (!entry || entry.handler !== prevHandler) { try { globalShortcut.unregister(accelerator) } catch { @@ -185,7 +186,7 @@ export class ShortcutService extends BaseService { } } - // Register new shortcuts or re-register changed ones + // Register new or changed shortcuts for (const [accelerator, { handler, window: win }] of desired) { if (!this.registeredAccelerators.has(accelerator)) { try { @@ -193,7 +194,7 @@ export class ShortcutService extends BaseService { const targetWindow = win?.isDestroyed?.() ? undefined : win handler(targetWindow) }) - this.registeredAccelerators.add(accelerator) + this.registeredAccelerators.set(accelerator, handler) } catch (error) { logger.warn(`Failed to register shortcut ${accelerator}`) } @@ -218,7 +219,7 @@ export class ShortcutService extends BaseService { window.off('blur', handlers.onBlur) }) this.windowOnHandlers.clear() - for (const accelerator of this.registeredAccelerators) { + for (const accelerator of this.registeredAccelerators.keys()) { try { globalShortcut.unregister(accelerator) } catch { diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index 0521a7ccf3c..7a01a2c09bb 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -13,7 +13,7 @@ import type { InputRef } from 'antd' import { Input, Table as AntTable } from 'antd' import type { ColumnsType } from 'antd/es/table' import type { FC, KeyboardEvent as ReactKeyboardEvent } from 'react' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -165,23 +165,20 @@ const ShortcutSettings: FC = () => { setConflictLabel(null) } - const findDuplicateLabel = useCallback( - (keys: string[], currentKey: ShortcutPreferenceKey): string | null => { - const normalized = keys.map((key) => key.toLowerCase()).join('+') - - for (const record of displayedShortcuts) { - if (record.key === currentKey) continue - if (!record.enabled) continue - const binding = record.displayKeys - if (!binding.length) continue - if (binding.map((key) => key.toLowerCase()).join('+') === normalized) { - return record.label - } + const findDuplicateLabel = (keys: string[], currentKey: ShortcutPreferenceKey): string | null => { + const normalized = keys.map((key) => key.toLowerCase()).join('+') + + for (const record of displayedShortcuts) { + if (record.key === currentKey) continue + if (!record.enabled) continue + const binding = record.displayKeys + if (!binding.length) continue + if (binding.map((key) => key.toLowerCase()).join('+') === normalized) { + return record.label } - return null - }, - [displayedShortcuts] - ) + } + return null + } const usableEndKeys = (event: ReactKeyboardEvent): string | null => { const { code } = event @@ -383,6 +380,7 @@ const ShortcutSettings: FC = () => { onBlur={(event) => { const isUndoClick = event.relatedTarget?.closest('.shortcut-undo-icon') if (!isUndoClick) { + clearTimeoutTimer('conflict-clear') setEditingKey(null) setPendingKeys([]) setConflictLabel(null) From e2c2ed0ba9ea314b8693bacc8960ca99b409dda2 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 7 Apr 2026 12:06:20 +0800 Subject: [PATCH 03/37] refactor: optimize shortcut handling and improve preference resolution --- packages/shared/shortcuts/definitions.ts | 6 +++++- src/main/services/ShortcutService.ts | 2 +- src/renderer/src/components/TopView/index.tsx | 3 +-- src/renderer/src/hooks/useShortcuts.ts | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index b0e1d5309b8..74b8cbd9d6c 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -150,6 +150,10 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ } ] as const +const definitionMap = new Map( + SHORTCUT_DEFINITIONS.map((definition) => [definition.key, definition]) +) + export const findShortcutDefinition = (key: string): ShortcutDefinition | undefined => { - return SHORTCUT_DEFINITIONS.find((definition) => definition.key === key) + return definitionMap.get(key) } diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 3284ef53519..7090522d537 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -125,7 +125,7 @@ export class ShortcutService extends BaseService { this.isRegisterOnBoot = false } - if (undefined === this.windowOnHandlers.get(window)) { + if (!this.windowOnHandlers.has(window)) { const onFocus = () => this.registerShortcuts(window, false) const onBlur = () => this.registerShortcuts(window, true) window.on('focus', onFocus) diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx index 20fabd7dd01..992d259e5ac 100644 --- a/src/renderer/src/components/TopView/index.tsx +++ b/src/renderer/src/components/TopView/index.tsx @@ -3,7 +3,6 @@ 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 type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' import { message, Modal } from 'antd' import type { PropsWithChildren } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react' @@ -39,7 +38,7 @@ const TopViewContainer: React.FC = ({ children }) => { const [modal, modalContextHolder] = Modal.useModal() const [messageApi, messageContextHolder] = message.useMessage() const [exitFullscreenPref] = usePreference('shortcut.app.exit_fullscreen') - const enableQuitFullScreen = (exitFullscreenPref as PreferenceShortcutType | undefined)?.enabled !== false + const enableQuitFullScreen = exitFullscreenPref?.enabled !== false useAppInit() diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index 3919de2fbce..cbb6c15f84b 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -37,12 +37,12 @@ const toFullKey = (key: ShortcutKey | ShortcutPreferenceKey): ShortcutPreference const resolvePreferenceValue = ( definition: ShortcutDefinition | undefined, - preference: PreferenceShortcutType | Record | undefined + preference: PreferenceShortcutType | undefined ): ShortcutPreferenceValue | null => { if (!definition) { return null } - return coerceShortcutPreference(definition, preference as PreferenceShortcutType | undefined) + return coerceShortcutPreference(definition, preference) } export const useShortcut = ( From 88f6e2330045c6081bf0a9b3b72b63941063aadf Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 7 Apr 2026 12:24:05 +0800 Subject: [PATCH 04/37] refactor: add labelKey to shortcut definitions and improve shortcut display handling --- .../shared/__tests__/shortcutUtils.test.ts | 1 + packages/shared/shortcuts/definitions.ts | 42 ++++++++++++++----- packages/shared/shortcuts/types.ts | 1 + src/main/services/ShortcutService.ts | 1 + src/renderer/src/hooks/useShortcuts.ts | 6 ++- .../src/pages/settings/ShortcutSettings.tsx | 26 +----------- 6 files changed, 40 insertions(+), 37 deletions(-) diff --git a/packages/shared/__tests__/shortcutUtils.test.ts b/packages/shared/__tests__/shortcutUtils.test.ts index 47bf969eb77..73006d4f72e 100644 --- a/packages/shared/__tests__/shortcutUtils.test.ts +++ b/packages/shared/__tests__/shortcutUtils.test.ts @@ -15,6 +15,7 @@ const makeDefinition = (overrides: Partial = {}): ShortcutDe defaultKey: ['CommandOrControl', 'L'], scope: 'renderer', category: 'chat', + labelKey: 'clear_topic', ...overrides }) diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index 74b8cbd9d6c..f2427c55f9e 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -7,6 +7,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ defaultKey: [], scope: 'main', category: 'app', + labelKey: 'show_app', system: true, persistOnBlur: true }, @@ -15,6 +16,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ defaultKey: ['CommandOrControl', 'E'], scope: 'main', category: 'selection', + labelKey: 'mini_window', system: true, persistOnBlur: true, enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled') @@ -24,6 +26,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ defaultKey: ['CommandOrControl', ','], scope: 'both', category: 'app', + labelKey: 'show_settings', editable: false, system: true }, @@ -31,13 +34,15 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ key: 'shortcut.app.toggle_show_assistants', defaultKey: ['CommandOrControl', '['], scope: 'renderer', - category: 'app' + category: 'app', + labelKey: 'toggle_show_assistants' }, { key: 'shortcut.app.exit_fullscreen', defaultKey: ['Escape'], scope: 'renderer', category: 'app', + labelKey: 'exit_fullscreen', editable: false, system: true }, @@ -46,6 +51,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ defaultKey: ['CommandOrControl', '='], scope: 'main', category: 'app', + labelKey: 'zoom_in', editable: false, system: true, variants: [['CommandOrControl', 'numadd']] @@ -55,6 +61,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ defaultKey: ['CommandOrControl', '-'], scope: 'main', category: 'app', + labelKey: 'zoom_out', editable: false, system: true, variants: [['CommandOrControl', 'numsub']] @@ -64,6 +71,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ defaultKey: ['CommandOrControl', '0'], scope: 'main', category: 'app', + labelKey: 'zoom_reset', editable: false, system: true }, @@ -71,63 +79,73 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ key: 'shortcut.app.search_message', defaultKey: ['CommandOrControl', 'Shift', 'F'], scope: 'renderer', - category: 'topic' + category: 'topic', + labelKey: 'search_message' }, // ==================== 聊天相关快捷键 ==================== { key: 'shortcut.chat.clear', defaultKey: ['CommandOrControl', 'L'], scope: 'renderer', - category: 'chat' + category: 'chat', + labelKey: 'clear_topic' }, { key: 'shortcut.chat.search_message', defaultKey: ['CommandOrControl', 'F'], scope: 'renderer', - category: 'chat' + category: 'chat', + labelKey: 'search_message_in_chat' }, { key: 'shortcut.chat.toggle_new_context', defaultKey: ['CommandOrControl', 'K'], scope: 'renderer', - category: 'chat' + category: 'chat', + labelKey: 'toggle_new_context' }, { key: 'shortcut.chat.copy_last_message', defaultKey: ['CommandOrControl', 'Shift', 'C'], scope: 'renderer', - category: 'chat' + category: 'chat', + labelKey: 'copy_last_message' }, { key: 'shortcut.chat.edit_last_user_message', defaultKey: ['CommandOrControl', 'Shift', 'E'], scope: 'renderer', - category: 'chat' + category: 'chat', + labelKey: 'edit_last_user_message' }, { key: 'shortcut.chat.select_model', defaultKey: ['CommandOrControl', 'Shift', 'M'], scope: 'renderer', - category: 'chat' + category: 'chat', + labelKey: 'select_model' }, // ==================== 话题管理快捷键 ==================== { key: 'shortcut.topic.new', defaultKey: ['CommandOrControl', 'N'], scope: 'renderer', - category: 'topic' + category: 'topic', + labelKey: 'new_topic' }, { key: 'shortcut.topic.rename', defaultKey: ['CommandOrControl', 'T'], scope: 'renderer', - category: 'topic' + category: 'topic', + labelKey: 'rename_topic' }, { key: 'shortcut.topic.toggle_show_topics', defaultKey: ['CommandOrControl', ']'], scope: 'renderer', - category: 'topic' + category: 'topic', + labelKey: 'toggle_show_topics' }, // ==================== 划词助手快捷键 ==================== { @@ -135,6 +153,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ defaultKey: [], scope: 'main', category: 'selection', + labelKey: 'selection_assistant_toggle', system: true, persistOnBlur: true, supportedPlatforms: ['darwin', 'win32'] @@ -144,6 +163,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ defaultKey: [], scope: 'main', category: 'selection', + labelKey: 'selection_assistant_select_text', system: true, persistOnBlur: true, supportedPlatforms: ['darwin', 'win32'] diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index ca8550b03f2..e8c98382ccf 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -17,6 +17,7 @@ export interface ShortcutDefinition { defaultKey: string[] scope: ShortcutScope category: ShortcutCategory + labelKey: string editable?: boolean system?: boolean persistOnBlur?: boolean diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 7090522d537..2ea9bc2df46 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -79,6 +79,7 @@ export class ShortcutService extends BaseService { }) this.handlers.set('shortcut.app.show_mini_window', () => { + if (!application.get('PreferenceService').get('feature.quick_assistant.enabled')) return application.get('WindowService').toggleMiniWindow() }) diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index cbb6c15f84b..7568c673276 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -106,7 +106,11 @@ export const useShortcutDisplay = (shortcutKey: ShortcutKey | ShortcutPreference return '' } - const displayBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey + const displayBinding = preferenceState.hasCustomBinding + ? preferenceState.rawBinding + : preferenceState.binding.length > 0 + ? preferenceState.binding + : definition.defaultKey if (!displayBinding.length) { return '' diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index 7a01a2c09bb..f3299bbfa86 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -19,29 +19,6 @@ import styled from 'styled-components' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' -const labelKeyMap: Record = { - 'shortcut.app.show_main_window': 'show_app', - 'shortcut.app.show_mini_window': 'mini_window', - 'shortcut.app.show_settings': 'show_settings', - 'shortcut.app.toggle_show_assistants': 'toggle_show_assistants', - 'shortcut.app.exit_fullscreen': 'exit_fullscreen', - 'shortcut.app.zoom_in': 'zoom_in', - 'shortcut.app.zoom_out': 'zoom_out', - 'shortcut.app.zoom_reset': 'zoom_reset', - 'shortcut.app.search_message': 'search_message', - 'shortcut.chat.clear': 'clear_topic', - 'shortcut.chat.search_message': 'search_message_in_chat', - 'shortcut.chat.toggle_new_context': 'toggle_new_context', - 'shortcut.chat.copy_last_message': 'copy_last_message', - 'shortcut.chat.edit_last_user_message': 'edit_last_user_message', - 'shortcut.chat.select_model': 'select_model', - 'shortcut.topic.new': 'new_topic', - 'shortcut.topic.rename': 'rename_topic', - 'shortcut.topic.toggle_show_topics': 'toggle_show_topics', - 'shortcut.selection.toggle_enabled': 'selection_assistant_toggle', - 'shortcut.selection.get_text': 'selection_assistant_select_text' -} - type ShortcutRecord = { id: string label: string @@ -80,8 +57,7 @@ const ShortcutSettings: FC = () => { }) return filtered.map((item) => { - const labelKey = labelKeyMap[item.definition.key] ?? item.definition.key - const label = getShortcutLabel(labelKey) + const label = getShortcutLabel(item.definition.labelKey) const displayKeys = item.preference.hasCustomBinding ? item.preference.rawBinding From 97ff8fab2ea2d7e516f075b809e54eb96cdb36c9 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 7 Apr 2026 12:29:53 +0800 Subject: [PATCH 05/37] refactor: add Windows_NavigateToSettings IPC channel and update navigation handling --- packages/shared/IpcChannel.ts | 1 + src/main/services/ShortcutService.ts | 7 ++-- .../src/handler/NavigationHandler.tsx | 35 ++++++++----------- 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 6fc78402c90..72af3262d55 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -181,6 +181,7 @@ 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', KnowledgeBase_Create = 'knowledge-base:create', KnowledgeBase_Reset = 'knowledge-base:reset', diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 2ea9bc2df46..85b2e4de8e5 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -3,6 +3,7 @@ import { application } from '@main/core/application' 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 { SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions' import type { ShortcutPreferenceKey } from '@shared/shortcuts/types' import { coerceShortcutPreference } from '@shared/shortcuts/utils' @@ -71,11 +72,7 @@ export class ShortcutService extends BaseService { if (!targetWindow || targetWindow.isDestroyed()) return - void targetWindow.webContents - .executeJavaScript(`typeof window.navigate === 'function' && window.navigate('/settings/provider')`, true) - .catch((error) => { - logger.warn('Failed to navigate to settings from shortcut:', error as Error) - }) + targetWindow.webContents.send(IpcChannel.Windows_NavigateToSettings) }) this.handlers.set('shortcut.app.show_mini_window', () => { diff --git a/src/renderer/src/handler/NavigationHandler.tsx b/src/renderer/src/handler/NavigationHandler.tsx index 33826555eac..e5199a5e6ef 100644 --- a/src/renderer/src/handler/NavigationHandler.tsx +++ b/src/renderer/src/handler/NavigationHandler.tsx @@ -1,36 +1,31 @@ -import { useShortcut } from '@renderer/hooks/useShortcuts' import { IpcChannel } from '@shared/IpcChannel' -import { useLocation, useNavigate } from '@tanstack/react-router' +import { useNavigate } from '@tanstack/react-router' import { useEffect } from 'react' const NavigationHandler: React.FC = () => { - const location = useLocation() const navigate = useNavigate() - useShortcut( - 'app.show_settings', - () => { - if (location.pathname.startsWith('/settings')) { - return - } - void navigate({ to: '/settings/provider' }) - }, - { - enableOnFormTags: true, - enableOnContentEditable: true - } - ) - - // 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]) From 343d722a79f80e67574030144a0d36bc4293abe6 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 7 Apr 2026 14:30:11 +0800 Subject: [PATCH 06/37] refactor: update auto-generated configuration and mappings with new timestamps and shortcut definitions --- .../data/preference/preferenceSchemas.ts | 26 ++- .../migrators/mappings/PreferencesMappings.ts | 14 +- .../data-classify/data/classification.json | 150 ++++++++---------- 3 files changed, 98 insertions(+), 92 deletions(-) diff --git a/packages/shared/data/preference/preferenceSchemas.ts b/packages/shared/data/preference/preferenceSchemas.ts index 64e1ceabb2a..ab0a59217bf 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-03-25T15:56:52.713Z + * Generated at: 2026-04-07T06:27:46.143Z * * This file is automatically generated from classification.json * To update this file, modify classification.json and run: @@ -424,25 +424,45 @@ 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.exit_fullscreen 'shortcut.app.exit_fullscreen': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.search_message 'shortcut.app.search_message': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.show_app 'shortcut.app.show_main_window': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.mini_window 'shortcut.app.show_mini_window': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.show_settings 'shortcut.app.show_settings': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.toggle_show_assistants 'shortcut.app.toggle_show_assistants': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.zoom_in 'shortcut.app.zoom_in': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.zoom_out 'shortcut.app.zoom_out': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.zoom_reset 'shortcut.app.zoom_reset': PreferenceTypes.PreferenceShortcutType + // 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.selection_assistant_select_text 'shortcut.selection.get_text': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.selection_assistant_toggle 'shortcut.selection.toggle_enabled': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.new_topic '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 @@ -753,9 +773,9 @@ export const DefaultPreferences: PreferenceSchemas = { /** * 生成统计: - * - 总配置项: 225 + * - 总配置项: 229 * - electronStore项: 1 - * - redux项: 205 + * - redux项: 209 * - localStorage项: 0 * - dexieSettings项: 7 */ diff --git a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts index 6a34a3da5c2..1470b496bc6 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-03-25T15:56:52.716Z + * Generated at: 2026-04-07T06:27:46.146Z * * This file contains pure mapping relationships without default values. * Default values are managed in packages/shared/data/preferences.ts @@ -788,14 +788,14 @@ export const REDUX_STORE_MAPPINGS = { originalKey: 'shortcuts.rename_topic', targetKey: 'shortcut.topic.rename' }, - { - originalKey: 'shortcuts.toggle_show_assistants', - targetKey: 'shortcut.app.toggle_show_assistants' - }, { originalKey: 'shortcuts.toggle_show_topics', targetKey: 'shortcut.topic.toggle_show_topics' }, + { + originalKey: 'shortcuts.toggle_show_assistants', + targetKey: 'shortcut.app.toggle_show_assistants' + }, { originalKey: 'shortcuts.copy_last_message', targetKey: 'shortcut.chat.copy_last_message' @@ -953,11 +953,11 @@ export const LOCALSTORAGE_MAPPINGS: ReadonlyArray<{ originalKey: string; targetK /** * 映射统计: * - ElectronStore项: 1 - * - Redux Store项: 212 + * - Redux Store项: 211 * - Redux分类: settings, selectionStore, memory, nutstore, preprocess, shortcuts, translate, websearch, ocr, note * - DexieSettings项: 7 * - localStorage项: 0 - * - 总配置项: 220 + * - 总配置项: 219 * * 使用说明: * 1. ElectronStore读取: configManager.get(mapping.originalKey) diff --git a/v2-refactor-temp/tools/data-classify/data/classification.json b/v2-refactor-temp/tools/data-classify/data/classification.json index 753bfd32a3b..1994aead7ce 100644 --- a/v2-refactor-temp/tools/data-classify/data/classification.json +++ b/v2-refactor-temp/tools/data-classify/data/classification.json @@ -2,9 +2,9 @@ "metadata": { "version": "2.0.0", "lastUpdated": "2026-03-23T08:30:00.000Z", - "totalItems": 406, - "classified": 278, - "pending": 122, + "totalItems": 408, + "classified": 282, + "pending": 120, "deleted": 0 }, "classifications": { @@ -2467,236 +2467,222 @@ { "category": "preferences", "defaultValue": { - "editable": false, "enabled": true, - "key": ["CommandOrControl", "="], - "system": true + "key": ["CommandOrControl", "="] }, "originalKey": "zoom_in", "status": "classified", "targetKey": "shortcut.app.zoom_in", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": false, "enabled": true, - "key": ["CommandOrControl", "-"], - "system": true + "key": ["CommandOrControl", "-"] }, "originalKey": "zoom_out", "status": "classified", "targetKey": "shortcut.app.zoom_out", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": false, "enabled": true, - "key": ["CommandOrControl", "0"], - "system": true + "key": ["CommandOrControl", "0"] }, "originalKey": "zoom_reset", "status": "classified", "targetKey": "shortcut.app.zoom_reset", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": false, "enabled": true, - "key": ["CommandOrControl", ","], - "system": true + "key": ["CommandOrControl", ","] }, "originalKey": "show_settings", "status": "classified", "targetKey": "shortcut.app.show_settings", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, "enabled": true, - "key": [], - "system": true + "key": [] }, "originalKey": "show_app", "status": "classified", "targetKey": "shortcut.app.show_main_window", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, "enabled": false, - "key": ["CommandOrControl", "E"], - "system": true + "key": ["CommandOrControl", "E"] }, "originalKey": "mini_window", "status": "classified", "targetKey": "shortcut.app.show_mini_window", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, "enabled": false, - "key": [], - "system": true + "key": [] }, "originalKey": "selection_assistant_toggle", "status": "classified", "targetKey": "shortcut.selection.toggle_enabled", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, "enabled": false, - "key": [], - "system": true + "key": [] }, "originalKey": "selection_assistant_select_text", "status": "classified", "targetKey": "shortcut.selection.get_text", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, "enabled": true, - "key": ["CommandOrControl", "N"], - "system": false + "key": ["CommandOrControl", "N"] }, "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 + "enabled": true, + "key": ["CommandOrControl", "T"] }, "originalKey": "rename_topic", - "status": "pending", - "targetKey": null, - "type": "object" + "status": "classified", + "targetKey": "shortcut.topic.rename", + "type": "PreferenceTypes.PreferenceShortcutType" + }, + { + "category": "preferences", + "defaultValue": { + "enabled": true, + "key": ["CommandOrControl", "]"] + }, + "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 + "key": ["CommandOrControl", "["] }, "originalKey": "toggle_show_assistants", "status": "classified", "targetKey": "shortcut.app.toggle_show_assistants", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, "enabled": false, - "key": ["CommandOrControl", "Shift", "C"], - "system": false + "key": ["CommandOrControl", "Shift", "C"] }, "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 + "key": ["CommandOrControl", "Shift", "E"] }, "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 + "key": ["CommandOrControl", "F"] }, "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 + "key": ["CommandOrControl", "Shift", "F"] }, "originalKey": "search_message", "status": "classified", "targetKey": "shortcut.app.search_message", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": true, "enabled": true, - "key": ["CommandOrControl", "L"], - "system": false + "key": ["CommandOrControl", "L"] }, "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 + "key": ["CommandOrControl", "K"] }, "originalKey": "toggle_new_context", "status": "classified", "targetKey": "shortcut.chat.toggle_new_context", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" + }, + { + "category": "preferences", + "defaultValue": { + "enabled": true, + "key": ["CommandOrControl", "Shift", "M"] + }, + "originalKey": "select_model", + "status": "classified", + "targetKey": "shortcut.chat.select_model", + "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { - "editable": false, "enabled": true, - "key": ["Escape"], - "system": true + "key": ["Escape"] }, "originalKey": "exit_fullscreen", "status": "classified", "targetKey": "shortcut.app.exit_fullscreen", - "type": "object" + "type": "PreferenceTypes.PreferenceShortcutType" } ] } From a17fc81d1d92c8f7c2ff8d434ee5cbc571677db8 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 7 Apr 2026 14:44:47 +0800 Subject: [PATCH 07/37] fix: address review feedback for shortcut system - Fix Escape key saving as binding instead of canceling recording (C1) - Fix conflict detection using displayKeys instead of normalized binding (C2) - Stabilize useHotkeys callback with useRef to avoid re-registration (W1) - Add guard for ready-to-show callback on stopped service (W3) - Use registerDisposable for preference subscriptions (W4) - Fix antd Table render signature parameter mismatch (S2) - Add duplicate key check in isValidShortcut (S3) Signed-off-by: kangfenmao --- packages/shared/shortcuts/utils.ts | 4 ++++ src/main/services/ShortcutService.ts | 11 +++++----- src/renderer/src/hooks/useShortcuts.ts | 16 +++++++++----- .../src/pages/settings/ShortcutSettings.tsx | 21 ++++++++++++------- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/shared/shortcuts/utils.ts b/packages/shared/shortcuts/utils.ts index 1d51909b5a2..96f0416d591 100644 --- a/packages/shared/shortcuts/utils.ts +++ b/packages/shared/shortcuts/utils.ts @@ -84,6 +84,10 @@ export const isValidShortcut = (keys: string[]): boolean => { 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]) diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 85b2e4de8e5..7bf03623e34 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -28,7 +28,6 @@ export class ShortcutService extends BaseService { private handlers = new Map() private windowOnHandlers = new Map void; onBlur: () => void }>() private isRegisterOnBoot = true - private preferenceUnsubscribers: Array<() => void> = [] private registeredAccelerators = new Map() protected async onInit() { @@ -46,8 +45,6 @@ export class ShortcutService extends BaseService { protected async onStop() { this.unregisterAll() - this.preferenceUnsubscribers.forEach((unsub) => unsub()) - this.preferenceUnsubscribers = [] this.mainWindow = null } @@ -103,12 +100,13 @@ export class ShortcutService extends BaseService { private subscribeToPreferenceChanges(): void { const preferenceService = application.get('PreferenceService') - this.preferenceUnsubscribers = relevantDefinitions.map((definition) => - preferenceService.subscribeChange(definition.key, () => { + for (const definition of relevantDefinitions) { + const unsub = preferenceService.subscribeChange(definition.key, () => { logger.debug(`Shortcut preference changed: ${definition.key}`) this.reregisterShortcuts() }) - ) + this.registerDisposable({ dispose: unsub }) + } } private registerForWindow(window: BrowserWindow): void { @@ -116,6 +114,7 @@ export class ShortcutService extends BaseService { if (this.isRegisterOnBoot) { window.once('ready-to-show', () => { + if (!this.mainWindow || this.mainWindow.isDestroyed()) return if (application.get('PreferenceService').get('app.tray.on_launch')) { this.registerShortcuts(window, true) } diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index 7568c673276..c41e0439adb 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -14,7 +14,7 @@ import { formatShortcutDisplay, getDefaultShortcutPreference } from '@shared/shortcuts/utils' -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useRef } from 'react' import { useHotkeys } from 'react-hotkeys-hook' interface UseShortcutOptions { @@ -55,6 +55,12 @@ export const useShortcut = ( const [preference] = usePreference(fullKey) const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference]) + const callbackRef = useRef(callback) + callbackRef.current = callback + + const optionsRef = useRef(options) + optionsRef.current = options + const hotkey = useMemo(() => { if (!definition || !preferenceState) { return 'none' @@ -78,11 +84,11 @@ export const useShortcut = ( useHotkeys( hotkey, (event) => { - if (options.preventDefault) { + if (optionsRef.current.preventDefault) { event.preventDefault() } - if (options.enabled !== false) { - callback(event) + if (optionsRef.current.enabled !== false) { + callbackRef.current(event) } }, { @@ -91,7 +97,7 @@ export const useShortcut = ( enabled: hotkey !== 'none', enableOnContentEditable: options.enableOnContentEditable }, - [hotkey, callback, options] + [hotkey] ) } diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index f3299bbfa86..4fbc6c2bffd 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -144,13 +144,13 @@ const ShortcutSettings: FC = () => { const findDuplicateLabel = (keys: string[], currentKey: ShortcutPreferenceKey): string | null => { const normalized = keys.map((key) => key.toLowerCase()).join('+') - for (const record of displayedShortcuts) { - if (record.key === currentKey) continue - if (!record.enabled) continue - const binding = record.displayKeys + for (const shortcut of shortcuts) { + if (shortcut.definition.key === currentKey) continue + if (!shortcut.preference.enabled) continue + const binding = shortcut.preference.binding if (!binding.length) continue if (binding.map((key) => key.toLowerCase()).join('+') === normalized) { - return record.label + return getShortcutLabel(shortcut.definition.labelKey) } } return null @@ -269,6 +269,13 @@ const ShortcutSettings: FC = () => { const handleKeyDown = (event: ReactKeyboardEvent, record: ShortcutRecord) => { event.preventDefault() + if (event.code === 'Escape') { + setEditingKey(null) + setPendingKeys([]) + setConflictLabel(null) + return + } + const keys: string[] = [] if (event.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl') @@ -387,7 +394,7 @@ const ShortcutSettings: FC = () => { key: 'actions', align: 'right', width: 70, - render: (record) => ( + render: (_value, record) => ( - - - - ) - }, - { - key: 'enabled', - align: 'right', - width: 50, - render: (_value, record) => ( - void record.updatePreference({ enabled: !record.enabled })} - /> - ) } - ] + + return ( + record.editable && handleAddShortcut(record)}> + {t('settings.shortcuts.press_shortcut')} + + ) + } return ( {t('settings.shortcuts.title')} -
- + setSearchQuery(e.target.value)} - allowClear />
-
} - dataSource={filteredShortcuts} - pagination={false} - size="middle" - showHeader={false} - rowKey="id" - /> +
+ {filteredShortcuts.map((record, index) => ( +
+ {record.label} + + {renderShortcutCell(record)} + {record.displayKeys.length > 0 && ( + void record.updatePreference({ enabled: !record.enabled })} + /> + )} + +
+ ))} +
@@ -456,44 +337,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: 140px; - text-align: center; -` - -const SearchInput = styled(Input)` - max-width: 260px; -` - -const ShortcutText = styled.span<{ isEditable: boolean }>` - cursor: ${({ isEditable }) => (isEditable ? 'pointer' : 'not-allowed')}; - padding: 4px 11px; - opacity: ${({ isEditable }) => (isEditable ? 1 : 0.5)}; -` - -const ConflictHint = styled.span` - position: absolute; - top: 100%; - right: 0; - margin-top: 2px; - font-size: 12px; - color: var(--color-error, #ff4d4f); - white-space: nowrap; -` - export default ShortcutSettings diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md index 3966654c33c..dd2e601224a 100644 --- a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -113,8 +113,10 @@ v1 快捷键系统存在以下架构缺陷: key: 'shortcut.app.show_mini_window', // Preference key defaultKey: ['CommandOrControl', 'E'], // Electron accelerator 格式 scope: 'main', // main | renderer | both - category: 'app', // app | chat | topic | selection + category: 'selection', // app | chat | topic | selection + labelKey: 'mini_window', // i18n label key system: true, // 系统级(不可删除绑定) + editable: true, // 用户可修改绑定(默认 true) persistOnBlur: true, // 窗口失焦后仍然生效 enabledWhen: (get) => !!get('feature.quick_assistant.enabled'), supportedPlatforms: ['darwin', 'win32'] @@ -125,12 +127,17 @@ v1 快捷键系统存在以下架构缺陷: | 字段 | 用途 | |------|------| +| `key` | Preference key,必须是 `shortcut.{category}.{name}` 格式 | +| `defaultKey` | Electron accelerator 格式的默认绑定,空数组表示无默认绑定 | | `scope` | 决定快捷键注册在哪个进程:`main`(globalShortcut)、`renderer`(react-hotkeys-hook)、`both`(两者都注册) | +| `category` | 逻辑分类,用于设置页 UI 分组 | +| `labelKey` | i18n label key,由 `getShortcutLabel()` 消费 | +| `editable` | 设为 `false` 表示用户不可修改绑定(如 Escape 退出全屏),默认 `true` | +| `system` | 系统级标记,`true` 时不可删除绑定 | | `persistOnBlur` | 窗口失焦时是否保留注册(如 `show_main_window` 需要在任何时候响应) | -| `enabledWhen` | 动态启用条件,接收 `getPreference` 函数,在注册时求值 | -| `supportedPlatforms` | 限制快捷键仅在指定操作系统上注册和显示 | -| `editable` | 设为 `false` 表示用户不可修改绑定(如 Escape 退出全屏) | | `variants` | 同一快捷键的多组绑定(如 zoom_in 同时绑定 `=` 和小键盘 `+`) | +| `enabledWhen` | 动态启用条件,接收 `getPreference` 函数,在注册时求值 | +| `supportedPlatforms` | 限制快捷键仅在指定操作系统上注册和显示,类型为 `SupportedPlatform[]`(`'darwin' | 'win32' | 'linux'`) | #### `types.ts` — 类型体系 @@ -143,12 +150,10 @@ type ShortcutKey = ShortcutPreferenceKey extends `shortcut.${infer Rest}` ? Rest // 运行时归一化后的完整状态 interface ShortcutPreferenceValue { - binding: string[] // 生效的绑定(有值则用,否则 fallback 到 defaultKey) - rawBinding: string[] // 用户原始设置值(可能为空数组) - hasCustomBinding: boolean // 是否有用户自定义绑定 - enabled: boolean - editable: boolean // 来自 definition.editable - system: boolean // 来自 definition.system + binding: string[] // 生效的绑定(用户自定义或 fallback 到 defaultKey,始终有效) + enabled: boolean // 是否启用 + editable: boolean // 来自 definition.editable,不存储在偏好中 + system: boolean // 来自 definition.system,不存储在偏好中 } ``` @@ -175,11 +180,13 @@ useShortcut('shortcut.chat.clear', callback) ``` 输入值为 null/undefined → 使用 schema 默认值 -输入的 key 为空数组 → binding 回退到 defaultKey,rawBinding 保留空数组 +输入的 key 为空数组 → binding 回退到 defaultKey 输入的 enabled 非布尔 → 使用默认 enabled editable/system → 始终从 definition 读取(不存储在偏好中) ``` +**设计决策**:禁用快捷键统一使用 `enabled: false`,`binding` 始终包含有效绑定(用户自定义或默认值)。不存在"清空绑定"的独立语义——想禁用就关 `enabled`,想换键就录制覆盖,想重置就写回 `defaultKey`。 + ### 2. 偏好层 (`preferenceSchemas.ts` + `preferenceTypes.ts`) 偏好值的存储结构经过精简,只持久化用户可变部分: @@ -244,11 +251,12 @@ this.handlers.set('shortcut.app.zoom_in', (window) => { #### 偏好变更订阅 ```typescript -this.preferenceUnsubscribers = relevantDefinitions.map((definition) => - preferenceService.subscribeChange(definition.key, () => { +for (const definition of relevantDefinitions) { + const unsub = preferenceService.subscribeChange(definition.key, () => { this.reregisterShortcuts() // 整体重注册 }) -) + this.registerDisposable({ dispose: unsub }) // 生命周期自动清理 +} ``` ### 4. 渲染进程 Hook 层 (`useShortcuts.ts`) From f54fc1a631182196b3d94e3136e5cf579d070c63 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 7 Apr 2026 17:32:34 +0800 Subject: [PATCH 09/37] refactor: replace persistOnBlur with global in shortcut definitions and types --- packages/shared/shortcuts/definitions.ts | 8 ++++---- packages/shared/shortcuts/types.ts | 4 ++-- src/main/services/ShortcutService.ts | 2 +- .../docs/shortcuts/shortcut-system-refactor.md | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index f2427c55f9e..7e772df7591 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -9,7 +9,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'app', labelKey: 'show_app', system: true, - persistOnBlur: true + global: true }, { key: 'shortcut.app.show_mini_window', @@ -18,7 +18,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'selection', labelKey: 'mini_window', system: true, - persistOnBlur: true, + global: true, enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled') }, { @@ -155,7 +155,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'selection', labelKey: 'selection_assistant_toggle', system: true, - persistOnBlur: true, + global: true, supportedPlatforms: ['darwin', 'win32'] }, { @@ -165,7 +165,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'selection', labelKey: 'selection_assistant_select_text', system: true, - persistOnBlur: true, + global: true, supportedPlatforms: ['darwin', 'win32'] } ] as const diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index e0a728e4733..1357cc137ee 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -31,8 +31,8 @@ export interface ShortcutDefinition { editable?: boolean /** System-level shortcut — when `true` the binding cannot be deleted. */ system?: boolean - /** Whether the shortcut stays registered when the window loses focus (i.e. a global shortcut). */ - persistOnBlur?: 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[][] /** Dynamic enable condition evaluated at registration time. Return `false` to skip registration. */ diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index d9af1c9dcf1..bceb1b2be48 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -147,7 +147,7 @@ export class ShortcutService extends BaseService { const desired = new Map() for (const definition of relevantDefinitions) { - if (onlyPersistent && !definition.persistOnBlur) continue + if (onlyPersistent && !definition.global) continue const rawPref = preferenceService.get(definition.key) as PreferenceShortcutType | undefined const pref = coerceShortcutPreference(definition, rawPref) diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md index dd2e601224a..0d3648b7352 100644 --- a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -117,7 +117,7 @@ v1 快捷键系统存在以下架构缺陷: labelKey: 'mini_window', // i18n label key system: true, // 系统级(不可删除绑定) editable: true, // 用户可修改绑定(默认 true) - persistOnBlur: true, // 窗口失焦后仍然生效 + global: true, // 全局快捷键(窗口失焦后仍然生效) enabledWhen: (get) => !!get('feature.quick_assistant.enabled'), supportedPlatforms: ['darwin', 'win32'] } @@ -134,7 +134,7 @@ v1 快捷键系统存在以下架构缺陷: | `labelKey` | i18n label key,由 `getShortcutLabel()` 消费 | | `editable` | 设为 `false` 表示用户不可修改绑定(如 Escape 退出全屏),默认 `true` | | `system` | 系统级标记,`true` 时不可删除绑定 | -| `persistOnBlur` | 窗口失焦时是否保留注册(如 `show_main_window` 需要在任何时候响应) | +| `global` | 全局快捷键,窗口失焦时是否保留注册(如 `show_main_window` 需要在任何时候响应) | | `variants` | 同一快捷键的多组绑定(如 zoom_in 同时绑定 `=` 和小键盘 `+`) | | `enabledWhen` | 动态启用条件,接收 `getPreference` 函数,在注册时求值 | | `supportedPlatforms` | 限制快捷键仅在指定操作系统上注册和显示,类型为 `SupportedPlatform[]`(`'darwin' | 'win32' | 'linux'`) | @@ -236,9 +236,9 @@ this.handlers.set('shortcut.app.zoom_in', (window) => { ``` 窗口创建 → registerForWindow(window) - ├── 首次创建 + tray_on_launch → ready-to-show 时仅注册 persistOnBlur 快捷键 + ├── 首次创建 + tray_on_launch → ready-to-show 时仅注册 global 快捷键 ├── focus 事件 → registerShortcuts(window, false) 注册全部 - └── blur 事件 → registerShortcuts(window, true) 仅保留 persistOnBlur + └── blur 事件 → registerShortcuts(window, true) 仅保留 global ``` `registerShortcuts` 的核心流程: From 6075b25b1a71803f360e9b45658e6a2ae6079c5a Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 7 Apr 2026 19:55:03 +0800 Subject: [PATCH 10/37] refactor: shortcut keys to use a new naming convention - Updated shortcut keys in AppMenuService, ShortcutService, and various components to follow the new structure: 'shortcut.app.core' for core shortcuts and 'shortcut.app' for app-specific shortcuts. - Adjusted preference keys in classification JSON to reflect the new naming convention. - Ensured all related components and services are aligned with the updated shortcut keys for consistency and clarity. --- .../data/preference-schema-guide.md | 2 +- .../shared/__tests__/shortcutUtils.test.ts | 4 +- .../data/preference/preferenceSchemas.ts | 98 ++++---- packages/shared/shortcuts/definitions.ts | 80 +++---- packages/shared/shortcuts/types.ts | 14 +- .../migrators/mappings/PreferencesMappings.ts | 42 ++-- src/main/services/AppMenuService.ts | 12 +- src/main/services/ShortcutService.ts | 16 +- src/renderer/src/components/TopView/index.tsx | 2 +- src/renderer/src/pages/agents/AgentChat.tsx | 2 +- src/renderer/src/pages/agents/AgentNavbar.tsx | 2 +- src/renderer/src/pages/agents/AgentPage.tsx | 4 +- .../components/AgentChatNavbar/index.tsx | 2 +- src/renderer/src/pages/home/Chat.tsx | 6 +- src/renderer/src/pages/home/HomePage.tsx | 4 +- .../src/pages/home/Inputbar/Inputbar.tsx | 4 +- .../home/Inputbar/tools/clearTopicTool.tsx | 2 +- .../tools/components/NewContextButton.tsx | 4 +- .../home/Inputbar/tools/createSessionTool.tsx | 2 +- .../home/Inputbar/tools/newTopicTool.tsx | 2 +- .../src/pages/home/Messages/Messages.tsx | 4 +- src/renderer/src/pages/home/Navbar.tsx | 2 +- .../home/components/ChatNavBar/index.tsx | 2 +- .../src/pages/knowledge/KnowledgePage.tsx | 2 +- .../shortcuts/shortcut-system-refactor.md | 94 ++++---- .../data-classify/data/classification.json | 215 ++++++++++++------ 26 files changed, 349 insertions(+), 274 deletions(-) diff --git a/docs/en/references/data/preference-schema-guide.md b/docs/en/references/data/preference-schema-guide.md index e5d6cf3b71c..ef87e71ac8e 100644 --- a/docs/en/references/data/preference-schema-guide.md +++ b/docs/en/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.app.general.show_main_window': { key: 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/__tests__/shortcutUtils.test.ts b/packages/shared/__tests__/shortcutUtils.test.ts index 8086ea5a5bc..efef97fc1d5 100644 --- a/packages/shared/__tests__/shortcutUtils.test.ts +++ b/packages/shared/__tests__/shortcutUtils.test.ts @@ -11,10 +11,10 @@ import { } from '../shortcuts/utils' const makeDefinition = (overrides: Partial = {}): ShortcutDefinition => ({ - key: 'shortcut.chat.clear', + key: 'shortcut.app.chat.clear', defaultKey: ['CommandOrControl', 'L'], scope: 'renderer', - category: 'chat', + category: 'app.chat', labelKey: 'clear_topic', ...overrides }) diff --git a/packages/shared/data/preference/preferenceSchemas.ts b/packages/shared/data/preference/preferenceSchemas.ts index ab0a59217bf..619db21ed94 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-07T06:27:46.143Z + * Generated at: 2026-04-07T12:06:27.869Z * * This file is automatically generated from classification.json * To update this file, modify classification.json and run: @@ -424,46 +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.app.chat.clear': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.copy_last_message + 'shortcut.app.chat.copy_last_message': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.edit_last_user_message + 'shortcut.app.chat.edit_last_user_message': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.search_message_in_chat + 'shortcut.app.chat.search_message': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.select_model + 'shortcut.app.chat.select_model': PreferenceTypes.PreferenceShortcutType + // redux/shortcuts/shortcuts.toggle_new_context + 'shortcut.app.chat.toggle_new_context': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.exit_fullscreen - 'shortcut.app.exit_fullscreen': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.general.exit_fullscreen': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.search_message - 'shortcut.app.search_message': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.general.search': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.show_app - 'shortcut.app.show_main_window': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.general.show_main_window': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.mini_window - 'shortcut.app.show_mini_window': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.general.show_mini_window': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.show_settings - 'shortcut.app.show_settings': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.general.show_settings': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.toggle_show_assistants - 'shortcut.app.toggle_show_assistants': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.general.toggle_show_assistants': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_in - 'shortcut.app.zoom_in': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.general.zoom_in': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_out - 'shortcut.app.zoom_out': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.general.zoom_out': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_reset - 'shortcut.app.zoom_reset': PreferenceTypes.PreferenceShortcutType - // 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.selection_assistant_select_text - 'shortcut.selection.get_text': PreferenceTypes.PreferenceShortcutType - // redux/shortcuts/shortcuts.selection_assistant_toggle - 'shortcut.selection.toggle_enabled': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.general.zoom_reset': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.new_topic - 'shortcut.topic.new': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.topic.new': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.rename_topic - 'shortcut.topic.rename': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.topic.rename': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.toggle_show_topics - 'shortcut.topic.toggle_show_topics': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.topic.toggle_show_topics': 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/settings/enableTopicNaming 'topic.naming.enabled': boolean // redux/settings/topicNamingPrompt @@ -721,26 +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': { enabled: true, key: ['Escape'] }, - 'shortcut.app.search_message': { enabled: true, key: ['CommandOrControl', 'Shift', 'F'] }, - 'shortcut.app.show_main_window': { enabled: true, key: [] }, - 'shortcut.app.show_mini_window': { enabled: false, key: ['CommandOrControl', 'E'] }, - 'shortcut.app.show_settings': { enabled: true, key: ['CommandOrControl', ','] }, - 'shortcut.app.toggle_show_assistants': { enabled: true, key: ['CommandOrControl', '['] }, - 'shortcut.app.zoom_in': { enabled: true, key: ['CommandOrControl', '='] }, - 'shortcut.app.zoom_out': { enabled: true, key: ['CommandOrControl', '-'] }, - 'shortcut.app.zoom_reset': { enabled: true, key: ['CommandOrControl', '0'] }, - 'shortcut.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, - 'shortcut.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, - 'shortcut.chat.edit_last_user_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'E'] }, - 'shortcut.chat.search_message': { enabled: true, key: ['CommandOrControl', 'F'] }, - 'shortcut.chat.select_model': { enabled: true, key: ['CommandOrControl', 'Shift', 'M'] }, - 'shortcut.chat.toggle_new_context': { enabled: true, key: ['CommandOrControl', 'K'] }, - 'shortcut.selection.get_text': { enabled: false, key: [] }, - 'shortcut.selection.toggle_enabled': { enabled: false, key: [] }, - 'shortcut.topic.new': { enabled: true, key: ['CommandOrControl', 'N'] }, - 'shortcut.topic.rename': { enabled: true, key: ['CommandOrControl', 'T'] }, - 'shortcut.topic.toggle_show_topics': { enabled: true, key: ['CommandOrControl', ']'] }, + 'shortcut.app.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, + 'shortcut.app.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, + 'shortcut.app.chat.edit_last_user_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'E'] }, + 'shortcut.app.chat.search_message': { enabled: true, key: ['CommandOrControl', 'F'] }, + 'shortcut.app.chat.select_model': { enabled: true, key: ['CommandOrControl', 'Shift', 'M'] }, + 'shortcut.app.chat.toggle_new_context': { enabled: true, key: ['CommandOrControl', 'K'] }, + 'shortcut.app.general.exit_fullscreen': { enabled: true, key: ['Escape'] }, + 'shortcut.app.general.search': { enabled: true, key: ['CommandOrControl', 'Shift', 'F'] }, + 'shortcut.app.general.show_main_window': { enabled: true, key: [] }, + 'shortcut.app.general.show_mini_window': { enabled: false, key: ['CommandOrControl', 'E'] }, + 'shortcut.app.general.show_settings': { enabled: true, key: ['CommandOrControl', ','] }, + 'shortcut.app.general.toggle_show_assistants': { enabled: true, key: ['CommandOrControl', '['] }, + 'shortcut.app.general.zoom_in': { enabled: true, key: ['CommandOrControl', '='] }, + 'shortcut.app.general.zoom_out': { enabled: true, key: ['CommandOrControl', '-'] }, + 'shortcut.app.general.zoom_reset': { enabled: true, key: ['CommandOrControl', '0'] }, + 'shortcut.app.topic.new': { enabled: true, key: ['CommandOrControl', 'N'] }, + 'shortcut.app.topic.rename': { enabled: true, key: ['CommandOrControl', 'T'] }, + 'shortcut.app.topic.toggle_show_topics': { enabled: true, key: ['CommandOrControl', ']'] }, + 'shortcut.feature.selection.get_text': { enabled: false, key: [] }, + 'shortcut.feature.selection.toggle_enabled': { enabled: false, key: [] }, 'topic.naming.enabled': true, 'topic.naming_prompt': '', 'topic.position': 'left', diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index 7e772df7591..710b669404b 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -3,166 +3,166 @@ import type { ShortcutDefinition } from './types' export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 应用级快捷键 ==================== { - key: 'shortcut.app.show_main_window', + key: 'shortcut.app.general.show_main_window', defaultKey: [], scope: 'main', - category: 'app', + category: 'app.general', labelKey: 'show_app', system: true, global: true }, { - key: 'shortcut.app.show_mini_window', + key: 'shortcut.app.general.show_mini_window', defaultKey: ['CommandOrControl', 'E'], scope: 'main', - category: 'selection', + category: 'app.general', labelKey: 'mini_window', system: true, global: true, enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled') }, { - key: 'shortcut.app.show_settings', + key: 'shortcut.app.general.show_settings', defaultKey: ['CommandOrControl', ','], scope: 'both', - category: 'app', + category: 'app.general', labelKey: 'show_settings', editable: false, system: true }, { - key: 'shortcut.app.toggle_show_assistants', + key: 'shortcut.app.general.toggle_show_assistants', defaultKey: ['CommandOrControl', '['], scope: 'renderer', - category: 'app', + category: 'app.general', labelKey: 'toggle_show_assistants' }, { - key: 'shortcut.app.exit_fullscreen', + key: 'shortcut.app.general.exit_fullscreen', defaultKey: ['Escape'], scope: 'renderer', - category: 'app', + category: 'app.general', labelKey: 'exit_fullscreen', editable: false, system: true }, { - key: 'shortcut.app.zoom_in', + key: 'shortcut.app.general.zoom_in', defaultKey: ['CommandOrControl', '='], scope: 'main', - category: 'app', + category: 'app.general', labelKey: 'zoom_in', editable: false, system: true, variants: [['CommandOrControl', 'numadd']] }, { - key: 'shortcut.app.zoom_out', + key: 'shortcut.app.general.zoom_out', defaultKey: ['CommandOrControl', '-'], scope: 'main', - category: 'app', + category: 'app.general', labelKey: 'zoom_out', editable: false, system: true, variants: [['CommandOrControl', 'numsub']] }, { - key: 'shortcut.app.zoom_reset', + key: 'shortcut.app.general.zoom_reset', defaultKey: ['CommandOrControl', '0'], scope: 'main', - category: 'app', + category: 'app.general', labelKey: 'zoom_reset', editable: false, system: true }, { - key: 'shortcut.app.search_message', + key: 'shortcut.app.general.search', defaultKey: ['CommandOrControl', 'Shift', 'F'], scope: 'renderer', - category: 'topic', + category: 'app.general', labelKey: 'search_message' }, // ==================== 聊天相关快捷键 ==================== { - key: 'shortcut.chat.clear', + key: 'shortcut.app.chat.clear', defaultKey: ['CommandOrControl', 'L'], scope: 'renderer', - category: 'chat', + category: 'app.chat', labelKey: 'clear_topic' }, { - key: 'shortcut.chat.search_message', + key: 'shortcut.app.chat.search_message', defaultKey: ['CommandOrControl', 'F'], scope: 'renderer', - category: 'chat', + category: 'app.chat', labelKey: 'search_message_in_chat' }, { - key: 'shortcut.chat.toggle_new_context', + key: 'shortcut.app.chat.toggle_new_context', defaultKey: ['CommandOrControl', 'K'], scope: 'renderer', - category: 'chat', + category: 'app.chat', labelKey: 'toggle_new_context' }, { - key: 'shortcut.chat.copy_last_message', + key: 'shortcut.app.chat.copy_last_message', defaultKey: ['CommandOrControl', 'Shift', 'C'], scope: 'renderer', - category: 'chat', + category: 'app.chat', labelKey: 'copy_last_message' }, { - key: 'shortcut.chat.edit_last_user_message', + key: 'shortcut.app.chat.edit_last_user_message', defaultKey: ['CommandOrControl', 'Shift', 'E'], scope: 'renderer', - category: 'chat', + category: 'app.chat', labelKey: 'edit_last_user_message' }, { - key: 'shortcut.chat.select_model', + key: 'shortcut.app.chat.select_model', defaultKey: ['CommandOrControl', 'Shift', 'M'], scope: 'renderer', - category: 'chat', + category: 'app.chat', labelKey: 'select_model' }, // ==================== 话题管理快捷键 ==================== { - key: 'shortcut.topic.new', + key: 'shortcut.app.topic.new', defaultKey: ['CommandOrControl', 'N'], scope: 'renderer', - category: 'topic', + category: 'app.topic', labelKey: 'new_topic' }, { - key: 'shortcut.topic.rename', + key: 'shortcut.app.topic.rename', defaultKey: ['CommandOrControl', 'T'], scope: 'renderer', - category: 'topic', + category: 'app.topic', labelKey: 'rename_topic' }, { - key: 'shortcut.topic.toggle_show_topics', + key: 'shortcut.app.topic.toggle_show_topics', defaultKey: ['CommandOrControl', ']'], scope: 'renderer', - category: 'topic', + category: 'app.topic', labelKey: 'toggle_show_topics' }, // ==================== 划词助手快捷键 ==================== { - key: 'shortcut.selection.toggle_enabled', + key: 'shortcut.feature.selection.toggle_enabled', defaultKey: [], scope: 'main', - category: 'selection', + category: 'feature.selection', labelKey: 'selection_assistant_toggle', system: true, global: true, supportedPlatforms: ['darwin', 'win32'] }, { - key: 'shortcut.selection.get_text', + key: 'shortcut.feature.selection.get_text', defaultKey: [], scope: 'main', - category: 'selection', + category: 'feature.selection', labelKey: 'selection_assistant_select_text', system: true, global: true, diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index 1357cc137ee..ed47330df27 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -2,7 +2,15 @@ import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data export type ShortcutScope = 'main' | 'renderer' | 'both' -export type ShortcutCategory = 'app' | 'chat' | 'topic' | 'selection' +/** Built-in shortcut categories for UI grouping. */ +export type BuiltinShortcutCategory = 'app.general' | 'app.chat' | 'app.topic' | 'feature.selection' + +/** + * Dot-separated namespace for UI grouping in the settings page. + * Built-in: `app.general`, `app.chat`, `app.topic`, `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 @@ -17,13 +25,13 @@ export type ShortcutEnabledPredicate = (getPreference: GetPreferenceFn) => boole /** 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 (e.g. `shortcut.chat.clear`). */ + /** Preference key in `shortcut.app.{category}.{name}` format for built-in shortcuts. Plugins use `shortcut.plugin.{pluginId}.{name}`. */ key: ShortcutPreferenceKey /** Default key binding in Electron accelerator format (e.g. `['CommandOrControl', 'L']`). Empty array means no default binding. */ defaultKey: string[] /** Where the shortcut is registered: `main` (globalShortcut), `renderer` (react-hotkeys-hook), or `both`. */ scope: ShortcutScope - /** Logical category for UI grouping in the settings page. */ + /** Dot-separated category for UI grouping (e.g. `app.general`, `app.chat`, `app.topic`, `plugin.translator`). */ category: ShortcutCategory /** i18n label key used by `getShortcutLabel()` for display. */ labelKey: string diff --git a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts index 1470b496bc6..0d993578238 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-07T06:27:46.146Z + * Generated at: 2026-04-07T12:06:27.872Z * * This file contains pure mapping relationships without default values. * Default values are managed in packages/shared/data/preferences.ts @@ -750,83 +750,83 @@ export const REDUX_STORE_MAPPINGS = { shortcuts: [ { originalKey: 'shortcuts.zoom_in', - targetKey: 'shortcut.app.zoom_in' + targetKey: 'shortcut.app.general.zoom_in' }, { originalKey: 'shortcuts.zoom_out', - targetKey: 'shortcut.app.zoom_out' + targetKey: 'shortcut.app.general.zoom_out' }, { originalKey: 'shortcuts.zoom_reset', - targetKey: 'shortcut.app.zoom_reset' + targetKey: 'shortcut.app.general.zoom_reset' }, { originalKey: 'shortcuts.show_settings', - targetKey: 'shortcut.app.show_settings' + targetKey: 'shortcut.app.general.show_settings' }, { originalKey: 'shortcuts.show_app', - targetKey: 'shortcut.app.show_main_window' + targetKey: 'shortcut.app.general.show_main_window' }, { originalKey: 'shortcuts.mini_window', - targetKey: 'shortcut.app.show_mini_window' + targetKey: 'shortcut.app.general.show_mini_window' }, { originalKey: 'shortcuts.selection_assistant_toggle', - targetKey: 'shortcut.selection.toggle_enabled' + targetKey: 'shortcut.feature.selection.toggle_enabled' }, { originalKey: 'shortcuts.selection_assistant_select_text', - targetKey: 'shortcut.selection.get_text' + targetKey: 'shortcut.feature.selection.get_text' }, { originalKey: 'shortcuts.new_topic', - targetKey: 'shortcut.topic.new' + targetKey: 'shortcut.app.topic.new' }, { originalKey: 'shortcuts.rename_topic', - targetKey: 'shortcut.topic.rename' + targetKey: 'shortcut.app.topic.rename' }, { originalKey: 'shortcuts.toggle_show_topics', - targetKey: 'shortcut.topic.toggle_show_topics' + targetKey: 'shortcut.app.topic.toggle_show_topics' }, { originalKey: 'shortcuts.toggle_show_assistants', - targetKey: 'shortcut.app.toggle_show_assistants' + targetKey: 'shortcut.app.general.toggle_show_assistants' }, { originalKey: 'shortcuts.copy_last_message', - targetKey: 'shortcut.chat.copy_last_message' + targetKey: 'shortcut.app.chat.copy_last_message' }, { originalKey: 'shortcuts.edit_last_user_message', - targetKey: 'shortcut.chat.edit_last_user_message' + targetKey: 'shortcut.app.chat.edit_last_user_message' }, { originalKey: 'shortcuts.search_message_in_chat', - targetKey: 'shortcut.chat.search_message' + targetKey: 'shortcut.app.chat.search_message' }, { originalKey: 'shortcuts.search_message', - targetKey: 'shortcut.app.search_message' + targetKey: 'shortcut.app.general.search' }, { originalKey: 'shortcuts.clear_topic', - targetKey: 'shortcut.chat.clear' + targetKey: 'shortcut.app.chat.clear' }, { originalKey: 'shortcuts.toggle_new_context', - targetKey: 'shortcut.chat.toggle_new_context' + targetKey: 'shortcut.app.chat.toggle_new_context' }, { originalKey: 'shortcuts.select_model', - targetKey: 'shortcut.chat.select_model' + targetKey: 'shortcut.app.chat.select_model' }, { originalKey: 'shortcuts.exit_fullscreen', - targetKey: 'shortcut.app.exit_fullscreen' + targetKey: 'shortcut.app.general.exit_fullscreen' } ], translate: [ diff --git a/src/main/services/AppMenuService.ts b/src/main/services/AppMenuService.ts index ebb8d030b23..3d25f7d4358 100644 --- a/src/main/services/AppMenuService.ts +++ b/src/main/services/AppMenuService.ts @@ -11,9 +11,9 @@ import type { MenuItemConstructorOptions } from 'electron' import { app, Menu, shell } from 'electron' const zoomShortcutKeys: ShortcutPreferenceKey[] = [ - 'shortcut.app.zoom_in', - 'shortcut.app.zoom_out', - 'shortcut.app.zoom_reset' + 'shortcut.app.general.zoom_in', + 'shortcut.app.general.zoom_out', + 'shortcut.app.general.zoom_reset' ] const isShortcutEnabled = (key: ShortcutPreferenceKey): boolean => { @@ -45,9 +45,9 @@ export class AppMenuService extends BaseService { const locale = locales[getAppLanguage()] const { appMenu } = locale.translation - const zoomInEnabled = isShortcutEnabled('shortcut.app.zoom_in') - const zoomOutEnabled = isShortcutEnabled('shortcut.app.zoom_out') - const zoomResetEnabled = isShortcutEnabled('shortcut.app.zoom_reset') + const zoomInEnabled = isShortcutEnabled('shortcut.app.general.zoom_in') + const zoomOutEnabled = isShortcutEnabled('shortcut.app.general.zoom_out') + const zoomResetEnabled = isShortcutEnabled('shortcut.app.general.zoom_reset') const template: MenuItemConstructorOptions[] = [ { diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index bceb1b2be48..9a3d8b3b8cb 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -51,11 +51,11 @@ export class ShortcutService extends BaseService { } private registerBuiltInHandlers(): void { - this.handlers.set('shortcut.app.show_main_window', () => { + this.handlers.set('shortcut.app.general.show_main_window', () => { application.get('WindowService').toggleMainWindow() }) - this.handlers.set('shortcut.app.show_settings', () => { + this.handlers.set('shortcut.app.general.show_settings', () => { let targetWindow = application.get('WindowService').getMainWindow() if ( @@ -74,28 +74,28 @@ export class ShortcutService extends BaseService { targetWindow.webContents.send(IpcChannel.Windows_NavigateToSettings) }) - this.handlers.set('shortcut.app.show_mini_window', () => { + this.handlers.set('shortcut.app.general.show_mini_window', () => { if (!application.get('PreferenceService').get('feature.quick_assistant.enabled')) return application.get('WindowService').toggleMiniWindow() }) - this.handlers.set('shortcut.app.zoom_in', (window) => { + this.handlers.set('shortcut.app.general.zoom_in', (window) => { if (window) handleZoomFactor([window], 0.1) }) - this.handlers.set('shortcut.app.zoom_out', (window) => { + this.handlers.set('shortcut.app.general.zoom_out', (window) => { if (window) handleZoomFactor([window], -0.1) }) - this.handlers.set('shortcut.app.zoom_reset', (window) => { + this.handlers.set('shortcut.app.general.zoom_reset', (window) => { if (window) handleZoomFactor([window], 0, true) }) - this.handlers.set('shortcut.selection.toggle_enabled', () => { + this.handlers.set('shortcut.feature.selection.toggle_enabled', () => { application.get('SelectionService').toggleEnabled() }) - this.handlers.set('shortcut.selection.get_text', () => { + this.handlers.set('shortcut.feature.selection.get_text', () => { application.get('SelectionService').processSelectTextByShortcut() }) } diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx index 992d259e5ac..b6763502a69 100644 --- a/src/renderer/src/components/TopView/index.tsx +++ b/src/renderer/src/components/TopView/index.tsx @@ -37,7 +37,7 @@ const TopViewContainer: React.FC = ({ children }) => { const [modal, modalContextHolder] = Modal.useModal() const [messageApi, messageContextHolder] = message.useMessage() - const [exitFullscreenPref] = usePreference('shortcut.app.exit_fullscreen') + const [exitFullscreenPref] = usePreference('shortcut.app.general.exit_fullscreen') const enableQuitFullScreen = exitFullscreenPref?.enabled !== false useAppInit() diff --git a/src/renderer/src/pages/agents/AgentChat.tsx b/src/renderer/src/pages/agents/AgentChat.tsx index 6644151ed55..717ad518e6f 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( - 'topic.new', + 'app.topic.new', () => { void createDefaultSession() }, diff --git a/src/renderer/src/pages/agents/AgentNavbar.tsx b/src/renderer/src/pages/agents/AgentNavbar.tsx index 36450d82ecf..d1e9d462b56 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('app.search_message', () => { + useShortcut('app.general.search', () => { void SearchPopup.show() }) diff --git a/src/renderer/src/pages/agents/AgentPage.tsx b/src/renderer/src/pages/agents/AgentPage.tsx index 83fd6a50d38..63b296f3a56 100644 --- a/src/renderer/src/pages/agents/AgentPage.tsx +++ b/src/renderer/src/pages/agents/AgentPage.tsx @@ -32,7 +32,7 @@ const AgentPage = () => { const { apiServerConfig, apiServerRunning, apiServerLoading } = useApiServer() const { t } = useTranslation() - useShortcut('app.toggle_show_assistants', () => { + useShortcut('app.general.toggle_show_assistants', () => { if (topicPosition === 'left') { void toggleShowAssistants() return @@ -41,7 +41,7 @@ const AgentPage = () => { void EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS) }) - useShortcut('topic.toggle_show_topics', () => { + useShortcut('app.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 c397c7d69bf..6cc81734a55 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('app.search_message', () => { + useShortcut('app.general.search', () => { void SearchPopup.show() }) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 8e37ed0511a..7f3db251650 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('chat.search_message', () => { + useShortcut('app.chat.search_message', () => { try { const selectedText = window.getSelection()?.toString().trim() contentSearchRef.current?.enable(selectedText) @@ -69,7 +69,7 @@ const Chat: FC = (props) => { } }) - useShortcut('topic.rename', async () => { + useShortcut('app.topic.rename', async () => { const topic = props.activeTopic if (!topic) return @@ -87,7 +87,7 @@ const Chat: FC = (props) => { } }) - useShortcut('chat.select_model', async () => { + useShortcut('app.chat.select_model', async () => { const modelFilter = (m: Model) => !isEmbeddingModel(m) && !isRerankModel(m) const selectedModel = await SelectChatModelPopup.show({ model: assistant?.model, filter: modelFilter }) if (selectedModel) { diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index 97625cd89ba..063e54c341d 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -45,7 +45,7 @@ const HomePage: FC = () => { _activeAssistant = activeAssistant - useShortcut('app.toggle_show_assistants', () => { + useShortcut('app.general.toggle_show_assistants', () => { if (topicPosition === 'right') { void toggleShowAssistants() return @@ -62,7 +62,7 @@ const HomePage: FC = () => { void EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS) }) - useShortcut('topic.toggle_show_topics', () => { + useShortcut('app.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 f73b7540c4b..aa122ce0e2c 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( - 'topic.new', + 'app.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('chat.clear', clearTopic, { + useShortcut('app.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 03d6facbe7c..4adaf43a78d 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('chat.clear') + const clearTopicShortcut = useShortcutDisplay('app.chat.clear') return ( = ({ onNewContext }) => { - const newContextShortcut = useShortcutDisplay('chat.toggle_new_context') + const newContextShortcut = useShortcutDisplay('app.chat.toggle_new_context') const { t } = useTranslation() - useShortcut('chat.toggle_new_context', onNewContext) + useShortcut('app.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 fef38717659..835892945a2 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('topic.new') + const newTopicShortcut = useShortcutDisplay('app.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 e260b0a9e0e..f425fc4a578 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('topic.new') + const newTopicShortcut = useShortcutDisplay('app.topic.new') return ( = ({ assistant, topic, setActiveTopic, o ) }, [displayMessages.length, hasMore, isLoadingMore, messages, setTimeoutTimer]) - useShortcut('chat.copy_last_message', () => { + useShortcut('app.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('chat.edit_last_user_message', () => { + useShortcut('app.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 efcf77cb0f8..9ee84489d50 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('app.search_message', () => { + useShortcut('app.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 ed0f2e932ac..161aefbc28d 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('app.search_message', () => { + useShortcut('app.general.search', () => { void SearchPopup.show() }) diff --git a/src/renderer/src/pages/knowledge/KnowledgePage.tsx b/src/renderer/src/pages/knowledge/KnowledgePage.tsx index 4441dc11de3..49b7d73ed75 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('app.search_message', () => { + useShortcut('app.general.search', () => { if (selectedBase) { void KnowledgeSearchPopup.show({ base: selectedBase }).then() } diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md index 0d3648b7352..95e5535f178 100644 --- a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -110,10 +110,10 @@ v1 快捷键系统存在以下架构缺陷: ```typescript { - key: 'shortcut.app.show_mini_window', // Preference key + key: 'shortcut.app.general.show_mini_window', // Preference key defaultKey: ['CommandOrControl', 'E'], // Electron accelerator 格式 scope: 'main', // main | renderer | both - category: 'selection', // app | chat | topic | selection + category: 'feature.selection', // 点分命名空间 UI 分组:app.general、app.chat、plugin.xxx 等 labelKey: 'mini_window', // i18n label key system: true, // 系统级(不可删除绑定) editable: true, // 用户可修改绑定(默认 true) @@ -127,10 +127,10 @@ v1 快捷键系统存在以下架构缺陷: | 字段 | 用途 | |------|------| -| `key` | Preference key,必须是 `shortcut.{category}.{name}` 格式 | +| `key` | Preference key,内置快捷键用 `shortcut.app.{category}.{name}` 格式,插件用 `shortcut.plugin.{pluginId}.{name}` | | `defaultKey` | Electron accelerator 格式的默认绑定,空数组表示无默认绑定 | | `scope` | 决定快捷键注册在哪个进程:`main`(globalShortcut)、`renderer`(react-hotkeys-hook)、`both`(两者都注册) | -| `category` | 逻辑分类,用于设置页 UI 分组 | +| `category` | 点分命名空间 UI 分组(如 `app.general`、`app.chat`、`app.topic`、`plugin.translator`),类型为 `string` 以支持插件扩展 | | `labelKey` | i18n label key,由 `getShortcutLabel()` 消费 | | `editable` | 设为 `false` 表示用户不可修改绑定(如 Escape 退出全屏),默认 `true` | | `system` | 系统级标记,`true` 时不可删除绑定 | @@ -161,8 +161,8 @@ interface ShortcutPreferenceValue { ```typescript // 两种写法等价,均有类型补全 -useShortcut('chat.clear', callback) -useShortcut('shortcut.chat.clear', callback) +useShortcut('app.chat.clear', callback) +useShortcut('shortcut.app.chat.clear', callback) ``` #### `utils.ts` — 纯函数工具集 @@ -204,8 +204,8 @@ type PreferenceShortcutType = { `preferenceSchemas.ts` 中为每个快捷键声明默认值: ```typescript -'shortcut.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, -'shortcut.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, +'shortcut.app.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, +'shortcut.app.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, ``` ### 3. 主进程服务层 (`ShortcutService`) @@ -227,7 +227,7 @@ export class ShortcutService extends BaseService { ... } private handlers = new Map() // 注册示例 -this.handlers.set('shortcut.app.zoom_in', (window) => { +this.handlers.set('shortcut.app.general.zoom_in', (window) => { if (window) handleZoomFactor([window], 0.1) }) ``` @@ -278,8 +278,8 @@ for (const definition of relevantDefinitions) { ```typescript // 调用侧简洁用法 -useShortcut('chat.clear', () => clearChat()) -useShortcut('topic.new', () => createTopic(), { enableOnFormTags: false }) +useShortcut('app.chat.clear', () => clearChat()) +useShortcut('app.topic.new', () => createTopic(), { enableOnFormTags: false }) ``` **Options:** @@ -334,22 +334,22 @@ preferenceSchemas.ts 中声明 key ↓ 代码生成 PreferenceKeyType(所有偏好 key 的联合类型) ↓ Extract<..., `shortcut.${string}`> -ShortcutPreferenceKey(如 'shortcut.chat.clear') +ShortcutPreferenceKey(如 'shortcut.app.chat.clear') ↓ Template literal infer -ShortcutKey(如 'chat.clear') +ShortcutKey(如 'app.chat.clear') ``` ### 调用侧类型安全 ```typescript -// ✅ 编译通过 — 'chat.clear' 是合法的 ShortcutKey -useShortcut('chat.clear', callback) +// ✅ 编译通过 — 'app.chat.clear' 是合法的 ShortcutKey +useShortcut('app.chat.clear', callback) // ✅ 编译通过 — 完整 key 也被接受 -useShortcut('shortcut.chat.clear', callback) +useShortcut('shortcut.app.chat.clear', callback) -// ❌ 编译报错 — 'chat.invalid' 不在 ShortcutKey 联合类型中 -useShortcut('chat.invalid', callback) +// ❌ 编译报错 — 'app.chat.invalid' 不在 ShortcutKey 联合类型中 +useShortcut('app.chat.invalid', callback) ``` --- @@ -378,7 +378,7 @@ convertKeyToAccelerator() + isValidShortcut() + isDuplicateShortcut() ↓ 通过校验 updatePreference({ key: newKeys }) ↓ useMultiplePreferences.setValues() -preferenceService.set('shortcut.chat.clear', { key: [...], enabled: true }) +preferenceService.set('shortcut.app.chat.clear', { key: [...], enabled: true }) ├── SQLite 持久化 ├── IPC 广播 → 所有渲染窗口自动更新 └── subscribeChange 回调 → ShortcutService.reregisterShortcuts() @@ -401,7 +401,7 @@ callback(event) // 如 clearChat() ``` 用户按下 Cmd+E(窗口失焦状态) ↓ Electron globalShortcut -handlers.get('shortcut.app.show_mini_window') +handlers.get('shortcut.app.general.show_mini_window') ↓ toggleMiniWindow() ``` @@ -414,41 +414,41 @@ toggleMiniWindow() | Preference Key | 默认绑定 | 作用域 | 备注 | |---|---|---|---| -| `shortcut.app.show_main_window` | *(无)* | main | 失焦持久,系统级 | -| `shortcut.app.show_mini_window` | `Cmd/Ctrl+E` | main | 关联 quick_assistant 开关 | -| `shortcut.app.show_settings` | `Cmd/Ctrl+,` | both | 不可编辑 | -| `shortcut.app.toggle_show_assistants` | `Cmd/Ctrl+[` | renderer | | -| `shortcut.app.exit_fullscreen` | `Escape` | renderer | 不可编辑,系统级 | -| `shortcut.app.zoom_in` | `Cmd/Ctrl+=` | main | 含小键盘变体 | -| `shortcut.app.zoom_out` | `Cmd/Ctrl+-` | main | 含小键盘变体 | -| `shortcut.app.zoom_reset` | `Cmd/Ctrl+0` | main | | -| `shortcut.app.search_message` | `Cmd/Ctrl+Shift+F` | renderer | | +| `shortcut.app.general.show_main_window` | *(无)* | main | 失焦持久,系统级 | +| `shortcut.app.general.show_mini_window` | `Cmd/Ctrl+E` | main | 关联 quick_assistant 开关 | +| `shortcut.app.general.show_settings` | `Cmd/Ctrl+,` | both | 不可编辑 | +| `shortcut.app.general.toggle_show_assistants` | `Cmd/Ctrl+[` | renderer | | +| `shortcut.app.general.exit_fullscreen` | `Escape` | renderer | 不可编辑,系统级 | +| `shortcut.app.general.zoom_in` | `Cmd/Ctrl+=` | main | 含小键盘变体 | +| `shortcut.app.general.zoom_out` | `Cmd/Ctrl+-` | main | 含小键盘变体 | +| `shortcut.app.general.zoom_reset` | `Cmd/Ctrl+0` | main | | +| `shortcut.app.general.search` | `Cmd/Ctrl+Shift+F` | renderer | | ### 聊天 (`chat`) | Preference Key | 默认绑定 | 默认启用 | 备注 | |---|---|---|---| -| `shortcut.chat.clear` | `Cmd/Ctrl+L` | 是 | | -| `shortcut.chat.search_message` | `Cmd/Ctrl+F` | 是 | | -| `shortcut.chat.toggle_new_context` | `Cmd/Ctrl+K` | 是 | | -| `shortcut.chat.copy_last_message` | `Cmd/Ctrl+Shift+C` | 否 | | -| `shortcut.chat.edit_last_user_message` | `Cmd/Ctrl+Shift+E` | 否 | | -| `shortcut.chat.select_model` | `Cmd/Ctrl+Shift+M` | 是 | | +| `shortcut.app.chat.clear` | `Cmd/Ctrl+L` | 是 | | +| `shortcut.app.chat.search_message` | `Cmd/Ctrl+F` | 是 | | +| `shortcut.app.chat.toggle_new_context` | `Cmd/Ctrl+K` | 是 | | +| `shortcut.app.chat.copy_last_message` | `Cmd/Ctrl+Shift+C` | 否 | | +| `shortcut.app.chat.edit_last_user_message` | `Cmd/Ctrl+Shift+E` | 否 | | +| `shortcut.app.chat.select_model` | `Cmd/Ctrl+Shift+M` | 是 | | ### 话题 (`topic`) | Preference Key | 默认绑定 | |---|---| -| `shortcut.topic.new` | `Cmd/Ctrl+N` | -| `shortcut.topic.rename` | `Cmd/Ctrl+T` | -| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl+]` | +| `shortcut.app.topic.new` | `Cmd/Ctrl+N` | +| `shortcut.app.topic.rename` | `Cmd/Ctrl+T` | +| `shortcut.app.topic.toggle_show_topics` | `Cmd/Ctrl+]` | ### 划词助手 (`selection`) | Preference Key | 默认绑定 | 支持平台 | |---|---|---| -| `shortcut.selection.toggle_enabled` | *(无)* | macOS, Windows | -| `shortcut.selection.get_text` | *(无)* | macOS, Windows | +| `shortcut.feature.selection.toggle_enabled` | *(无)* | macOS, Windows | +| `shortcut.feature.selection.get_text` | *(无)* | macOS, Windows | --- @@ -460,7 +460,7 @@ toggleMiniWindow() ```typescript // packages/shared/data/preference/preferenceSchemas.ts -'shortcut.chat.regenerate': { enabled: true, key: ['CommandOrControl', 'Shift', 'R'] }, +'shortcut.app.chat.regenerate': { enabled: true, key: ['CommandOrControl', 'Shift', 'R'] }, ``` > 注意:类型声明区也需要添加对应的类型声明行。 @@ -470,10 +470,10 @@ toggleMiniWindow() ```typescript // packages/shared/shortcuts/definitions.ts { - key: 'shortcut.chat.regenerate', + key: 'shortcut.app.chat.regenerate', defaultKey: ['CommandOrControl', 'Shift', 'R'], scope: 'renderer', - category: 'chat' + category: 'app.chat' } ``` @@ -481,10 +481,10 @@ toggleMiniWindow() ```typescript // 渲染进程 -useShortcut('chat.regenerate', () => regenerateLastMessage()) +useShortcut('app.chat.regenerate', () => regenerateLastMessage()) // 或主进程(在 ShortcutService.registerBuiltInHandlers 中) -this.handlers.set('shortcut.chat.regenerate', () => { ... }) +this.handlers.set('shortcut.app.chat.regenerate', () => { ... }) ``` ### 条件启用 @@ -493,7 +493,7 @@ this.handlers.set('shortcut.chat.regenerate', () => { ... }) ```typescript { - key: 'shortcut.app.show_mini_window', + key: 'shortcut.app.general.show_mini_window', enabledWhen: (get) => !!get('feature.quick_assistant.enabled'), // 当 quick_assistant 关闭时,此快捷键不会被注册 } @@ -503,7 +503,7 @@ this.handlers.set('shortcut.chat.regenerate', () => { ... }) ```typescript { - key: 'shortcut.selection.toggle_enabled', + key: 'shortcut.feature.selection.toggle_enabled', supportedPlatforms: ['darwin', 'win32'], // Linux 上不会注册,设置页也不会显示 } diff --git a/v2-refactor-temp/tools/data-classify/data/classification.json b/v2-refactor-temp/tools/data-classify/data/classification.json index 1994aead7ce..7b43dbf3e24 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": 408, - "classified": 282, - "pending": 120, + "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", @@ -2468,44 +2476,56 @@ "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "="] + "key": [ + "CommandOrControl", + "=" + ] }, "originalKey": "zoom_in", "status": "classified", - "targetKey": "shortcut.app.zoom_in", + "targetKey": "shortcut.app.general.zoom_in", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "-"] + "key": [ + "CommandOrControl", + "-" + ] }, "originalKey": "zoom_out", "status": "classified", - "targetKey": "shortcut.app.zoom_out", + "targetKey": "shortcut.app.general.zoom_out", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "0"] + "key": [ + "CommandOrControl", + "0" + ] }, "originalKey": "zoom_reset", "status": "classified", - "targetKey": "shortcut.app.zoom_reset", + "targetKey": "shortcut.app.general.zoom_reset", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", ","] + "key": [ + "CommandOrControl", + "," + ] }, "originalKey": "show_settings", "status": "classified", - "targetKey": "shortcut.app.show_settings", + "targetKey": "shortcut.app.general.show_settings", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2516,18 +2536,21 @@ }, "originalKey": "show_app", "status": "classified", - "targetKey": "shortcut.app.show_main_window", + "targetKey": "shortcut.app.general.show_main_window", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": false, - "key": ["CommandOrControl", "E"] + "key": [ + "CommandOrControl", + "E" + ] }, "originalKey": "mini_window", "status": "classified", - "targetKey": "shortcut.app.show_mini_window", + "targetKey": "shortcut.app.general.show_mini_window", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2538,7 +2561,7 @@ }, "originalKey": "selection_assistant_toggle", "status": "classified", - "targetKey": "shortcut.selection.toggle_enabled", + "targetKey": "shortcut.feature.selection.toggle_enabled", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2549,139 +2572,178 @@ }, "originalKey": "selection_assistant_select_text", "status": "classified", - "targetKey": "shortcut.selection.get_text", + "targetKey": "shortcut.feature.selection.get_text", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "N"] + "key": [ + "CommandOrControl", + "N" + ] }, "originalKey": "new_topic", "status": "classified", - "targetKey": "shortcut.topic.new", + "targetKey": "shortcut.app.topic.new", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "T"] + "key": [ + "CommandOrControl", + "T" + ] }, "originalKey": "rename_topic", "status": "classified", - "targetKey": "shortcut.topic.rename", + "targetKey": "shortcut.app.topic.rename", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "]"] + "key": [ + "CommandOrControl", + "]" + ] }, "originalKey": "toggle_show_topics", "status": "classified", - "targetKey": "shortcut.topic.toggle_show_topics", + "targetKey": "shortcut.app.topic.toggle_show_topics", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "["] + "key": [ + "CommandOrControl", + "[" + ] }, "originalKey": "toggle_show_assistants", "status": "classified", - "targetKey": "shortcut.app.toggle_show_assistants", + "targetKey": "shortcut.app.general.toggle_show_assistants", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": false, - "key": ["CommandOrControl", "Shift", "C"] + "key": [ + "CommandOrControl", + "Shift", + "C" + ] }, "originalKey": "copy_last_message", "status": "classified", - "targetKey": "shortcut.chat.copy_last_message", + "targetKey": "shortcut.app.chat.copy_last_message", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": false, - "key": ["CommandOrControl", "Shift", "E"] + "key": [ + "CommandOrControl", + "Shift", + "E" + ] }, "originalKey": "edit_last_user_message", "status": "classified", - "targetKey": "shortcut.chat.edit_last_user_message", + "targetKey": "shortcut.app.chat.edit_last_user_message", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "F"] + "key": [ + "CommandOrControl", + "F" + ] }, "originalKey": "search_message_in_chat", "status": "classified", - "targetKey": "shortcut.chat.search_message", + "targetKey": "shortcut.app.chat.search_message", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "Shift", "F"] + "key": [ + "CommandOrControl", + "Shift", + "F" + ] }, "originalKey": "search_message", "status": "classified", - "targetKey": "shortcut.app.search_message", + "targetKey": "shortcut.app.general.search", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "L"] + "key": [ + "CommandOrControl", + "L" + ] }, "originalKey": "clear_topic", "status": "classified", - "targetKey": "shortcut.chat.clear", + "targetKey": "shortcut.app.chat.clear", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "K"] + "key": [ + "CommandOrControl", + "K" + ] }, "originalKey": "toggle_new_context", "status": "classified", - "targetKey": "shortcut.chat.toggle_new_context", + "targetKey": "shortcut.app.chat.toggle_new_context", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["CommandOrControl", "Shift", "M"] + "key": [ + "CommandOrControl", + "Shift", + "M" + ] }, "originalKey": "select_model", "status": "classified", - "targetKey": "shortcut.chat.select_model", + "targetKey": "shortcut.app.chat.select_model", "type": "PreferenceTypes.PreferenceShortcutType" }, { "category": "preferences", "defaultValue": { "enabled": true, - "key": ["Escape"] + "key": [ + "Escape" + ] }, "originalKey": "exit_fullscreen", "status": "classified", - "targetKey": "shortcut.app.exit_fullscreen", + "targetKey": "shortcut.app.general.exit_fullscreen", "type": "PreferenceTypes.PreferenceShortcutType" } ] @@ -2743,6 +2805,10 @@ { "originalKey": "settings", "type": "object", + "defaultValue": null, + "status": "pending", + "category": null, + "targetKey": null, "children": [ { "originalKey": "autoCopy", @@ -2753,6 +2819,14 @@ "targetKey": "feature.translate.page.auto_copy" } ] + }, + { + "originalKey": "autoCopy", + "type": "boolean", + "defaultValue": false, + "status": "classified", + "category": "preferences", + "targetKey": "feature.translate.page.auto_copy" } ], "websearch": [ @@ -2836,8 +2910,7 @@ "defaultValue": "codeTools.qwenCode", "status": "classified", "category": "preferences", - "targetKey": null, - "note": "complex mapping: determines which tool gets enabled: true" + "targetKey": null }, { "originalKey": "selectedModels", @@ -2846,7 +2919,6 @@ "status": "classified", "category": "preferences", "targetKey": null, - "note": "complex mapping: Model objects → modelId strings per tool", "children": [ { "category": "preferences", @@ -2896,7 +2968,6 @@ "status": "classified", "category": "preferences", "targetKey": null, - "note": "complex mapping: per-tool env vars → overrides.envVars", "children": [ { "category": "preferences", @@ -2930,8 +3001,7 @@ "defaultValue": [], "status": "classified", "category": "preferences", - "targetKey": null, - "note": "complex mapping: global dirs → assigned to selected tool override" + "targetKey": null }, { "originalKey": "currentDirectory", @@ -2939,8 +3009,7 @@ "defaultValue": "", "status": "classified", "category": "preferences", - "targetKey": null, - "note": "complex mapping: global currentDir → assigned to selected tool override" + "targetKey": null }, { "originalKey": "selectedTerminal", @@ -2948,8 +3017,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": [ @@ -3156,16 +3224,6 @@ "targetKey": "app.zoom_factor" } ], - "Shortcuts": [ - { - "originalKey": "Shortcuts", - "type": "unknown", - "defaultValue": null, - "status": "pending", - "category": "preferences", - "targetKey": null - } - ], "ClickTrayToShowQuickAssistant": [ { "originalKey": "ClickTrayToShowQuickAssistant", @@ -3358,9 +3416,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", @@ -3368,11 +3436,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 @@ -3424,7 +3492,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" @@ -3511,9 +3582,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": [ @@ -3543,11 +3612,9 @@ "defaultValue": null, "status": "classified", "category": "user_data", - "targetTable": "translate_language", - "targetKey": null, - "notes": "Migrated via TranslateMigrator to SQLite translate_language table" + "targetKey": null } ] } } -} +} \ No newline at end of file From 6854148a7bd04d3f66aa7949406a4e8d620e5b1c Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 7 Apr 2026 20:17:13 +0800 Subject: [PATCH 11/37] refactor: rename toggle_show_assistants to toggle_sidebar in shortcuts and translations - Updated shortcut key mapping from 'toggle_show_assistants' to 'toggle_sidebar' in label.ts. - Changed corresponding translations in en-us.json, zh-cn.json, zh-tw.json, de-de.json, el-gr.json, es-es.json, fr-fr.json, ja-jp.json, pt-pt.json, ro-ro.json, and ru-ru.json. - Modified usage of the shortcut in AgentPage.tsx and HomePage.tsx to reflect the new sidebar toggle logic. - Adjusted migration and initial state in migrate.ts and shortcuts.ts to use 'toggle_sidebar'. - Updated documentation and classification files to align with the new shortcut key. --- packages/shared/data/preference/preferenceSchemas.ts | 6 +++--- packages/shared/shortcuts/definitions.ts | 4 ++-- .../migration/v2/migrators/mappings/PreferencesMappings.ts | 4 ++-- src/renderer/src/i18n/label.ts | 2 +- src/renderer/src/i18n/locales/en-us.json | 2 +- src/renderer/src/i18n/locales/zh-cn.json | 2 +- src/renderer/src/i18n/locales/zh-tw.json | 2 +- src/renderer/src/i18n/translate/de-de.json | 2 +- src/renderer/src/i18n/translate/el-gr.json | 2 +- src/renderer/src/i18n/translate/es-es.json | 2 +- src/renderer/src/i18n/translate/fr-fr.json | 2 +- src/renderer/src/i18n/translate/ja-jp.json | 2 +- src/renderer/src/i18n/translate/pt-pt.json | 2 +- src/renderer/src/i18n/translate/ro-ro.json | 2 +- src/renderer/src/i18n/translate/ru-ru.json | 2 +- src/renderer/src/pages/agents/AgentPage.tsx | 3 ++- src/renderer/src/pages/home/HomePage.tsx | 3 ++- src/renderer/src/store/migrate.ts | 2 +- src/renderer/src/store/shortcuts.ts | 2 +- v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md | 2 +- .../tools/data-classify/data/classification.json | 2 +- 21 files changed, 27 insertions(+), 25 deletions(-) diff --git a/packages/shared/data/preference/preferenceSchemas.ts b/packages/shared/data/preference/preferenceSchemas.ts index 619db21ed94..ac77423ffd1 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-07T12:06:27.869Z + * Generated at: 2026-04-07T12:13:54.403Z * * This file is automatically generated from classification.json * To update this file, modify classification.json and run: @@ -447,7 +447,7 @@ export interface PreferenceSchemas { // redux/shortcuts/shortcuts.show_settings 'shortcut.app.general.show_settings': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.toggle_show_assistants - 'shortcut.app.general.toggle_show_assistants': PreferenceTypes.PreferenceShortcutType + 'shortcut.app.general.toggle_sidebar': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_in 'shortcut.app.general.zoom_in': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_out @@ -732,7 +732,7 @@ export const DefaultPreferences: PreferenceSchemas = { 'shortcut.app.general.show_main_window': { enabled: true, key: [] }, 'shortcut.app.general.show_mini_window': { enabled: false, key: ['CommandOrControl', 'E'] }, 'shortcut.app.general.show_settings': { enabled: true, key: ['CommandOrControl', ','] }, - 'shortcut.app.general.toggle_show_assistants': { enabled: true, key: ['CommandOrControl', '['] }, + 'shortcut.app.general.toggle_sidebar': { enabled: true, key: ['CommandOrControl', '['] }, 'shortcut.app.general.zoom_in': { enabled: true, key: ['CommandOrControl', '='] }, 'shortcut.app.general.zoom_out': { enabled: true, key: ['CommandOrControl', '-'] }, 'shortcut.app.general.zoom_reset': { enabled: true, key: ['CommandOrControl', '0'] }, diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index 710b669404b..78e7a271da5 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -31,11 +31,11 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ system: true }, { - key: 'shortcut.app.general.toggle_show_assistants', + key: 'shortcut.app.general.toggle_sidebar', defaultKey: ['CommandOrControl', '['], scope: 'renderer', category: 'app.general', - labelKey: 'toggle_show_assistants' + labelKey: 'toggle_sidebar' }, { key: 'shortcut.app.general.exit_fullscreen', diff --git a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts index 0d993578238..5b69d625487 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-07T12:06:27.872Z + * Generated at: 2026-04-07T12:13:54.406Z * * This file contains pure mapping relationships without default values. * Default values are managed in packages/shared/data/preferences.ts @@ -794,7 +794,7 @@ export const REDUX_STORE_MAPPINGS = { }, { originalKey: 'shortcuts.toggle_show_assistants', - targetKey: 'shortcut.app.general.toggle_show_assistants' + targetKey: 'shortcut.app.general.toggle_sidebar' }, { originalKey: 'shortcuts.copy_last_message', diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index d8ca93dfb65..df1804e4acb 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -228,7 +228,7 @@ const shortcutKeyMap = { 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_sidebar: 'settings.shortcuts.toggle_sidebar', toggle_show_topics: 'settings.shortcuts.toggle_show_topics', zoom_in: 'settings.shortcuts.zoom_in', zoom_out: 'settings.shortcuts.zoom_out', diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 50e7b32df30..b2d2af073a6 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -5578,8 +5578,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 03bfdbd1a01..a6e6d200a2f 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -5578,8 +5578,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 7cb825dd2a8..42c68ceab2f 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -5578,8 +5578,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/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 67ed5570daa..c5358ba4b35 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -5578,8 +5578,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": "Assistentenanzeige umschalten", "zoom_in": "Oberfläche vergrößern", "zoom_out": "Oberfläche verkleinern", "zoom_reset": "Zoom zurücksetzen" diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 3664b5854cf..6bf74393c5f 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -5578,8 +5578,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/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 5b6c370766d..d705369eae3 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -5578,8 +5578,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 visibilidad de asistentes", "zoom_in": "Ampliar interfaz", "zoom_out": "Reducir interfaz", "zoom_reset": "Restablecer zoom" diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index c98716d920c..f90bac0bd15 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -5578,8 +5578,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 l'affichage des assistants", "zoom_in": "Agrandir l'interface", "zoom_out": "Réduire l'interface", "zoom_reset": "Réinitialiser le zoom" diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 4d75887af51..475d1f75413 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -5578,8 +5578,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/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 56b0264e1eb..594201057ad 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -5578,8 +5578,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 exibição de assistentes", "zoom_in": "Ampliar interface", "zoom_out": "Diminuir interface", "zoom_reset": "Redefinir zoom" diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index 80fe92446d4..f81a3b7abe5 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -5578,8 +5578,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": "Comută asistenții", "zoom_in": "Mărește", "zoom_out": "Micșorează", "zoom_reset": "Resetează zoom-ul" diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 551957bc952..5d631afb807 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -5578,8 +5578,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/pages/agents/AgentPage.tsx b/src/renderer/src/pages/agents/AgentPage.tsx index 63b296f3a56..9cde95220c2 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('app.general.toggle_show_assistants', () => { + // TODO: Replace with sidebar toggle logic once the new sidebar UI is implemented + useShortcut('app.general.toggle_sidebar', () => { if (topicPosition === 'left') { void toggleShowAssistants() return diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index 063e54c341d..c0029d623f9 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('app.general.toggle_show_assistants', () => { + // TODO: Replace with sidebar toggle logic once the new sidebar UI is implemented + useShortcut('app.general.toggle_sidebar', () => { if (topicPosition === 'right') { void toggleShowAssistants() return diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index f90a1d9a124..34e698d8485 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -804,7 +804,7 @@ const migrateConfig = { shortcut.system = shortcut.key !== 'new_topic' }) state.shortcuts.shortcuts.push({ - key: 'toggle_show_assistants', + key: 'toggle_sidebar', shortcut: [isMac ? 'Command' : 'Ctrl', '['], editable: true, enabled: true, diff --git a/src/renderer/src/store/shortcuts.ts b/src/renderer/src/store/shortcuts.ts index 1d2af71b608..c2afb2e60f0 100644 --- a/src/renderer/src/store/shortcuts.ts +++ b/src/renderer/src/store/shortcuts.ts @@ -78,7 +78,7 @@ const initialState: ShortcutsState = { system: false }, { - key: 'toggle_show_assistants', + key: 'toggle_sidebar', shortcut: ['CommandOrControl', '['], editable: true, enabled: true, diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md index 95e5535f178..d61bfe113e7 100644 --- a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -417,7 +417,7 @@ toggleMiniWindow() | `shortcut.app.general.show_main_window` | *(无)* | main | 失焦持久,系统级 | | `shortcut.app.general.show_mini_window` | `Cmd/Ctrl+E` | main | 关联 quick_assistant 开关 | | `shortcut.app.general.show_settings` | `Cmd/Ctrl+,` | both | 不可编辑 | -| `shortcut.app.general.toggle_show_assistants` | `Cmd/Ctrl+[` | renderer | | +| `shortcut.app.general.toggle_sidebar` | `Cmd/Ctrl+[` | renderer | | | `shortcut.app.general.exit_fullscreen` | `Escape` | renderer | 不可编辑,系统级 | | `shortcut.app.general.zoom_in` | `Cmd/Ctrl+=` | main | 含小键盘变体 | | `shortcut.app.general.zoom_out` | `Cmd/Ctrl+-` | main | 含小键盘变体 | diff --git a/v2-refactor-temp/tools/data-classify/data/classification.json b/v2-refactor-temp/tools/data-classify/data/classification.json index 7b43dbf3e24..26f2b018e7f 100644 --- a/v2-refactor-temp/tools/data-classify/data/classification.json +++ b/v2-refactor-temp/tools/data-classify/data/classification.json @@ -2628,7 +2628,7 @@ }, "originalKey": "toggle_show_assistants", "status": "classified", - "targetKey": "shortcut.app.general.toggle_show_assistants", + "targetKey": "shortcut.app.general.toggle_sidebar", "type": "PreferenceTypes.PreferenceShortcutType" }, { From 3da7678830f7d56ac0334340d422ce71d8b52d95 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 8 Apr 2026 17:11:01 +0800 Subject: [PATCH 12/37] refactor(shortcuts): improve coerceShortcutPreference logic and update useShortcut options handling --- packages/shared/__tests__/shortcutUtils.test.ts | 4 ++-- packages/shared/shortcuts/utils.ts | 2 +- src/renderer/src/hooks/useShortcuts.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/shared/__tests__/shortcutUtils.test.ts b/packages/shared/__tests__/shortcutUtils.test.ts index efef97fc1d5..5f59a980333 100644 --- a/packages/shared/__tests__/shortcutUtils.test.ts +++ b/packages/shared/__tests__/shortcutUtils.test.ts @@ -148,14 +148,14 @@ describe('coerceShortcutPreference', () => { expect(result.binding).toEqual(['Alt', 'L']) }) - it('falls back to default when key is empty array', () => { + it('returns empty binding when key is explicitly cleared (empty array)', () => { const def = makeDefinition() const result = coerceShortcutPreference(def, { key: [], enabled: true }) - expect(result.binding).toEqual(['CommandOrControl', 'L']) + expect(result.binding).toEqual([]) }) it('respects enabled: false from preference', () => { diff --git a/packages/shared/shortcuts/utils.ts b/packages/shared/shortcuts/utils.ts index 30018ade9cd..03e46eb447c 100644 --- a/packages/shared/shortcuts/utils.ts +++ b/packages/shared/shortcuts/utils.ts @@ -123,7 +123,7 @@ export const coerceShortcutPreference = ( value?: PreferenceShortcutType | null ): ShortcutPreferenceValue => { const fallback = getDefaultShortcutPreference(definition) - const binding = value?.key?.length ? ensureArray(value.key) : fallback.binding + const binding = value != null ? (value.key?.length ? ensureArray(value.key) : []) : fallback.binding return { binding, diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index 7440f3247cf..c0212684f62 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -92,10 +92,10 @@ export const useShortcut = ( } }, { - enableOnFormTags: options.enableOnFormTags, - description: options.description ?? fullKey, + enableOnFormTags: optionsRef.current.enableOnFormTags, + description: optionsRef.current.description ?? fullKey, enabled: hotkey !== 'none', - enableOnContentEditable: options.enableOnContentEditable + enableOnContentEditable: optionsRef.current.enableOnContentEditable }, [hotkey] ) From 5db347805d88cf5be41312d84bf5040c3c1210e1 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 8 Apr 2026 18:36:18 +0800 Subject: [PATCH 13/37] refactor(shortcuts): rename defaultKey to defaultBinding and update related logic --- .../shared/__tests__/shortcutUtils.test.ts | 28 ++++----- packages/shared/shortcuts/definitions.ts | 40 ++++++------ packages/shared/shortcuts/types.ts | 6 +- packages/shared/shortcuts/utils.ts | 12 ++-- src/main/services/AppMenuService.ts | 4 +- src/main/services/ShortcutService.ts | 4 +- src/renderer/src/hooks/useShortcuts.ts | 63 +++++++------------ .../src/pages/settings/ShortcutSettings.tsx | 2 +- .../shortcuts/shortcut-system-refactor.md | 20 +++--- 9 files changed, 82 insertions(+), 97 deletions(-) diff --git a/packages/shared/__tests__/shortcutUtils.test.ts b/packages/shared/__tests__/shortcutUtils.test.ts index 5f59a980333..8ea8c68eca3 100644 --- a/packages/shared/__tests__/shortcutUtils.test.ts +++ b/packages/shared/__tests__/shortcutUtils.test.ts @@ -2,17 +2,17 @@ import { describe, expect, it } from 'vitest' import type { ShortcutDefinition } from '../shortcuts/types' import { - coerceShortcutPreference, convertAcceleratorToHotkey, convertKeyToAccelerator, formatShortcutDisplay, - getDefaultShortcutPreference, - isValidShortcut + getDefaultShortcut, + isValidShortcut, + resolveShortcutPreference } from '../shortcuts/utils' const makeDefinition = (overrides: Partial = {}): ShortcutDefinition => ({ key: 'shortcut.app.chat.clear', - defaultKey: ['CommandOrControl', 'L'], + defaultBinding: ['CommandOrControl', 'L'], scope: 'renderer', category: 'app.chat', labelKey: 'clear_topic', @@ -100,10 +100,10 @@ describe('isValidShortcut', () => { }) }) -describe('getDefaultShortcutPreference', () => { +describe('getDefaultShortcut', () => { it('returns default preference from schema defaults', () => { const def = makeDefinition() - const result = getDefaultShortcutPreference(def) + const result = getDefaultShortcut(def) expect(result.binding).toEqual(['CommandOrControl', 'L']) expect(result.enabled).toBe(true) @@ -113,19 +113,19 @@ describe('getDefaultShortcutPreference', () => { it('respects editable: false', () => { const def = makeDefinition({ editable: false }) - expect(getDefaultShortcutPreference(def).editable).toBe(false) + expect(getDefaultShortcut(def).editable).toBe(false) }) it('respects system: true', () => { const def = makeDefinition({ system: true }) - expect(getDefaultShortcutPreference(def).system).toBe(true) + expect(getDefaultShortcut(def).system).toBe(true) }) }) -describe('coerceShortcutPreference', () => { +describe('resolveShortcutPreference', () => { it('returns fallback when value is undefined', () => { const def = makeDefinition() - const result = coerceShortcutPreference(def, undefined) + const result = resolveShortcutPreference(def, undefined) expect(result.binding).toEqual(['CommandOrControl', 'L']) expect(result.enabled).toBe(true) @@ -133,14 +133,14 @@ describe('coerceShortcutPreference', () => { it('returns fallback when value is null', () => { const def = makeDefinition() - const result = coerceShortcutPreference(def, null) + const result = resolveShortcutPreference(def, null) expect(result.binding).toEqual(['CommandOrControl', 'L']) }) it('uses custom key when provided', () => { const def = makeDefinition() - const result = coerceShortcutPreference(def, { + const result = resolveShortcutPreference(def, { key: ['Alt', 'L'], enabled: true }) @@ -150,7 +150,7 @@ describe('coerceShortcutPreference', () => { it('returns empty binding when key is explicitly cleared (empty array)', () => { const def = makeDefinition() - const result = coerceShortcutPreference(def, { + const result = resolveShortcutPreference(def, { key: [], enabled: true }) @@ -160,7 +160,7 @@ describe('coerceShortcutPreference', () => { it('respects enabled: false from preference', () => { const def = makeDefinition() - const result = coerceShortcutPreference(def, { + const result = resolveShortcutPreference(def, { key: ['CommandOrControl', 'L'], enabled: false }) diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index 78e7a271da5..fcfa395a51c 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -4,7 +4,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 应用级快捷键 ==================== { key: 'shortcut.app.general.show_main_window', - defaultKey: [], + defaultBinding: [], scope: 'main', category: 'app.general', labelKey: 'show_app', @@ -13,7 +13,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.app.general.show_mini_window', - defaultKey: ['CommandOrControl', 'E'], + defaultBinding: ['CommandOrControl', 'E'], scope: 'main', category: 'app.general', labelKey: 'mini_window', @@ -23,7 +23,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.app.general.show_settings', - defaultKey: ['CommandOrControl', ','], + defaultBinding: ['CommandOrControl', ','], scope: 'both', category: 'app.general', labelKey: 'show_settings', @@ -32,14 +32,14 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.app.general.toggle_sidebar', - defaultKey: ['CommandOrControl', '['], + defaultBinding: ['CommandOrControl', '['], scope: 'renderer', category: 'app.general', labelKey: 'toggle_sidebar' }, { key: 'shortcut.app.general.exit_fullscreen', - defaultKey: ['Escape'], + defaultBinding: ['Escape'], scope: 'renderer', category: 'app.general', labelKey: 'exit_fullscreen', @@ -48,7 +48,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.app.general.zoom_in', - defaultKey: ['CommandOrControl', '='], + defaultBinding: ['CommandOrControl', '='], scope: 'main', category: 'app.general', labelKey: 'zoom_in', @@ -58,7 +58,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.app.general.zoom_out', - defaultKey: ['CommandOrControl', '-'], + defaultBinding: ['CommandOrControl', '-'], scope: 'main', category: 'app.general', labelKey: 'zoom_out', @@ -68,7 +68,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.app.general.zoom_reset', - defaultKey: ['CommandOrControl', '0'], + defaultBinding: ['CommandOrControl', '0'], scope: 'main', category: 'app.general', labelKey: 'zoom_reset', @@ -77,7 +77,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.app.general.search', - defaultKey: ['CommandOrControl', 'Shift', 'F'], + defaultBinding: ['CommandOrControl', 'Shift', 'F'], scope: 'renderer', category: 'app.general', labelKey: 'search_message' @@ -85,42 +85,42 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 聊天相关快捷键 ==================== { key: 'shortcut.app.chat.clear', - defaultKey: ['CommandOrControl', 'L'], + defaultBinding: ['CommandOrControl', 'L'], scope: 'renderer', category: 'app.chat', labelKey: 'clear_topic' }, { key: 'shortcut.app.chat.search_message', - defaultKey: ['CommandOrControl', 'F'], + defaultBinding: ['CommandOrControl', 'F'], scope: 'renderer', category: 'app.chat', labelKey: 'search_message_in_chat' }, { key: 'shortcut.app.chat.toggle_new_context', - defaultKey: ['CommandOrControl', 'K'], + defaultBinding: ['CommandOrControl', 'K'], scope: 'renderer', category: 'app.chat', labelKey: 'toggle_new_context' }, { key: 'shortcut.app.chat.copy_last_message', - defaultKey: ['CommandOrControl', 'Shift', 'C'], + defaultBinding: ['CommandOrControl', 'Shift', 'C'], scope: 'renderer', category: 'app.chat', labelKey: 'copy_last_message' }, { key: 'shortcut.app.chat.edit_last_user_message', - defaultKey: ['CommandOrControl', 'Shift', 'E'], + defaultBinding: ['CommandOrControl', 'Shift', 'E'], scope: 'renderer', category: 'app.chat', labelKey: 'edit_last_user_message' }, { key: 'shortcut.app.chat.select_model', - defaultKey: ['CommandOrControl', 'Shift', 'M'], + defaultBinding: ['CommandOrControl', 'Shift', 'M'], scope: 'renderer', category: 'app.chat', labelKey: 'select_model' @@ -128,21 +128,21 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 话题管理快捷键 ==================== { key: 'shortcut.app.topic.new', - defaultKey: ['CommandOrControl', 'N'], + defaultBinding: ['CommandOrControl', 'N'], scope: 'renderer', category: 'app.topic', labelKey: 'new_topic' }, { key: 'shortcut.app.topic.rename', - defaultKey: ['CommandOrControl', 'T'], + defaultBinding: ['CommandOrControl', 'T'], scope: 'renderer', category: 'app.topic', labelKey: 'rename_topic' }, { key: 'shortcut.app.topic.toggle_show_topics', - defaultKey: ['CommandOrControl', ']'], + defaultBinding: ['CommandOrControl', ']'], scope: 'renderer', category: 'app.topic', labelKey: 'toggle_show_topics' @@ -150,7 +150,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 划词助手快捷键 ==================== { key: 'shortcut.feature.selection.toggle_enabled', - defaultKey: [], + defaultBinding: [], scope: 'main', category: 'feature.selection', labelKey: 'selection_assistant_toggle', @@ -160,7 +160,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.feature.selection.get_text', - defaultKey: [], + defaultBinding: [], scope: 'main', category: 'feature.selection', labelKey: 'selection_assistant_select_text', diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index ed47330df27..29931b23f1b 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -28,7 +28,7 @@ export interface ShortcutDefinition { /** Preference key in `shortcut.app.{category}.{name}` format for built-in shortcuts. Plugins use `shortcut.plugin.{pluginId}.{name}`. */ key: ShortcutPreferenceKey /** Default key binding in Electron accelerator format (e.g. `['CommandOrControl', 'L']`). Empty array means no default binding. */ - defaultKey: string[] + defaultBinding: string[] /** Where the shortcut is registered: `main` (globalShortcut), `renderer` (react-hotkeys-hook), or `both`. */ scope: ShortcutScope /** Dot-separated category for UI grouping (e.g. `app.general`, `app.chat`, `app.topic`, `plugin.translator`). */ @@ -50,8 +50,8 @@ export interface ShortcutDefinition { } /** Runtime-resolved shortcut state after merging user preferences with definition defaults. */ -export interface ShortcutPreferenceValue { - /** Effective key binding used at runtime. Always contains a valid binding (user-defined or default). */ +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 index 03e46eb447c..b012fa6f287 100644 --- a/packages/shared/shortcuts/utils.ts +++ b/packages/shared/shortcuts/utils.ts @@ -1,7 +1,7 @@ import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas' import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' -import type { ShortcutDefinition, ShortcutPreferenceValue } from './types' +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'] @@ -104,11 +104,11 @@ const ensureArray = (value: unknown): string[] => { const ensureBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback) -export const getDefaultShortcutPreference = (definition: ShortcutDefinition): ShortcutPreferenceValue => { +export const getDefaultShortcut = (definition: ShortcutDefinition): ResolvedShortcut => { const fallback = DefaultPreferences.default[definition.key] const rawBinding = ensureArray(fallback?.key) - const binding = rawBinding.length ? rawBinding : definition.defaultKey + const binding = rawBinding.length ? rawBinding : definition.defaultBinding return { binding, @@ -118,11 +118,11 @@ export const getDefaultShortcutPreference = (definition: ShortcutDefinition): Sh } } -export const coerceShortcutPreference = ( +export const resolveShortcutPreference = ( definition: ShortcutDefinition, value?: PreferenceShortcutType | null -): ShortcutPreferenceValue => { - const fallback = getDefaultShortcutPreference(definition) +): ResolvedShortcut => { + const fallback = getDefaultShortcut(definition) const binding = value != null ? (value.key?.length ? ensureArray(value.key) : []) : fallback.binding return { diff --git a/src/main/services/AppMenuService.ts b/src/main/services/AppMenuService.ts index 3d25f7d4358..7c320aa3123 100644 --- a/src/main/services/AppMenuService.ts +++ b/src/main/services/AppMenuService.ts @@ -6,7 +6,7 @@ import type { PreferenceShortcutType } from '@shared/data/preference/preferenceT import { IpcChannel } from '@shared/IpcChannel' import { findShortcutDefinition } from '@shared/shortcuts/definitions' import type { ShortcutPreferenceKey } from '@shared/shortcuts/types' -import { coerceShortcutPreference } from '@shared/shortcuts/utils' +import { resolveShortcutPreference } from '@shared/shortcuts/utils' import type { MenuItemConstructorOptions } from 'electron' import { app, Menu, shell } from 'electron' @@ -20,7 +20,7 @@ const isShortcutEnabled = (key: ShortcutPreferenceKey): boolean => { const definition = findShortcutDefinition(key) if (!definition) return true const rawPref = application.get('PreferenceService').get(key) as PreferenceShortcutType | undefined - return coerceShortcutPreference(definition, rawPref).enabled + return resolveShortcutPreference(definition, rawPref).enabled } const getMainWindows = (): Electron.BrowserWindow[] => diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 9a3d8b3b8cb..82417857f1a 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -6,7 +6,7 @@ import type { PreferenceShortcutType } from '@shared/data/preference/preferenceT import { IpcChannel } from '@shared/IpcChannel' import { SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions' import type { ShortcutPreferenceKey, SupportedPlatform } from '@shared/shortcuts/types' -import { coerceShortcutPreference } from '@shared/shortcuts/utils' +import { resolveShortcutPreference } from '@shared/shortcuts/utils' import type { BrowserWindow } from 'electron' import { globalShortcut } from 'electron' @@ -150,7 +150,7 @@ export class ShortcutService extends BaseService { if (onlyPersistent && !definition.global) continue const rawPref = preferenceService.get(definition.key) as PreferenceShortcutType | undefined - const pref = coerceShortcutPreference(definition, rawPref) + const pref = resolveShortcutPreference(definition, rawPref) if (!pref.enabled || !pref.binding.length) continue if (definition.enabledWhen && !definition.enabledWhen(getPreference as any)) continue diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index c0212684f62..56505a88ced 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -2,17 +2,12 @@ import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference import { isMac } from '@renderer/config/constant' import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions' -import type { - ShortcutDefinition, - ShortcutKey, - ShortcutPreferenceKey, - ShortcutPreferenceValue -} from '@shared/shortcuts/types' +import type { ResolvedShortcut, ShortcutDefinition, ShortcutKey, ShortcutPreferenceKey } from '@shared/shortcuts/types' import { - coerceShortcutPreference, convertAcceleratorToHotkey, formatShortcutDisplay, - getDefaultShortcutPreference + getDefaultShortcut, + resolveShortcutPreference } from '@shared/shortcuts/utils' import { useCallback, useMemo, useRef } from 'react' import { useHotkeys } from 'react-hotkeys-hook' @@ -35,16 +30,6 @@ const defaultOptions: UseShortcutOptions = { const toFullKey = (key: ShortcutKey | ShortcutPreferenceKey): ShortcutPreferenceKey => (key.startsWith('shortcut.') ? key : `shortcut.${key}`) as ShortcutPreferenceKey -const resolvePreferenceValue = ( - definition: ShortcutDefinition | undefined, - preference: PreferenceShortcutType | undefined -): ShortcutPreferenceValue | null => { - if (!definition) { - return null - } - return coerceShortcutPreference(definition, preference) -} - export const useShortcut = ( shortcutKey: ShortcutKey | ShortcutPreferenceKey, callback: (event: KeyboardEvent) => void, @@ -53,7 +38,10 @@ export const useShortcut = ( const fullKey = toFullKey(shortcutKey) const definition = useMemo(() => findShortcutDefinition(fullKey), [fullKey]) const [preference] = usePreference(fullKey) - const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference]) + const resolved = useMemo( + () => (definition ? resolveShortcutPreference(definition, preference) : null), + [definition, preference] + ) const callbackRef = useRef(callback) callbackRef.current = callback @@ -62,7 +50,7 @@ export const useShortcut = ( optionsRef.current = options const hotkey = useMemo(() => { - if (!definition || !preferenceState) { + if (!definition || !resolved) { return 'none' } @@ -70,16 +58,16 @@ export const useShortcut = ( return 'none' } - if (!preferenceState.enabled) { + if (!resolved.enabled) { return 'none' } - if (!preferenceState.binding.length) { + if (!resolved.binding.length) { return 'none' } - return convertAcceleratorToHotkey(preferenceState.binding) - }, [definition, preferenceState]) + return convertAcceleratorToHotkey(resolved.binding) + }, [definition, resolved]) useHotkeys( hotkey, @@ -105,27 +93,24 @@ export const useShortcutDisplay = (shortcutKey: ShortcutKey | ShortcutPreference const fullKey = toFullKey(shortcutKey) const definition = useMemo(() => findShortcutDefinition(fullKey), [fullKey]) const [preference] = usePreference(fullKey) - const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference]) + const resolved = useMemo( + () => (definition ? resolveShortcutPreference(definition, preference) : null), + [definition, preference] + ) return useMemo(() => { - if (!definition || !preferenceState || !preferenceState.enabled) { - return '' - } - - const displayBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey - - if (!displayBinding.length) { + if (!definition || !resolved || !resolved.enabled || !resolved.binding.length) { return '' } - return formatShortcutDisplay(displayBinding, isMac) - }, [definition, preferenceState]) + return formatShortcutDisplay(resolved.binding, isMac) + }, [definition, resolved]) } export interface ShortcutListItem { definition: ShortcutDefinition - preference: ShortcutPreferenceValue - defaultPreference: ShortcutPreferenceValue + preference: ResolvedShortcut + defaultPreference: ResolvedShortcut updatePreference: (patch: Partial) => Promise } @@ -143,7 +128,7 @@ export const useAllShortcuts = (): ShortcutListItem[] => { const buildNextPreference = useCallback( ( - state: ShortcutPreferenceValue, + state: ResolvedShortcut, currentValue: PreferenceShortcutType | undefined, patch: Partial ): PreferenceShortcutType => { @@ -170,8 +155,8 @@ export const useAllShortcuts = (): ShortcutListItem[] => { () => SHORTCUT_DEFINITIONS.map((definition) => { const rawValue = values[definition.key] as PreferenceShortcutType | undefined - const preference = coerceShortcutPreference(definition, rawValue) - const defaultPreference = getDefaultShortcutPreference(definition) + const preference = resolveShortcutPreference(definition, rawValue) + const defaultPreference = getDefaultShortcut(definition) const updatePreference = async (patch: Partial) => { const currentValue = values[definition.key] as PreferenceShortcutType | undefined diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index e4100cc86fc..7ea40a15e4a 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -82,7 +82,7 @@ const ShortcutSettings: FC = () => { return filtered.map((item) => { const label = getShortcutLabel(item.definition.labelKey) - const displayKeys = item.preference.binding.length > 0 ? item.preference.binding : item.definition.defaultKey + const displayKeys = item.preference.binding return { id: item.definition.key, diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md index d61bfe113e7..e3d6ea62ed6 100644 --- a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -111,7 +111,7 @@ v1 快捷键系统存在以下架构缺陷: ```typescript { key: 'shortcut.app.general.show_mini_window', // Preference key - defaultKey: ['CommandOrControl', 'E'], // Electron accelerator 格式 + defaultBinding: ['CommandOrControl', 'E'], // Electron accelerator 格式 scope: 'main', // main | renderer | both category: 'feature.selection', // 点分命名空间 UI 分组:app.general、app.chat、plugin.xxx 等 labelKey: 'mini_window', // i18n label key @@ -128,7 +128,7 @@ v1 快捷键系统存在以下架构缺陷: | 字段 | 用途 | |------|------| | `key` | Preference key,内置快捷键用 `shortcut.app.{category}.{name}` 格式,插件用 `shortcut.plugin.{pluginId}.{name}` | -| `defaultKey` | Electron accelerator 格式的默认绑定,空数组表示无默认绑定 | +| `defaultBinding` | Electron accelerator 格式的默认绑定,空数组表示无默认绑定 | | `scope` | 决定快捷键注册在哪个进程:`main`(globalShortcut)、`renderer`(react-hotkeys-hook)、`both`(两者都注册) | | `category` | 点分命名空间 UI 分组(如 `app.general`、`app.chat`、`app.topic`、`plugin.translator`),类型为 `string` 以支持插件扩展 | | `labelKey` | i18n label key,由 `getShortcutLabel()` 消费 | @@ -149,8 +149,8 @@ type ShortcutPreferenceKey = Extract type ShortcutKey = ShortcutPreferenceKey extends `shortcut.${infer Rest}` ? Rest : never // 运行时归一化后的完整状态 -interface ShortcutPreferenceValue { - binding: string[] // 生效的绑定(用户自定义或 fallback 到 defaultKey,始终有效) +interface ResolvedShortcut { + binding: string[] // 生效的绑定(用户自定义、默认值或空数组——显式清空) enabled: boolean // 是否启用 editable: boolean // 来自 definition.editable,不存储在偏好中 system: boolean // 来自 definition.system,不存储在偏好中 @@ -173,19 +173,19 @@ useShortcut('shortcut.app.chat.clear', callback) | `convertAcceleratorToHotkey` | Electron accelerator → `react-hotkeys-hook` 字符串 | | `formatShortcutDisplay` | accelerator → 用户友好的显示字符串(Mac 用符号,其他用文字) | | `isValidShortcut` | 校验快捷键有效性(须含修饰键,或为特殊单键如 F1-F12、Escape) | -| `getDefaultShortcutPreference` | 从 `DefaultPreferences` 读取 schema 默认值并归一化 | -| `coerceShortcutPreference` | **核心归一化函数**:将任意偏好值 + 定义 → 完整的 `ShortcutPreferenceValue` | +| `getDefaultShortcut` | 从 `DefaultPreferences` 读取 schema 默认值并归一化 | +| `resolveShortcutPreference` | **核心归一化函数**:将任意偏好值 + 定义 → 完整的 `ResolvedShortcut` | -`coerceShortcutPreference` 的防御逻辑: +`resolveShortcutPreference` 的防御逻辑: ``` 输入值为 null/undefined → 使用 schema 默认值 -输入的 key 为空数组 → binding 回退到 defaultKey +输入的 key 为空数组 → binding 为空(用户显式清空) 输入的 enabled 非布尔 → 使用默认 enabled editable/system → 始终从 definition 读取(不存储在偏好中) ``` -**设计决策**:禁用快捷键统一使用 `enabled: false`,`binding` 始终包含有效绑定(用户自定义或默认值)。不存在"清空绑定"的独立语义——想禁用就关 `enabled`,想换键就录制覆盖,想重置就写回 `defaultKey`。 +**设计决策**:禁用快捷键可以使用 `enabled: false`,也可以清空绑定(`key: []`)。想换键就录制覆盖,想重置就写回 `defaultBinding`。 ### 2. 偏好层 (`preferenceSchemas.ts` + `preferenceTypes.ts`) @@ -471,7 +471,7 @@ toggleMiniWindow() // packages/shared/shortcuts/definitions.ts { key: 'shortcut.app.chat.regenerate', - defaultKey: ['CommandOrControl', 'Shift', 'R'], + defaultBinding: ['CommandOrControl', 'Shift', 'R'], scope: 'renderer', category: 'app.chat' } From f934bd03aa861764e1a513c8f39f834a684db042 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 9 Apr 2026 13:13:16 +0800 Subject: [PATCH 14/37] refactor: update auto-generated timestamps in boot and preference config files --- packages/shared/data/bootConfig/bootConfigSchemas.ts | 2 +- packages/shared/data/preference/preferenceSchemas.ts | 2 +- .../data/migration/v2/migrators/mappings/BootConfigMappings.ts | 2 +- .../data/migration/v2/migrators/mappings/PreferencesMappings.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/data/bootConfig/bootConfigSchemas.ts b/packages/shared/data/bootConfig/bootConfigSchemas.ts index 4e4c7ea3de7..4c852db47ea 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-08T07:05:58.553Z + * Generated at: 2026-04-09T05:12:37.581Z * * 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 ac77423ffd1..dff40171d9d 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-07T12:13:54.403Z + * Generated at: 2026-04-09T05:12:37.575Z * * This file is automatically generated from classification.json * To update this file, modify classification.json and run: diff --git a/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts b/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts index d86957df829..bbaf07c27d7 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-08T07:05:58.556Z + * Generated at: 2026-04-09T05:12:37.582Z * * 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/PreferencesMappings.ts b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts index 5b69d625487..1bc24ca4967 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-07T12:13:54.406Z + * Generated at: 2026-04-09T05:12:37.582Z * * This file contains pure mapping relationships without default values. * Default values are managed in packages/shared/data/preferences.ts From e9b1ea45d660136ce128232e6c151e97d21fd3d5 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 10 Apr 2026 18:44:02 +0800 Subject: [PATCH 15/37] Refactor shortcut keys to remove 'app.' prefix and update related mappings - Changed shortcut keys from 'shortcut.app.*' to 'shortcut.*' for general, chat, and topic categories. - Updated all references in the codebase to reflect the new shortcut key format. - Adjusted preference mappings and documentation to align with the new structure. - Ensured backward compatibility by updating migration scripts and Redux store mappings. --- .../data/preference-schema-guide.md | 2 +- .../shared/__tests__/shortcutUtils.test.ts | 4 +- .../data/bootConfig/bootConfigSchemas.ts | 2 +- .../data/preference/preferenceSchemas.ts | 82 +++++++++--------- packages/shared/shortcuts/definitions.ts | 72 ++++++++-------- packages/shared/shortcuts/types.ts | 8 +- .../migrators/mappings/BootConfigMappings.ts | 2 +- .../migrators/mappings/PreferencesMappings.ts | 38 ++++----- src/main/services/AppMenuService.ts | 12 +-- src/main/services/ShortcutService.ts | 12 +-- src/renderer/src/components/TopView/index.tsx | 2 +- src/renderer/src/pages/agents/AgentChat.tsx | 2 +- src/renderer/src/pages/agents/AgentNavbar.tsx | 2 +- src/renderer/src/pages/agents/AgentPage.tsx | 4 +- .../components/AgentChatNavbar/index.tsx | 2 +- src/renderer/src/pages/home/Chat.tsx | 6 +- src/renderer/src/pages/home/HomePage.tsx | 4 +- .../src/pages/home/Inputbar/Inputbar.tsx | 4 +- .../home/Inputbar/tools/clearTopicTool.tsx | 2 +- .../tools/components/NewContextButton.tsx | 4 +- .../home/Inputbar/tools/createSessionTool.tsx | 2 +- .../home/Inputbar/tools/newTopicTool.tsx | 2 +- .../src/pages/home/Messages/Messages.tsx | 4 +- src/renderer/src/pages/home/Navbar.tsx | 2 +- .../home/components/ChatNavBar/index.tsx | 2 +- .../src/pages/knowledge/KnowledgePage.tsx | 2 +- .../shortcuts/shortcut-system-refactor.md | 84 +++++++++---------- .../data-classify/data/classification.json | 36 ++++---- 28 files changed, 200 insertions(+), 200 deletions(-) diff --git a/docs/references/data/preference-schema-guide.md b/docs/references/data/preference-schema-guide.md index ef87e71ac8e..c67f23adbf9 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.general.show_main_window': { key: string[], enabled: boolean, ... } +'shortcut.general.show_main_window': { key: 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/__tests__/shortcutUtils.test.ts b/packages/shared/__tests__/shortcutUtils.test.ts index 8ea8c68eca3..67873a36373 100644 --- a/packages/shared/__tests__/shortcutUtils.test.ts +++ b/packages/shared/__tests__/shortcutUtils.test.ts @@ -11,10 +11,10 @@ import { } from '../shortcuts/utils' const makeDefinition = (overrides: Partial = {}): ShortcutDefinition => ({ - key: 'shortcut.app.chat.clear', + key: 'shortcut.chat.clear', defaultBinding: ['CommandOrControl', 'L'], scope: 'renderer', - category: 'app.chat', + category: 'chat', labelKey: 'clear_topic', ...overrides }) diff --git a/packages/shared/data/bootConfig/bootConfigSchemas.ts b/packages/shared/data/bootConfig/bootConfigSchemas.ts index b1b7289abc6..54cee1eb5be 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-10T09:14:14.762Z + * Generated at: 2026-04-10T10:40:56.185Z * * 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 a39ab47d10d..849c7bed4fe 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-10T09:14:14.755Z + * Generated at: 2026-04-10T10:40:56.179Z * * This file is automatically generated from classification.json * To update this file, modify classification.json and run: @@ -425,45 +425,45 @@ export interface PreferenceSchemas { // dexieSettings/settings/translate:target:language 'feature.translate.page.target_language': PreferenceTypes.TranslateLanguageCode // redux/shortcuts/shortcuts.clear_topic - 'shortcut.app.chat.clear': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.clear': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.copy_last_message - 'shortcut.app.chat.copy_last_message': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.copy_last_message': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.edit_last_user_message - 'shortcut.app.chat.edit_last_user_message': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.edit_last_user_message': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.search_message_in_chat - 'shortcut.app.chat.search_message': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.search_message': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.select_model - 'shortcut.app.chat.select_model': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.select_model': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.toggle_new_context - 'shortcut.app.chat.toggle_new_context': PreferenceTypes.PreferenceShortcutType + 'shortcut.chat.toggle_new_context': 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.general.exit_fullscreen': PreferenceTypes.PreferenceShortcutType + 'shortcut.general.exit_fullscreen': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.search_message - 'shortcut.app.general.search': PreferenceTypes.PreferenceShortcutType + 'shortcut.general.search': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.show_app - 'shortcut.app.general.show_main_window': PreferenceTypes.PreferenceShortcutType + 'shortcut.general.show_main_window': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.mini_window - 'shortcut.app.general.show_mini_window': PreferenceTypes.PreferenceShortcutType + 'shortcut.general.show_mini_window': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.show_settings - 'shortcut.app.general.show_settings': PreferenceTypes.PreferenceShortcutType + 'shortcut.general.show_settings': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.toggle_show_assistants - 'shortcut.app.general.toggle_sidebar': PreferenceTypes.PreferenceShortcutType + 'shortcut.general.toggle_sidebar': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_in - 'shortcut.app.general.zoom_in': PreferenceTypes.PreferenceShortcutType + 'shortcut.general.zoom_in': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_out - 'shortcut.app.general.zoom_out': PreferenceTypes.PreferenceShortcutType + 'shortcut.general.zoom_out': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.zoom_reset - 'shortcut.app.general.zoom_reset': PreferenceTypes.PreferenceShortcutType + 'shortcut.general.zoom_reset': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.new_topic - 'shortcut.app.topic.new': PreferenceTypes.PreferenceShortcutType + 'shortcut.topic.new': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.rename_topic - 'shortcut.app.topic.rename': PreferenceTypes.PreferenceShortcutType + 'shortcut.topic.rename': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.toggle_show_topics - 'shortcut.app.topic.toggle_show_topics': 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 + 'shortcut.topic.toggle_show_topics': PreferenceTypes.PreferenceShortcutType // redux/settings/enableTopicNaming 'topic.naming.enabled': boolean // redux/settings/topicNamingPrompt @@ -721,26 +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.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, - 'shortcut.app.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, - 'shortcut.app.chat.edit_last_user_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'E'] }, - 'shortcut.app.chat.search_message': { enabled: true, key: ['CommandOrControl', 'F'] }, - 'shortcut.app.chat.select_model': { enabled: true, key: ['CommandOrControl', 'Shift', 'M'] }, - 'shortcut.app.chat.toggle_new_context': { enabled: true, key: ['CommandOrControl', 'K'] }, - 'shortcut.app.general.exit_fullscreen': { enabled: true, key: ['Escape'] }, - 'shortcut.app.general.search': { enabled: true, key: ['CommandOrControl', 'Shift', 'F'] }, - 'shortcut.app.general.show_main_window': { enabled: true, key: [] }, - 'shortcut.app.general.show_mini_window': { enabled: false, key: ['CommandOrControl', 'E'] }, - 'shortcut.app.general.show_settings': { enabled: true, key: ['CommandOrControl', ','] }, - 'shortcut.app.general.toggle_sidebar': { enabled: true, key: ['CommandOrControl', '['] }, - 'shortcut.app.general.zoom_in': { enabled: true, key: ['CommandOrControl', '='] }, - 'shortcut.app.general.zoom_out': { enabled: true, key: ['CommandOrControl', '-'] }, - 'shortcut.app.general.zoom_reset': { enabled: true, key: ['CommandOrControl', '0'] }, - 'shortcut.app.topic.new': { enabled: true, key: ['CommandOrControl', 'N'] }, - 'shortcut.app.topic.rename': { enabled: true, key: ['CommandOrControl', 'T'] }, - 'shortcut.app.topic.toggle_show_topics': { enabled: true, key: ['CommandOrControl', ']'] }, + 'shortcut.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, + 'shortcut.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, + 'shortcut.chat.edit_last_user_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'E'] }, + 'shortcut.chat.search_message': { enabled: true, key: ['CommandOrControl', 'F'] }, + 'shortcut.chat.select_model': { enabled: true, key: ['CommandOrControl', 'Shift', 'M'] }, + 'shortcut.chat.toggle_new_context': { enabled: true, key: ['CommandOrControl', 'K'] }, 'shortcut.feature.selection.get_text': { enabled: false, key: [] }, 'shortcut.feature.selection.toggle_enabled': { enabled: false, key: [] }, + 'shortcut.general.exit_fullscreen': { enabled: true, key: ['Escape'] }, + 'shortcut.general.search': { enabled: true, key: ['CommandOrControl', 'Shift', 'F'] }, + 'shortcut.general.show_main_window': { enabled: true, key: [] }, + 'shortcut.general.show_mini_window': { enabled: false, key: ['CommandOrControl', 'E'] }, + 'shortcut.general.show_settings': { enabled: true, key: ['CommandOrControl', ','] }, + 'shortcut.general.toggle_sidebar': { enabled: true, key: ['CommandOrControl', '['] }, + 'shortcut.general.zoom_in': { enabled: true, key: ['CommandOrControl', '='] }, + 'shortcut.general.zoom_out': { enabled: true, key: ['CommandOrControl', '-'] }, + 'shortcut.general.zoom_reset': { enabled: true, key: ['CommandOrControl', '0'] }, + 'shortcut.topic.new': { enabled: true, key: ['CommandOrControl', 'N'] }, + 'shortcut.topic.rename': { enabled: true, key: ['CommandOrControl', 'T'] }, + 'shortcut.topic.toggle_show_topics': { enabled: true, key: ['CommandOrControl', ']'] }, 'topic.naming.enabled': true, 'topic.naming_prompt': '', 'topic.position': 'left', diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index fcfa395a51c..824f0295df0 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -3,148 +3,148 @@ import type { ShortcutDefinition } from './types' export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 应用级快捷键 ==================== { - key: 'shortcut.app.general.show_main_window', + key: 'shortcut.general.show_main_window', defaultBinding: [], scope: 'main', - category: 'app.general', + category: 'general', labelKey: 'show_app', system: true, global: true }, { - key: 'shortcut.app.general.show_mini_window', + key: 'shortcut.general.show_mini_window', defaultBinding: ['CommandOrControl', 'E'], scope: 'main', - category: 'app.general', + category: 'general', labelKey: 'mini_window', system: true, global: true, enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled') }, { - key: 'shortcut.app.general.show_settings', + key: 'shortcut.general.show_settings', defaultBinding: ['CommandOrControl', ','], scope: 'both', - category: 'app.general', + category: 'general', labelKey: 'show_settings', editable: false, system: true }, { - key: 'shortcut.app.general.toggle_sidebar', + key: 'shortcut.general.toggle_sidebar', defaultBinding: ['CommandOrControl', '['], scope: 'renderer', - category: 'app.general', + category: 'general', labelKey: 'toggle_sidebar' }, { - key: 'shortcut.app.general.exit_fullscreen', + key: 'shortcut.general.exit_fullscreen', defaultBinding: ['Escape'], scope: 'renderer', - category: 'app.general', + category: 'general', labelKey: 'exit_fullscreen', editable: false, system: true }, { - key: 'shortcut.app.general.zoom_in', + key: 'shortcut.general.zoom_in', defaultBinding: ['CommandOrControl', '='], scope: 'main', - category: 'app.general', + category: 'general', labelKey: 'zoom_in', editable: false, system: true, variants: [['CommandOrControl', 'numadd']] }, { - key: 'shortcut.app.general.zoom_out', + key: 'shortcut.general.zoom_out', defaultBinding: ['CommandOrControl', '-'], scope: 'main', - category: 'app.general', + category: 'general', labelKey: 'zoom_out', editable: false, system: true, variants: [['CommandOrControl', 'numsub']] }, { - key: 'shortcut.app.general.zoom_reset', + key: 'shortcut.general.zoom_reset', defaultBinding: ['CommandOrControl', '0'], scope: 'main', - category: 'app.general', + category: 'general', labelKey: 'zoom_reset', editable: false, system: true }, { - key: 'shortcut.app.general.search', + key: 'shortcut.general.search', defaultBinding: ['CommandOrControl', 'Shift', 'F'], scope: 'renderer', - category: 'app.general', + category: 'general', labelKey: 'search_message' }, // ==================== 聊天相关快捷键 ==================== { - key: 'shortcut.app.chat.clear', + key: 'shortcut.chat.clear', defaultBinding: ['CommandOrControl', 'L'], scope: 'renderer', - category: 'app.chat', + category: 'chat', labelKey: 'clear_topic' }, { - key: 'shortcut.app.chat.search_message', + key: 'shortcut.chat.search_message', defaultBinding: ['CommandOrControl', 'F'], scope: 'renderer', - category: 'app.chat', + category: 'chat', labelKey: 'search_message_in_chat' }, { - key: 'shortcut.app.chat.toggle_new_context', + key: 'shortcut.chat.toggle_new_context', defaultBinding: ['CommandOrControl', 'K'], scope: 'renderer', - category: 'app.chat', + category: 'chat', labelKey: 'toggle_new_context' }, { - key: 'shortcut.app.chat.copy_last_message', + key: 'shortcut.chat.copy_last_message', defaultBinding: ['CommandOrControl', 'Shift', 'C'], scope: 'renderer', - category: 'app.chat', + category: 'chat', labelKey: 'copy_last_message' }, { - key: 'shortcut.app.chat.edit_last_user_message', + key: 'shortcut.chat.edit_last_user_message', defaultBinding: ['CommandOrControl', 'Shift', 'E'], scope: 'renderer', - category: 'app.chat', + category: 'chat', labelKey: 'edit_last_user_message' }, { - key: 'shortcut.app.chat.select_model', + key: 'shortcut.chat.select_model', defaultBinding: ['CommandOrControl', 'Shift', 'M'], scope: 'renderer', - category: 'app.chat', + category: 'chat', labelKey: 'select_model' }, // ==================== 话题管理快捷键 ==================== { - key: 'shortcut.app.topic.new', + key: 'shortcut.topic.new', defaultBinding: ['CommandOrControl', 'N'], scope: 'renderer', - category: 'app.topic', + category: 'topic', labelKey: 'new_topic' }, { - key: 'shortcut.app.topic.rename', + key: 'shortcut.topic.rename', defaultBinding: ['CommandOrControl', 'T'], scope: 'renderer', - category: 'app.topic', + category: 'topic', labelKey: 'rename_topic' }, { - key: 'shortcut.app.topic.toggle_show_topics', + key: 'shortcut.topic.toggle_show_topics', defaultBinding: ['CommandOrControl', ']'], scope: 'renderer', - category: 'app.topic', + category: 'topic', labelKey: 'toggle_show_topics' }, // ==================== 划词助手快捷键 ==================== diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index 29931b23f1b..3a09b6dfe8c 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -3,11 +3,11 @@ import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data export type ShortcutScope = 'main' | 'renderer' | 'both' /** Built-in shortcut categories for UI grouping. */ -export type BuiltinShortcutCategory = 'app.general' | 'app.chat' | 'app.topic' | 'feature.selection' +export type BuiltinShortcutCategory = 'general' | 'chat' | 'topic' | 'feature.selection' /** * Dot-separated namespace for UI grouping in the settings page. - * Built-in: `app.general`, `app.chat`, `app.topic`, `feature.selection`. + * Built-in: `general`, `chat`, `topic`, `feature.selection`. * Plugins: `plugin.{pluginId}` (e.g. `plugin.translator`). */ export type ShortcutCategory = BuiltinShortcutCategory | `plugin.${string}` @@ -25,13 +25,13 @@ export type ShortcutEnabledPredicate = (getPreference: GetPreferenceFn) => boole /** Static metadata for a single shortcut — the single source of truth for the shortcut system. */ export interface ShortcutDefinition { - /** Preference key in `shortcut.app.{category}.{name}` format for built-in shortcuts. Plugins use `shortcut.plugin.{pluginId}.{name}`. */ + /** Preference key in `shortcut.{category}.{name}` format for built-in shortcuts. Plugins use `shortcut.plugin.{pluginId}.{name}`. */ key: ShortcutPreferenceKey /** Default key binding in Electron accelerator format (e.g. `['CommandOrControl', 'L']`). Empty array means no default binding. */ defaultBinding: string[] /** Where the shortcut is registered: `main` (globalShortcut), `renderer` (react-hotkeys-hook), or `both`. */ scope: ShortcutScope - /** Dot-separated category for UI grouping (e.g. `app.general`, `app.chat`, `app.topic`, `plugin.translator`). */ + /** 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 diff --git a/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts b/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts index 916b4312487..78b6052e338 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-10T09:14:14.763Z + * Generated at: 2026-04-10T10:40:56.186Z * * 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/PreferencesMappings.ts b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts index e8a8cf0783e..3bd3826797c 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-10T09:14:14.763Z + * Generated at: 2026-04-10T10:40:56.186Z * * This file contains pure mapping relationships without default values. * Default values are managed in packages/shared/data/preferences.ts @@ -750,27 +750,27 @@ export const REDUX_STORE_MAPPINGS = { shortcuts: [ { originalKey: 'shortcuts.zoom_in', - targetKey: 'shortcut.app.general.zoom_in' + targetKey: 'shortcut.general.zoom_in' }, { originalKey: 'shortcuts.zoom_out', - targetKey: 'shortcut.app.general.zoom_out' + targetKey: 'shortcut.general.zoom_out' }, { originalKey: 'shortcuts.zoom_reset', - targetKey: 'shortcut.app.general.zoom_reset' + targetKey: 'shortcut.general.zoom_reset' }, { originalKey: 'shortcuts.show_settings', - targetKey: 'shortcut.app.general.show_settings' + targetKey: 'shortcut.general.show_settings' }, { originalKey: 'shortcuts.show_app', - targetKey: 'shortcut.app.general.show_main_window' + targetKey: 'shortcut.general.show_main_window' }, { originalKey: 'shortcuts.mini_window', - targetKey: 'shortcut.app.general.show_mini_window' + targetKey: 'shortcut.general.show_mini_window' }, { originalKey: 'shortcuts.selection_assistant_toggle', @@ -782,51 +782,51 @@ export const REDUX_STORE_MAPPINGS = { }, { originalKey: 'shortcuts.new_topic', - targetKey: 'shortcut.app.topic.new' + targetKey: 'shortcut.topic.new' }, { originalKey: 'shortcuts.rename_topic', - targetKey: 'shortcut.app.topic.rename' + targetKey: 'shortcut.topic.rename' }, { originalKey: 'shortcuts.toggle_show_topics', - targetKey: 'shortcut.app.topic.toggle_show_topics' + targetKey: 'shortcut.topic.toggle_show_topics' }, { originalKey: 'shortcuts.toggle_show_assistants', - targetKey: 'shortcut.app.general.toggle_sidebar' + targetKey: 'shortcut.general.toggle_sidebar' }, { originalKey: 'shortcuts.copy_last_message', - targetKey: 'shortcut.app.chat.copy_last_message' + targetKey: 'shortcut.chat.copy_last_message' }, { originalKey: 'shortcuts.edit_last_user_message', - targetKey: 'shortcut.app.chat.edit_last_user_message' + targetKey: 'shortcut.chat.edit_last_user_message' }, { originalKey: 'shortcuts.search_message_in_chat', - targetKey: 'shortcut.app.chat.search_message' + targetKey: 'shortcut.chat.search_message' }, { originalKey: 'shortcuts.search_message', - targetKey: 'shortcut.app.general.search' + targetKey: 'shortcut.general.search' }, { originalKey: 'shortcuts.clear_topic', - targetKey: 'shortcut.app.chat.clear' + targetKey: 'shortcut.chat.clear' }, { originalKey: 'shortcuts.toggle_new_context', - targetKey: 'shortcut.app.chat.toggle_new_context' + targetKey: 'shortcut.chat.toggle_new_context' }, { originalKey: 'shortcuts.select_model', - targetKey: 'shortcut.app.chat.select_model' + targetKey: 'shortcut.chat.select_model' }, { originalKey: 'shortcuts.exit_fullscreen', - targetKey: 'shortcut.app.general.exit_fullscreen' + targetKey: 'shortcut.general.exit_fullscreen' } ], translate: [ diff --git a/src/main/services/AppMenuService.ts b/src/main/services/AppMenuService.ts index 7c320aa3123..8b1feac4103 100644 --- a/src/main/services/AppMenuService.ts +++ b/src/main/services/AppMenuService.ts @@ -11,9 +11,9 @@ import type { MenuItemConstructorOptions } from 'electron' import { app, Menu, shell } from 'electron' const zoomShortcutKeys: ShortcutPreferenceKey[] = [ - 'shortcut.app.general.zoom_in', - 'shortcut.app.general.zoom_out', - 'shortcut.app.general.zoom_reset' + 'shortcut.general.zoom_in', + 'shortcut.general.zoom_out', + 'shortcut.general.zoom_reset' ] const isShortcutEnabled = (key: ShortcutPreferenceKey): boolean => { @@ -45,9 +45,9 @@ export class AppMenuService extends BaseService { const locale = locales[getAppLanguage()] const { appMenu } = locale.translation - const zoomInEnabled = isShortcutEnabled('shortcut.app.general.zoom_in') - const zoomOutEnabled = isShortcutEnabled('shortcut.app.general.zoom_out') - const zoomResetEnabled = isShortcutEnabled('shortcut.app.general.zoom_reset') + const zoomInEnabled = isShortcutEnabled('shortcut.general.zoom_in') + const zoomOutEnabled = isShortcutEnabled('shortcut.general.zoom_out') + const zoomResetEnabled = isShortcutEnabled('shortcut.general.zoom_reset') const template: MenuItemConstructorOptions[] = [ { diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 82417857f1a..e9cf0b7c10b 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -51,11 +51,11 @@ export class ShortcutService extends BaseService { } private registerBuiltInHandlers(): void { - this.handlers.set('shortcut.app.general.show_main_window', () => { + this.handlers.set('shortcut.general.show_main_window', () => { application.get('WindowService').toggleMainWindow() }) - this.handlers.set('shortcut.app.general.show_settings', () => { + this.handlers.set('shortcut.general.show_settings', () => { let targetWindow = application.get('WindowService').getMainWindow() if ( @@ -74,20 +74,20 @@ export class ShortcutService extends BaseService { targetWindow.webContents.send(IpcChannel.Windows_NavigateToSettings) }) - this.handlers.set('shortcut.app.general.show_mini_window', () => { + this.handlers.set('shortcut.general.show_mini_window', () => { if (!application.get('PreferenceService').get('feature.quick_assistant.enabled')) return application.get('WindowService').toggleMiniWindow() }) - this.handlers.set('shortcut.app.general.zoom_in', (window) => { + this.handlers.set('shortcut.general.zoom_in', (window) => { if (window) handleZoomFactor([window], 0.1) }) - this.handlers.set('shortcut.app.general.zoom_out', (window) => { + this.handlers.set('shortcut.general.zoom_out', (window) => { if (window) handleZoomFactor([window], -0.1) }) - this.handlers.set('shortcut.app.general.zoom_reset', (window) => { + this.handlers.set('shortcut.general.zoom_reset', (window) => { if (window) handleZoomFactor([window], 0, true) }) diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx index b6763502a69..69399a39d08 100644 --- a/src/renderer/src/components/TopView/index.tsx +++ b/src/renderer/src/components/TopView/index.tsx @@ -37,7 +37,7 @@ const TopViewContainer: React.FC = ({ children }) => { const [modal, modalContextHolder] = Modal.useModal() const [messageApi, messageContextHolder] = message.useMessage() - const [exitFullscreenPref] = usePreference('shortcut.app.general.exit_fullscreen') + const [exitFullscreenPref] = usePreference('shortcut.general.exit_fullscreen') const enableQuitFullScreen = exitFullscreenPref?.enabled !== false useAppInit() diff --git a/src/renderer/src/pages/agents/AgentChat.tsx b/src/renderer/src/pages/agents/AgentChat.tsx index 717ad518e6f..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( - 'app.topic.new', + 'topic.new', () => { void createDefaultSession() }, diff --git a/src/renderer/src/pages/agents/AgentNavbar.tsx b/src/renderer/src/pages/agents/AgentNavbar.tsx index d1e9d462b56..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('app.general.search', () => { + 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 9cde95220c2..a8cf2f5f9d0 100644 --- a/src/renderer/src/pages/agents/AgentPage.tsx +++ b/src/renderer/src/pages/agents/AgentPage.tsx @@ -33,7 +33,7 @@ const AgentPage = () => { const { t } = useTranslation() // TODO: Replace with sidebar toggle logic once the new sidebar UI is implemented - useShortcut('app.general.toggle_sidebar', () => { + useShortcut('general.toggle_sidebar', () => { if (topicPosition === 'left') { void toggleShowAssistants() return @@ -42,7 +42,7 @@ const AgentPage = () => { void EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS) }) - useShortcut('app.topic.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 6cc81734a55..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('app.general.search', () => { + 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 405b0c0bd72..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('app.chat.search_message', () => { + useShortcut('chat.search_message', () => { try { const selectedText = window.getSelection()?.toString().trim() contentSearchRef.current?.enable(selectedText) @@ -69,7 +69,7 @@ const Chat: FC = (props) => { } }) - useShortcut('app.topic.rename', async () => { + useShortcut('topic.rename', async () => { const topic = props.activeTopic if (!topic) return @@ -87,7 +87,7 @@ const Chat: FC = (props) => { } }) - useShortcut('app.chat.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 c0029d623f9..0a5a0805272 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -46,7 +46,7 @@ const HomePage: FC = () => { _activeAssistant = activeAssistant // TODO: Replace with sidebar toggle logic once the new sidebar UI is implemented - useShortcut('app.general.toggle_sidebar', () => { + useShortcut('general.toggle_sidebar', () => { if (topicPosition === 'right') { void toggleShowAssistants() return @@ -63,7 +63,7 @@ const HomePage: FC = () => { void EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS) }) - useShortcut('app.topic.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 aa122ce0e2c..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( - 'app.topic.new', + '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('app.chat.clear', 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 4adaf43a78d..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('app.chat.clear') + const clearTopicShortcut = useShortcutDisplay('chat.clear') return ( = ({ onNewContext }) => { - const newContextShortcut = useShortcutDisplay('app.chat.toggle_new_context') + const newContextShortcut = useShortcutDisplay('chat.toggle_new_context') const { t } = useTranslation() - useShortcut('app.chat.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 835892945a2..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('app.topic.new') + 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 f425fc4a578..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('app.topic.new') + const newTopicShortcut = useShortcutDisplay('topic.new') return ( = ({ assistant, topic, setActiveTopic, o ) }, [displayMessages.length, hasMore, isLoadingMore, messages, setTimeoutTimer]) - useShortcut('app.chat.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('app.chat.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 9ee84489d50..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('app.general.search', () => { + 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 161aefbc28d..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('app.general.search', () => { + 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 49b7d73ed75..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('app.general.search', () => { + useShortcut('general.search', () => { if (selectedBase) { void KnowledgeSearchPopup.show({ base: selectedBase }).then() } diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md index e3d6ea62ed6..c1e7ff39da2 100644 --- a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -110,7 +110,7 @@ v1 快捷键系统存在以下架构缺陷: ```typescript { - key: 'shortcut.app.general.show_mini_window', // Preference key + key: 'shortcut.general.show_mini_window', // Preference key defaultBinding: ['CommandOrControl', 'E'], // Electron accelerator 格式 scope: 'main', // main | renderer | both category: 'feature.selection', // 点分命名空间 UI 分组:app.general、app.chat、plugin.xxx 等 @@ -130,7 +130,7 @@ v1 快捷键系统存在以下架构缺陷: | `key` | Preference key,内置快捷键用 `shortcut.app.{category}.{name}` 格式,插件用 `shortcut.plugin.{pluginId}.{name}` | | `defaultBinding` | Electron accelerator 格式的默认绑定,空数组表示无默认绑定 | | `scope` | 决定快捷键注册在哪个进程:`main`(globalShortcut)、`renderer`(react-hotkeys-hook)、`both`(两者都注册) | -| `category` | 点分命名空间 UI 分组(如 `app.general`、`app.chat`、`app.topic`、`plugin.translator`),类型为 `string` 以支持插件扩展 | +| `category` | 点分命名空间 UI 分组(如 `general`、`chat`、`topic`、`plugin.translator`),类型为 `string` 以支持插件扩展 | | `labelKey` | i18n label key,由 `getShortcutLabel()` 消费 | | `editable` | 设为 `false` 表示用户不可修改绑定(如 Escape 退出全屏),默认 `true` | | `system` | 系统级标记,`true` 时不可删除绑定 | @@ -161,8 +161,8 @@ interface ResolvedShortcut { ```typescript // 两种写法等价,均有类型补全 -useShortcut('app.chat.clear', callback) -useShortcut('shortcut.app.chat.clear', callback) +useShortcut('chat.clear', callback) +useShortcut('shortcut.chat.clear', callback) ``` #### `utils.ts` — 纯函数工具集 @@ -204,8 +204,8 @@ type PreferenceShortcutType = { `preferenceSchemas.ts` 中为每个快捷键声明默认值: ```typescript -'shortcut.app.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, -'shortcut.app.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, +'shortcut.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, +'shortcut.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, ``` ### 3. 主进程服务层 (`ShortcutService`) @@ -227,7 +227,7 @@ export class ShortcutService extends BaseService { ... } private handlers = new Map() // 注册示例 -this.handlers.set('shortcut.app.general.zoom_in', (window) => { +this.handlers.set('shortcut.general.zoom_in', (window) => { if (window) handleZoomFactor([window], 0.1) }) ``` @@ -278,8 +278,8 @@ for (const definition of relevantDefinitions) { ```typescript // 调用侧简洁用法 -useShortcut('app.chat.clear', () => clearChat()) -useShortcut('app.topic.new', () => createTopic(), { enableOnFormTags: false }) +useShortcut('chat.clear', () => clearChat()) +useShortcut('topic.new', () => createTopic(), { enableOnFormTags: false }) ``` **Options:** @@ -334,22 +334,22 @@ preferenceSchemas.ts 中声明 key ↓ 代码生成 PreferenceKeyType(所有偏好 key 的联合类型) ↓ Extract<..., `shortcut.${string}`> -ShortcutPreferenceKey(如 'shortcut.app.chat.clear') +ShortcutPreferenceKey(如 'shortcut.chat.clear') ↓ Template literal infer -ShortcutKey(如 'app.chat.clear') +ShortcutKey(如 'chat.clear') ``` ### 调用侧类型安全 ```typescript -// ✅ 编译通过 — 'app.chat.clear' 是合法的 ShortcutKey -useShortcut('app.chat.clear', callback) +// ✅ 编译通过 — 'chat.clear' 是合法的 ShortcutKey +useShortcut('chat.clear', callback) // ✅ 编译通过 — 完整 key 也被接受 -useShortcut('shortcut.app.chat.clear', callback) +useShortcut('shortcut.chat.clear', callback) -// ❌ 编译报错 — 'app.chat.invalid' 不在 ShortcutKey 联合类型中 -useShortcut('app.chat.invalid', callback) +// ❌ 编译报错 — 'chat.invalid' 不在 ShortcutKey 联合类型中 +useShortcut('chat.invalid', callback) ``` --- @@ -378,7 +378,7 @@ convertKeyToAccelerator() + isValidShortcut() + isDuplicateShortcut() ↓ 通过校验 updatePreference({ key: newKeys }) ↓ useMultiplePreferences.setValues() -preferenceService.set('shortcut.app.chat.clear', { key: [...], enabled: true }) +preferenceService.set('shortcut.chat.clear', { key: [...], enabled: true }) ├── SQLite 持久化 ├── IPC 广播 → 所有渲染窗口自动更新 └── subscribeChange 回调 → ShortcutService.reregisterShortcuts() @@ -401,7 +401,7 @@ callback(event) // 如 clearChat() ``` 用户按下 Cmd+E(窗口失焦状态) ↓ Electron globalShortcut -handlers.get('shortcut.app.general.show_mini_window') +handlers.get('shortcut.general.show_mini_window') ↓ toggleMiniWindow() ``` @@ -414,34 +414,34 @@ toggleMiniWindow() | Preference Key | 默认绑定 | 作用域 | 备注 | |---|---|---|---| -| `shortcut.app.general.show_main_window` | *(无)* | main | 失焦持久,系统级 | -| `shortcut.app.general.show_mini_window` | `Cmd/Ctrl+E` | main | 关联 quick_assistant 开关 | -| `shortcut.app.general.show_settings` | `Cmd/Ctrl+,` | both | 不可编辑 | -| `shortcut.app.general.toggle_sidebar` | `Cmd/Ctrl+[` | renderer | | -| `shortcut.app.general.exit_fullscreen` | `Escape` | renderer | 不可编辑,系统级 | -| `shortcut.app.general.zoom_in` | `Cmd/Ctrl+=` | main | 含小键盘变体 | -| `shortcut.app.general.zoom_out` | `Cmd/Ctrl+-` | main | 含小键盘变体 | -| `shortcut.app.general.zoom_reset` | `Cmd/Ctrl+0` | main | | -| `shortcut.app.general.search` | `Cmd/Ctrl+Shift+F` | renderer | | +| `shortcut.general.show_main_window` | *(无)* | main | 失焦持久,系统级 | +| `shortcut.general.show_mini_window` | `Cmd/Ctrl+E` | main | 关联 quick_assistant 开关 | +| `shortcut.general.show_settings` | `Cmd/Ctrl+,` | both | 不可编辑 | +| `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`) | Preference Key | 默认绑定 | 默认启用 | 备注 | |---|---|---|---| -| `shortcut.app.chat.clear` | `Cmd/Ctrl+L` | 是 | | -| `shortcut.app.chat.search_message` | `Cmd/Ctrl+F` | 是 | | -| `shortcut.app.chat.toggle_new_context` | `Cmd/Ctrl+K` | 是 | | -| `shortcut.app.chat.copy_last_message` | `Cmd/Ctrl+Shift+C` | 否 | | -| `shortcut.app.chat.edit_last_user_message` | `Cmd/Ctrl+Shift+E` | 否 | | -| `shortcut.app.chat.select_model` | `Cmd/Ctrl+Shift+M` | 是 | | +| `shortcut.chat.clear` | `Cmd/Ctrl+L` | 是 | | +| `shortcut.chat.search_message` | `Cmd/Ctrl+F` | 是 | | +| `shortcut.chat.toggle_new_context` | `Cmd/Ctrl+K` | 是 | | +| `shortcut.chat.copy_last_message` | `Cmd/Ctrl+Shift+C` | 否 | | +| `shortcut.chat.edit_last_user_message` | `Cmd/Ctrl+Shift+E` | 否 | | +| `shortcut.chat.select_model` | `Cmd/Ctrl+Shift+M` | 是 | | ### 话题 (`topic`) | Preference Key | 默认绑定 | |---|---| -| `shortcut.app.topic.new` | `Cmd/Ctrl+N` | -| `shortcut.app.topic.rename` | `Cmd/Ctrl+T` | -| `shortcut.app.topic.toggle_show_topics` | `Cmd/Ctrl+]` | +| `shortcut.topic.new` | `Cmd/Ctrl+N` | +| `shortcut.topic.rename` | `Cmd/Ctrl+T` | +| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl+]` | ### 划词助手 (`selection`) @@ -460,7 +460,7 @@ toggleMiniWindow() ```typescript // packages/shared/data/preference/preferenceSchemas.ts -'shortcut.app.chat.regenerate': { enabled: true, key: ['CommandOrControl', 'Shift', 'R'] }, +'shortcut.chat.regenerate': { enabled: true, key: ['CommandOrControl', 'Shift', 'R'] }, ``` > 注意:类型声明区也需要添加对应的类型声明行。 @@ -470,10 +470,10 @@ toggleMiniWindow() ```typescript // packages/shared/shortcuts/definitions.ts { - key: 'shortcut.app.chat.regenerate', + key: 'shortcut.chat.regenerate', defaultBinding: ['CommandOrControl', 'Shift', 'R'], scope: 'renderer', - category: 'app.chat' + category: 'chat' } ``` @@ -481,10 +481,10 @@ toggleMiniWindow() ```typescript // 渲染进程 -useShortcut('app.chat.regenerate', () => regenerateLastMessage()) +useShortcut('chat.regenerate', () => regenerateLastMessage()) // 或主进程(在 ShortcutService.registerBuiltInHandlers 中) -this.handlers.set('shortcut.app.chat.regenerate', () => { ... }) +this.handlers.set('shortcut.chat.regenerate', () => { ... }) ``` ### 条件启用 @@ -493,7 +493,7 @@ this.handlers.set('shortcut.app.chat.regenerate', () => { ... }) ```typescript { - key: 'shortcut.app.general.show_mini_window', + key: 'shortcut.general.show_mini_window', enabledWhen: (get) => !!get('feature.quick_assistant.enabled'), // 当 quick_assistant 关闭时,此快捷键不会被注册 } diff --git a/v2-refactor-temp/tools/data-classify/data/classification.json b/v2-refactor-temp/tools/data-classify/data/classification.json index 4c6b9d52b40..7811eaf4bdf 100644 --- a/v2-refactor-temp/tools/data-classify/data/classification.json +++ b/v2-refactor-temp/tools/data-classify/data/classification.json @@ -2484,7 +2484,7 @@ }, "originalKey": "zoom_in", "status": "classified", - "targetKey": "shortcut.app.general.zoom_in", + "targetKey": "shortcut.general.zoom_in", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2498,7 +2498,7 @@ }, "originalKey": "zoom_out", "status": "classified", - "targetKey": "shortcut.app.general.zoom_out", + "targetKey": "shortcut.general.zoom_out", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2512,7 +2512,7 @@ }, "originalKey": "zoom_reset", "status": "classified", - "targetKey": "shortcut.app.general.zoom_reset", + "targetKey": "shortcut.general.zoom_reset", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2526,7 +2526,7 @@ }, "originalKey": "show_settings", "status": "classified", - "targetKey": "shortcut.app.general.show_settings", + "targetKey": "shortcut.general.show_settings", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2537,7 +2537,7 @@ }, "originalKey": "show_app", "status": "classified", - "targetKey": "shortcut.app.general.show_main_window", + "targetKey": "shortcut.general.show_main_window", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2551,7 +2551,7 @@ }, "originalKey": "mini_window", "status": "classified", - "targetKey": "shortcut.app.general.show_mini_window", + "targetKey": "shortcut.general.show_mini_window", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2587,7 +2587,7 @@ }, "originalKey": "new_topic", "status": "classified", - "targetKey": "shortcut.app.topic.new", + "targetKey": "shortcut.topic.new", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2601,7 +2601,7 @@ }, "originalKey": "rename_topic", "status": "classified", - "targetKey": "shortcut.app.topic.rename", + "targetKey": "shortcut.topic.rename", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2615,7 +2615,7 @@ }, "originalKey": "toggle_show_topics", "status": "classified", - "targetKey": "shortcut.app.topic.toggle_show_topics", + "targetKey": "shortcut.topic.toggle_show_topics", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2629,7 +2629,7 @@ }, "originalKey": "toggle_show_assistants", "status": "classified", - "targetKey": "shortcut.app.general.toggle_sidebar", + "targetKey": "shortcut.general.toggle_sidebar", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2644,7 +2644,7 @@ }, "originalKey": "copy_last_message", "status": "classified", - "targetKey": "shortcut.app.chat.copy_last_message", + "targetKey": "shortcut.chat.copy_last_message", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2659,7 +2659,7 @@ }, "originalKey": "edit_last_user_message", "status": "classified", - "targetKey": "shortcut.app.chat.edit_last_user_message", + "targetKey": "shortcut.chat.edit_last_user_message", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2673,7 +2673,7 @@ }, "originalKey": "search_message_in_chat", "status": "classified", - "targetKey": "shortcut.app.chat.search_message", + "targetKey": "shortcut.chat.search_message", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2688,7 +2688,7 @@ }, "originalKey": "search_message", "status": "classified", - "targetKey": "shortcut.app.general.search", + "targetKey": "shortcut.general.search", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2702,7 +2702,7 @@ }, "originalKey": "clear_topic", "status": "classified", - "targetKey": "shortcut.app.chat.clear", + "targetKey": "shortcut.chat.clear", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2716,7 +2716,7 @@ }, "originalKey": "toggle_new_context", "status": "classified", - "targetKey": "shortcut.app.chat.toggle_new_context", + "targetKey": "shortcut.chat.toggle_new_context", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2731,7 +2731,7 @@ }, "originalKey": "select_model", "status": "classified", - "targetKey": "shortcut.app.chat.select_model", + "targetKey": "shortcut.chat.select_model", "type": "PreferenceTypes.PreferenceShortcutType" }, { @@ -2744,7 +2744,7 @@ }, "originalKey": "exit_fullscreen", "status": "classified", - "targetKey": "shortcut.app.general.exit_fullscreen", + "targetKey": "shortcut.general.exit_fullscreen", "type": "PreferenceTypes.PreferenceShortcutType" } ] From dff76cfb57efe9916bbc79a65ef8132dec6784e5 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 10 Apr 2026 19:43:38 +0800 Subject: [PATCH 16/37] Refactor shortcut system: update key references to binding, enhance error handling, and improve localization - Updated shortcut preference keys from 'key' to 'binding' across various files for consistency. - Enhanced error handling in ShortcutService and ShortcutSettings to log failures and provide user feedback. - Improved test coverage for ComplexPreferenceMappings to reflect new shortcut structure. - Added new localization strings for shortcut reset failures and save failures in multiple languages. - Updated documentation to reflect changes in shortcut system architecture and usage. --- .../data/preference-schema-guide.md | 2 +- .../shared/__tests__/shortcutUtils.test.ts | 47 +++++-- .../data/bootConfig/bootConfigSchemas.ts | 2 +- .../data/preference/preferenceSchemas.ts | 42 +++---- .../shared/data/preference/preferenceTypes.ts | 2 +- packages/shared/shortcuts/definitions.ts | 24 ++-- packages/shared/shortcuts/types.ts | 12 +- packages/shared/shortcuts/utils.ts | 10 +- .../v2/migrators/PreferencesMigrator.ts | 8 +- .../migrators/mappings/BootConfigMappings.ts | 2 +- .../mappings/ComplexPreferenceMappings.ts | 12 ++ .../migrators/mappings/PreferencesMappings.ts | 2 +- .../v2/migrators/mappings/ShortcutMappings.ts | 92 ++++++++++++++ .../ComplexPreferenceMappings.test.ts | 5 +- src/main/services/ShortcutService.ts | 33 ++--- src/renderer/src/hooks/useShortcuts.ts | 12 +- src/renderer/src/i18n/locales/en-us.json | 2 + src/renderer/src/i18n/locales/zh-cn.json | 2 + src/renderer/src/i18n/locales/zh-tw.json | 2 + src/renderer/src/i18n/translate/de-de.json | 4 +- src/renderer/src/i18n/translate/el-gr.json | 4 +- src/renderer/src/i18n/translate/es-es.json | 4 +- src/renderer/src/i18n/translate/fr-fr.json | 4 +- src/renderer/src/i18n/translate/ja-jp.json | 4 +- src/renderer/src/i18n/translate/pt-pt.json | 4 +- src/renderer/src/i18n/translate/ro-ro.json | 4 +- src/renderer/src/i18n/translate/ru-ru.json | 4 +- .../src/pages/settings/ShortcutSettings.tsx | 33 +++-- .../shortcuts/shortcut-system-refactor.md | 95 +++++++------- .../data-classify/data/classification.json | 116 +++++++++--------- 30 files changed, 377 insertions(+), 212 deletions(-) create mode 100644 src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts diff --git a/docs/references/data/preference-schema-guide.md b/docs/references/data/preference-schema-guide.md index c67f23adbf9..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.general.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/__tests__/shortcutUtils.test.ts b/packages/shared/__tests__/shortcutUtils.test.ts index 67873a36373..2e364d3518e 100644 --- a/packages/shared/__tests__/shortcutUtils.test.ts +++ b/packages/shared/__tests__/shortcutUtils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' +import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '../shortcuts/definitions' import type { ShortcutDefinition } from '../shortcuts/types' import { convertAcceleratorToHotkey, @@ -108,18 +109,12 @@ describe('getDefaultShortcut', () => { expect(result.binding).toEqual(['CommandOrControl', 'L']) expect(result.enabled).toBe(true) expect(result.editable).toBe(true) - expect(result.system).toBe(false) }) it('respects editable: false', () => { const def = makeDefinition({ editable: false }) expect(getDefaultShortcut(def).editable).toBe(false) }) - - it('respects system: true', () => { - const def = makeDefinition({ system: true }) - expect(getDefaultShortcut(def).system).toBe(true) - }) }) describe('resolveShortcutPreference', () => { @@ -138,20 +133,20 @@ describe('resolveShortcutPreference', () => { expect(result.binding).toEqual(['CommandOrControl', 'L']) }) - it('uses custom key when provided', () => { + it('uses custom binding when provided', () => { const def = makeDefinition() const result = resolveShortcutPreference(def, { - key: ['Alt', 'L'], + binding: ['Alt', 'L'], enabled: true }) expect(result.binding).toEqual(['Alt', 'L']) }) - it('returns empty binding when key is explicitly cleared (empty array)', () => { + it('returns empty binding when binding is explicitly cleared (empty array)', () => { const def = makeDefinition() const result = resolveShortcutPreference(def, { - key: [], + binding: [], enabled: true }) @@ -161,10 +156,40 @@ describe('resolveShortcutPreference', () => { it('respects enabled: false from preference', () => { const def = makeDefinition() const result = resolveShortcutPreference(def, { - key: ['CommandOrControl', 'L'], + binding: ['CommandOrControl', 'L'], enabled: false }) expect(result.enabled).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('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/data/bootConfig/bootConfigSchemas.ts b/packages/shared/data/bootConfig/bootConfigSchemas.ts index 54cee1eb5be..e57eaec366c 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-10T10:40:56.185Z + * Generated at: 2026-04-10T11:18:14.911Z * * 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 849c7bed4fe..932a91e2013 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-10T10:40:56.179Z + * Generated at: 2026-04-10T11:18:14.905Z * * This file is automatically generated from classification.json * To update this file, modify classification.json and run: @@ -721,26 +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.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, - 'shortcut.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, - 'shortcut.chat.edit_last_user_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'E'] }, - 'shortcut.chat.search_message': { enabled: true, key: ['CommandOrControl', 'F'] }, - 'shortcut.chat.select_model': { enabled: true, key: ['CommandOrControl', 'Shift', 'M'] }, - 'shortcut.chat.toggle_new_context': { enabled: true, key: ['CommandOrControl', 'K'] }, - 'shortcut.feature.selection.get_text': { enabled: false, key: [] }, - 'shortcut.feature.selection.toggle_enabled': { enabled: false, key: [] }, - 'shortcut.general.exit_fullscreen': { enabled: true, key: ['Escape'] }, - 'shortcut.general.search': { enabled: true, key: ['CommandOrControl', 'Shift', 'F'] }, - 'shortcut.general.show_main_window': { enabled: true, key: [] }, - 'shortcut.general.show_mini_window': { enabled: false, key: ['CommandOrControl', 'E'] }, - 'shortcut.general.show_settings': { enabled: true, key: ['CommandOrControl', ','] }, - 'shortcut.general.toggle_sidebar': { enabled: true, key: ['CommandOrControl', '['] }, - 'shortcut.general.zoom_in': { enabled: true, key: ['CommandOrControl', '='] }, - 'shortcut.general.zoom_out': { enabled: true, key: ['CommandOrControl', '-'] }, - 'shortcut.general.zoom_reset': { enabled: true, key: ['CommandOrControl', '0'] }, - 'shortcut.topic.new': { enabled: true, key: ['CommandOrControl', 'N'] }, - 'shortcut.topic.rename': { enabled: true, key: ['CommandOrControl', 'T'] }, - 'shortcut.topic.toggle_show_topics': { enabled: true, key: ['CommandOrControl', ']'] }, + '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.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: true }, + 'shortcut.general.show_mini_window': { binding: ['CommandOrControl', 'E'], 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', diff --git a/packages/shared/data/preference/preferenceTypes.ts b/packages/shared/data/preference/preferenceTypes.ts index ede1d0d0a67..ee6021b2465 100644 --- a/packages/shared/data/preference/preferenceTypes.ts +++ b/packages/shared/data/preference/preferenceTypes.ts @@ -22,7 +22,7 @@ export type PreferenceUpdateOptions = { } export type PreferenceShortcutType = { - key: string[] + binding: string[] enabled: boolean } diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index 824f0295df0..4f424d2a78b 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -1,4 +1,4 @@ -import type { ShortcutDefinition } from './types' +import type { ShortcutDefinition, ShortcutPreferenceKey } from './types' export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 应用级快捷键 ==================== @@ -8,7 +8,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ scope: 'main', category: 'general', labelKey: 'show_app', - system: true, global: true }, { @@ -17,9 +16,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ scope: 'main', category: 'general', labelKey: 'mini_window', - system: true, - global: true, - enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled') + global: true }, { key: 'shortcut.general.show_settings', @@ -27,8 +24,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ scope: 'both', category: 'general', labelKey: 'show_settings', - editable: false, - system: true + editable: false }, { key: 'shortcut.general.toggle_sidebar', @@ -43,8 +39,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ scope: 'renderer', category: 'general', labelKey: 'exit_fullscreen', - editable: false, - system: true + editable: false }, { key: 'shortcut.general.zoom_in', @@ -53,7 +48,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'general', labelKey: 'zoom_in', editable: false, - system: true, variants: [['CommandOrControl', 'numadd']] }, { @@ -63,7 +57,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'general', labelKey: 'zoom_out', editable: false, - system: true, variants: [['CommandOrControl', 'numsub']] }, { @@ -72,8 +65,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ scope: 'main', category: 'general', labelKey: 'zoom_reset', - editable: false, - system: true + editable: false }, { key: 'shortcut.general.search', @@ -154,7 +146,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ scope: 'main', category: 'feature.selection', labelKey: 'selection_assistant_toggle', - system: true, global: true, supportedPlatforms: ['darwin', 'win32'] }, @@ -164,16 +155,15 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ scope: 'main', category: 'feature.selection', labelKey: 'selection_assistant_select_text', - system: true, global: true, supportedPlatforms: ['darwin', 'win32'] } ] as const -const definitionMap = new Map( +const definitionMap = new Map( SHORTCUT_DEFINITIONS.map((definition) => [definition.key, definition]) ) -export const findShortcutDefinition = (key: string): ShortcutDefinition | undefined => { +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 index 3a09b6dfe8c..54b99c21558 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -1,4 +1,4 @@ -import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/preference/preferenceTypes' +import type { PreferenceKeyType } from '@shared/data/preference/preferenceTypes' export type ShortcutScope = 'main' | 'renderer' | 'both' @@ -19,10 +19,6 @@ export type ShortcutPreferenceKey = Extract(key: K) => PreferenceDefaultScopeType[K] - -export type ShortcutEnabledPredicate = (getPreference: GetPreferenceFn) => boolean - /** 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}`. */ @@ -37,14 +33,10 @@ export interface ShortcutDefinition { labelKey: string /** Whether users can modify the binding in settings. Defaults to `true`. */ editable?: boolean - /** System-level shortcut — when `true` the binding cannot be deleted. */ - system?: 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[][] - /** Dynamic enable condition evaluated at registration time. Return `false` to skip registration. */ - enabledWhen?: ShortcutEnabledPredicate /** Restrict this shortcut to specific operating systems. Omit to enable on all platforms. */ supportedPlatforms?: SupportedPlatform[] } @@ -57,6 +49,4 @@ export interface ResolvedShortcut { enabled: boolean /** Whether users can modify the binding. Injected from `ShortcutDefinition.editable`, not stored in preferences. */ editable: boolean - /** System-level flag. Injected from `ShortcutDefinition.system`, not stored in preferences. */ - system: boolean } diff --git a/packages/shared/shortcuts/utils.ts b/packages/shared/shortcuts/utils.ts index b012fa6f287..032fc3e2a36 100644 --- a/packages/shared/shortcuts/utils.ts +++ b/packages/shared/shortcuts/utils.ts @@ -107,14 +107,13 @@ const ensureBoolean = (value: unknown, fallback: boolean): boolean => (typeof va export const getDefaultShortcut = (definition: ShortcutDefinition): ResolvedShortcut => { const fallback = DefaultPreferences.default[definition.key] - const rawBinding = ensureArray(fallback?.key) + const rawBinding = ensureArray(fallback?.binding) const binding = rawBinding.length ? rawBinding : definition.defaultBinding return { binding, enabled: ensureBoolean(fallback?.enabled, true), - editable: definition.editable !== false, - system: definition.system === true + editable: definition.editable !== false } } @@ -123,12 +122,11 @@ export const resolveShortcutPreference = ( value?: PreferenceShortcutType | null ): ResolvedShortcut => { const fallback = getDefaultShortcut(definition) - const binding = value != null ? (value.key?.length ? ensureArray(value.key) : []) : fallback.binding + const binding = value != null ? (value.binding?.length ? ensureArray(value.binding) : []) : fallback.binding return { binding, enabled: ensureBoolean(value?.enabled, fallback.enabled), - editable: definition.editable !== false, - system: definition.system === true + editable: definition.editable !== false } } 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 78b6052e338..56a5974ccc6 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-10T10:40:56.186Z + * Generated at: 2026-04-10T11:18:14.912Z * * 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 3bd3826797c..60aaf3ceb6a 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-10T10:40:56.186Z + * Generated at: 2026-04-10T11:18:14.912Z * * This file contains pure mapping relationships without default values. * Default values are managed in packages/shared/data/preferences.ts 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..20836fa5fa4 --- /dev/null +++ b/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts @@ -0,0 +1,92 @@ +/** + * 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', + mini_window: 'shortcut.general.show_mini_window', + selection_assistant_toggle: 'shortcut.feature.selection.toggle_enabled', + selection_assistant_select_text: 'shortcut.feature.selection.get_text', + 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' +} + +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') + +export const transformShortcuts: TransformFunction = (sources) => { + const shortcuts = sources.shortcuts + const result: Record = {} + + 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 + } + + const binding = isStringArray(entry.shortcut) ? entry.shortcut : [] + const enabled = typeof entry.enabled === 'boolean' ? entry.enabled : true + + result[targetKey] = { binding, enabled } + } + + 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..ab45812b0eb 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,10 @@ 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') + // 7 websearch compression + 1 provider overrides + 1 code_cli overrides + // + 1 file processing overrides + 20 shortcut keys + expect(keys.length).toBe(30) }) it('should flatten target keys from all mappings', () => { diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index e9cf0b7c10b..c6b18204ad1 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -103,11 +103,12 @@ export class ShortcutService extends BaseService { private subscribeToPreferenceChanges(): void { const preferenceService = application.get('PreferenceService') for (const definition of relevantDefinitions) { - const unsub = preferenceService.subscribeChange(definition.key, () => { - logger.debug(`Shortcut preference changed: ${definition.key}`) - this.reregisterShortcuts() - }) - this.registerDisposable({ dispose: unsub }) + this.registerDisposable( + preferenceService.subscribeChange(definition.key, () => { + logger.debug(`Shortcut preference changed: ${definition.key}`) + this.reregisterShortcuts() + }) + ) } } @@ -141,7 +142,6 @@ export class ShortcutService extends BaseService { if (window.isDestroyed()) return const preferenceService = application.get('PreferenceService') - const getPreference = (key: K) => preferenceService.get(key as any) // Build the desired set of accelerators const desired = new Map() @@ -152,7 +152,6 @@ export class ShortcutService extends BaseService { const rawPref = preferenceService.get(definition.key) as PreferenceShortcutType | undefined const pref = resolveShortcutPreference(definition, rawPref) if (!pref.enabled || !pref.binding.length) continue - if (definition.enabledWhen && !definition.enabledWhen(getPreference as any)) continue const handler = this.handlers.get(definition.key) if (!handler) continue @@ -178,8 +177,8 @@ export class ShortcutService extends BaseService { if (!entry || entry.handler !== prevHandler) { try { globalShortcut.unregister(accelerator) - } catch { - // ignore + } catch (error) { + logger.debug(`Failed to unregister shortcut accelerator: ${accelerator}`, error as Error) } this.registeredAccelerators.delete(accelerator) } @@ -189,13 +188,17 @@ export class ShortcutService extends BaseService { for (const [accelerator, { handler, window: win }] of desired) { if (!this.registeredAccelerators.has(accelerator)) { try { - globalShortcut.register(accelerator, () => { + const success = globalShortcut.register(accelerator, () => { const targetWindow = win?.isDestroyed?.() ? undefined : win handler(targetWindow) }) - this.registeredAccelerators.set(accelerator, handler) + if (success) { + this.registeredAccelerators.set(accelerator, handler) + } else { + logger.warn(`Failed to register shortcut ${accelerator}: accelerator is held by another application`) + } } catch (error) { - logger.warn(`Failed to register shortcut ${accelerator}`) + logger.warn(`Failed to register shortcut ${accelerator}`, error as Error) } } } @@ -221,13 +224,13 @@ export class ShortcutService extends BaseService { for (const accelerator of this.registeredAccelerators.keys()) { try { globalShortcut.unregister(accelerator) - } catch { - // ignore + } catch (error) { + logger.debug(`Failed to unregister shortcut accelerator: ${accelerator}`, error as Error) } } this.registeredAccelerators.clear() } catch (error) { - logger.warn('Failed to unregister all shortcuts') + logger.warn('Failed to unregister all shortcuts', error as Error) } } } diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index 56505a88ced..ebb9200e5ee 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -27,8 +27,10 @@ const defaultOptions: UseShortcutOptions = { enableOnContentEditable: false } +const isFullKey = (key: string): key is ShortcutPreferenceKey => key.startsWith('shortcut.') + const toFullKey = (key: ShortcutKey | ShortcutPreferenceKey): ShortcutPreferenceKey => - (key.startsWith('shortcut.') ? key : `shortcut.${key}`) as ShortcutPreferenceKey + isFullKey(key) ? key : (`shortcut.${key}` as ShortcutPreferenceKey) export const useShortcut = ( shortcutKey: ShortcutKey | ShortcutPreferenceKey, @@ -134,7 +136,11 @@ export const useAllShortcuts = (): ShortcutListItem[] => { ): PreferenceShortcutType => { const current = (currentValue ?? {}) as PreferenceShortcutType - const nextKey = Array.isArray(patch.key) ? patch.key : Array.isArray(current.key) ? current.key : state.binding + const nextBinding = Array.isArray(patch.binding) + ? patch.binding + : Array.isArray(current.binding) + ? current.binding + : state.binding const nextEnabled = typeof patch.enabled === 'boolean' @@ -144,7 +150,7 @@ export const useAllShortcuts = (): ShortcutListItem[] => { : state.enabled return { - key: nextKey, + binding: nextBinding, enabled: nextEnabled } }, diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index d65d278be46..43e18e53e3f 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -5568,7 +5568,9 @@ "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", "search_message": "Search Message", "search_message_in_chat": "Search Message in Current Chat", "search_placeholder": "Search shortcuts...", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 17772d97481..7026a6f97bb 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -5568,7 +5568,9 @@ "rename_topic": "重命名话题", "reset_defaults": "重置默认快捷键", "reset_defaults_confirm": "确定要重置所有快捷键吗?", + "reset_defaults_failed": "重置快捷键失败", "reset_to_default": "重置为默认", + "save_failed": "保存快捷键失败", "search_message": "搜索消息", "search_message_in_chat": "在当前对话中搜索消息", "search_placeholder": "搜索快捷键...", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a4895b39191..64c3c495f2c 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -5568,7 +5568,9 @@ "rename_topic": "重新命名話題", "reset_defaults": "重設預設快捷鍵", "reset_defaults_confirm": "確定要重設所有快捷鍵嗎?", + "reset_defaults_failed": "重設快捷鍵失敗", "reset_to_default": "重設為預設", + "save_failed": "儲存快捷鍵失敗", "search_message": "搜尋訊息", "search_message_in_chat": "在目前對話中搜尋訊息", "search_placeholder": "搜尋捷徑...", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index eeca418d920..4d44f4714c6 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -5568,7 +5568,9 @@ "rename_topic": "Thema umbenennen", "reset_defaults": "Standard-Shortcuts zurücksetzen", "reset_defaults_confirm": "Alle Shortcuts wirklich zurücksetzen?", + "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Auf Standard zurücksetzen", + "save_failed": "[to be translated]:Failed to save shortcut", "search_message": "Nachricht suchen", "search_message_in_chat": "In aktuellem Chat suchen", "search_placeholder": "Suchverknüpfungen durchsuchen...", @@ -5580,7 +5582,7 @@ "title": "Shortcut", "toggle_new_context": "Kontext löschen", "toggle_show_topics": "Themenanzeige umschalten", - "toggle_sidebar": "Assistentenanzeige umschalten", + "toggle_sidebar": "Seitenleiste umschalten", "zoom_in": "Oberfläche vergrößern", "zoom_out": "Oberfläche verkleinern", "zoom_reset": "Zoom zurücksetzen" diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index d26331e42ea..a10d813193e 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -5568,7 +5568,9 @@ "rename_topic": "Μετονομασία θέματος", "reset_defaults": "Επαναφορά στα προεπιλεγμένα συντομού πλήκτρα", "reset_defaults_confirm": "Θέλετε να επαναφέρετε όλα τα συντομού πλήκτρα στις προεπιλεγμένες τιμές;", + "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Επαναφορά στις προεπιλεγμένες", + "save_failed": "[to be translated]:Failed to save shortcut", "search_message": "Αναζήτηση μηνυμάτων", "search_message_in_chat": "Αναζήτηση μηνύματος στην τρέχουσα συνομιλία", "search_placeholder": "Συντομεύσεις αναζήτησης...", @@ -5580,7 +5582,7 @@ "title": "Συντομοί δρομολόγια", "toggle_new_context": "Άδειασμα σενάριων", "toggle_show_topics": "Εναλλαγή εμφάνισης θεμάτων", - "toggle_sidebar": "Εναλλαγή εμφάνισης βοηθών", + "toggle_sidebar": "Εναλλαγή πλαϊνής γραμμής", "zoom_in": "Μεγέθυνση εμφάνισης", "zoom_out": "Σμικρύνση εμφάνισης", "zoom_reset": "Επαναφορά εμφάνισης" diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 9a6570a57cb..dd427907327 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -5568,7 +5568,9 @@ "rename_topic": "Renombrar tema", "reset_defaults": "Restablecer atajos predeterminados", "reset_defaults_confirm": "¿Está seguro de querer restablecer todos los atajos?", + "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Restablecer a predeterminado", + "save_failed": "[to be translated]:Failed to save shortcut", "search_message": "Buscar mensaje", "search_message_in_chat": "Buscar mensajes en la conversación actual", "search_placeholder": "Buscar accesos directos...", @@ -5580,7 +5582,7 @@ "title": "Atajos", "toggle_new_context": "Limpiar contexto", "toggle_show_topics": "Alternar visibilidad de temas", - "toggle_sidebar": "Alternar visibilidad de asistentes", + "toggle_sidebar": "Alternar barra lateral", "zoom_in": "Ampliar interfaz", "zoom_out": "Reducir interfaz", "zoom_reset": "Restablecer zoom" diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 8070e844e22..32945be4754 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -5568,7 +5568,9 @@ "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": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Réinitialiser aux valeurs par défaut", + "save_failed": "[to be translated]:Failed to save shortcut", "search_message": "Rechercher un message", "search_message_in_chat": "Rechercher un message dans la conversation actuelle", "search_placeholder": "Raccourcis de recherche...", @@ -5580,7 +5582,7 @@ "title": "Raccourcis", "toggle_new_context": "Effacer le contexte", "toggle_show_topics": "Basculer l'affichage des sujets", - "toggle_sidebar": "Basculer l'affichage des assistants", + "toggle_sidebar": "Basculer la barre latérale", "zoom_in": "Agrandir l'interface", "zoom_out": "Réduire l'interface", "zoom_reset": "Réinitialiser le zoom" diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index ad256e29014..7f5c4b5ecb2 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -5568,7 +5568,9 @@ "rename_topic": "トピックの名前を変更", "reset_defaults": "デフォルトのショートカットをリセット", "reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?", + "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "デフォルトにリセット", + "save_failed": "[to be translated]:Failed to save shortcut", "search_message": "メッセージを検索", "search_message_in_chat": "現在のチャットでメッセージを検索", "search_placeholder": "検索ショートカット...", @@ -5580,7 +5582,7 @@ "title": "ショートカット", "toggle_new_context": "コンテキストをクリア", "toggle_show_topics": "トピックの表示を切り替え", - "toggle_sidebar": "アシスタントの表示を切り替え", + "toggle_sidebar": "サイドバーを切り替え", "zoom_in": "ズームイン", "zoom_out": "ズームアウト", "zoom_reset": "ズームをリセット" diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 395cee4822c..d16be5f66f8 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -5568,7 +5568,9 @@ "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": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Redefinir para padrão", + "save_failed": "[to be translated]:Failed to save shortcut", "search_message": "Pesquisar mensagem", "search_message_in_chat": "Pesquisar mensagens nesta conversa", "search_placeholder": "Atalhos de pesquisa...", @@ -5580,7 +5582,7 @@ "title": "Atalhos", "toggle_new_context": "Limpar contexto", "toggle_show_topics": "Alternar exibição de tópicos", - "toggle_sidebar": "Alternar exibição de assistentes", + "toggle_sidebar": "Alternar barra lateral", "zoom_in": "Ampliar interface", "zoom_out": "Diminuir interface", "zoom_reset": "Redefinir zoom" diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index d554a6b0ce3..67f6d060d67 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -5568,7 +5568,9 @@ "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": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Resetează la implicit", + "save_failed": "[to be translated]:Failed to save shortcut", "search_message": "Caută mesaj", "search_message_in_chat": "Caută mesaj în chat-ul curent", "search_placeholder": "Căutare comenzi rapide...", @@ -5580,7 +5582,7 @@ "title": "Comenzi rapide de la tastatură", "toggle_new_context": "Șterge contextul", "toggle_show_topics": "Comută subiectele", - "toggle_sidebar": "Comută asistenții", + "toggle_sidebar": "Comută bara laterală", "zoom_in": "Mărește", "zoom_out": "Micșorează", "zoom_reset": "Resetează zoom-ul" diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 6312b507b32..a500b1a822b 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -5568,7 +5568,9 @@ "rename_topic": "Переименовать топик", "reset_defaults": "Сбросить настройки по умолчанию", "reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?", + "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Сбросить настройки по умолчанию", + "save_failed": "[to be translated]:Failed to save shortcut", "search_message": "Поиск сообщения", "search_message_in_chat": "Поиск сообщения в текущем диалоге", "search_placeholder": "Поиск ярлыков...", @@ -5580,7 +5582,7 @@ "title": "Горячие клавиши", "toggle_new_context": "Очистить контекст", "toggle_show_topics": "Переключить отображение топиков", - "toggle_sidebar": "Переключить отображение ассистентов", + "toggle_sidebar": "Переключить боковую панель", "zoom_in": "Увеличить", "zoom_out": "Уменьшить", "zoom_reset": "Сбросить масштаб" diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index 7ea40a15e4a..cf2118fbb6e 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -1,6 +1,7 @@ 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, platform } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllShortcuts } from '@renderer/hooks/useShortcuts' @@ -20,6 +21,8 @@ import { useTranslation } from 'react-i18next' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' +const logger = loggerService.withContext('ShortcutSettings') + type ShortcutRecord = { id: string label: string @@ -27,7 +30,6 @@ type ShortcutRecord = { enabled: boolean editable: boolean displayKeys: string[] - system: boolean updatePreference: (patch: Partial) => Promise defaultPreference: { binding: string[] @@ -91,7 +93,6 @@ const ShortcutSettings: FC = () => { enabled: item.preference.enabled, editable: item.preference.editable, displayKeys, - system: item.preference.system, updatePreference: item.updatePreference, defaultPreference: { binding: item.defaultPreference.binding, @@ -137,8 +138,15 @@ const ShortcutSettings: FC = () => { return !isBindingEqual(record.displayKeys, record.defaultPreference.binding) } + const handleUpdateFailure = (record: ShortcutRecord, error: unknown) => { + logger.error(`Failed to update shortcut preference: ${record.key}`, error as Error) + window.toast.error(t('settings.shortcuts.save_failed')) + } + const handleResetShortcut = (record: ShortcutRecord) => { - void record.updatePreference({ key: record.defaultPreference.binding }) + record.updatePreference({ binding: record.defaultPreference.binding }).catch((error) => { + handleUpdateFailure(record, error) + }) setEditingKey(null) setPendingKeys([]) setConflictLabel(null) @@ -200,7 +208,9 @@ const ShortcutSettings: FC = () => { } setConflictLabel(null) - void record.updatePreference({ key: keys }) + record.updatePreference({ binding: keys }).catch((error) => { + handleUpdateFailure(record, error) + }) setEditingKey(null) setPendingKeys([]) } @@ -214,12 +224,17 @@ const ShortcutSettings: FC = () => { shortcuts.forEach((item) => { updates[item.definition.key] = { - key: item.defaultPreference.binding, + binding: item.defaultPreference.binding, enabled: item.defaultPreference.enabled } }) - await preferenceService.setMultiple(updates) + try { + 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')) + } } }) } @@ -321,7 +336,11 @@ const ShortcutSettings: FC = () => { void record.updatePreference({ enabled: !record.enabled })} + onCheckedChange={() => { + record.updatePreference({ enabled: !record.enabled }).catch((error) => { + handleUpdateFailure(record, error) + }) + }} /> )} diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md index c1e7ff39da2..069a28e3701 100644 --- a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -50,7 +50,7 @@ v1 快捷键系统存在以下架构缺陷: 1. **关注点分离** — 定义层(静态元数据)、偏好层(用户配置)、服务层(注册与生命周期)、UI 层(展示与编辑)各司其职 2. **复用基础设施** — 所有持久化依赖 `preferenceService`,不引入新的存储通道 -3. **防御性 coerce** — 所有偏好读取均经过 `coerceShortcutPreference` 归一化,保证缺失字段有合理 fallback +3. **防御性 coerce** — 所有偏好读取均经过 `resolveShortcutPreference` 归一化,保证缺失字段有合理 fallback 4. **声明式驱动** — 注册逻辑遍历 `SHORTCUT_DEFINITIONS`,不硬编码具体快捷键 --- @@ -113,12 +113,10 @@ v1 快捷键系统存在以下架构缺陷: key: 'shortcut.general.show_mini_window', // Preference key defaultBinding: ['CommandOrControl', 'E'], // Electron accelerator 格式 scope: 'main', // main | renderer | both - category: 'feature.selection', // 点分命名空间 UI 分组:app.general、app.chat、plugin.xxx 等 + category: 'general', // 点分命名空间 UI 分组:general、chat、topic、plugin.xxx 等 labelKey: 'mini_window', // i18n label key - system: true, // 系统级(不可删除绑定) editable: true, // 用户可修改绑定(默认 true) global: true, // 全局快捷键(窗口失焦后仍然生效) - enabledWhen: (get) => !!get('feature.quick_assistant.enabled'), supportedPlatforms: ['darwin', 'win32'] } ``` @@ -127,16 +125,14 @@ v1 快捷键系统存在以下架构缺陷: | 字段 | 用途 | |------|------| -| `key` | Preference key,内置快捷键用 `shortcut.app.{category}.{name}` 格式,插件用 `shortcut.plugin.{pluginId}.{name}` | +| `key` | Preference key,内置快捷键用 `shortcut.{category}.{name}` 格式,插件用 `shortcut.plugin.{pluginId}.{name}` | | `defaultBinding` | Electron accelerator 格式的默认绑定,空数组表示无默认绑定 | | `scope` | 决定快捷键注册在哪个进程:`main`(globalShortcut)、`renderer`(react-hotkeys-hook)、`both`(两者都注册) | -| `category` | 点分命名空间 UI 分组(如 `general`、`chat`、`topic`、`plugin.translator`),类型为 `string` 以支持插件扩展 | +| `category` | 点分命名空间 UI 分组(如 `general`、`chat`、`topic`、`plugin.translator`),类型为 `ShortcutCategory` 以支持插件扩展 | | `labelKey` | i18n label key,由 `getShortcutLabel()` 消费 | | `editable` | 设为 `false` 表示用户不可修改绑定(如 Escape 退出全屏),默认 `true` | -| `system` | 系统级标记,`true` 时不可删除绑定 | | `global` | 全局快捷键,窗口失焦时是否保留注册(如 `show_main_window` 需要在任何时候响应) | | `variants` | 同一快捷键的多组绑定(如 zoom_in 同时绑定 `=` 和小键盘 `+`) | -| `enabledWhen` | 动态启用条件,接收 `getPreference` 函数,在注册时求值 | | `supportedPlatforms` | 限制快捷键仅在指定操作系统上注册和显示,类型为 `SupportedPlatform[]`(`'darwin' | 'win32' | 'linux'`) | #### `types.ts` — 类型体系 @@ -153,7 +149,6 @@ interface ResolvedShortcut { binding: string[] // 生效的绑定(用户自定义、默认值或空数组——显式清空) enabled: boolean // 是否启用 editable: boolean // 来自 definition.editable,不存储在偏好中 - system: boolean // 来自 definition.system,不存储在偏好中 } ``` @@ -163,6 +158,9 @@ interface ResolvedShortcut { // 两种写法等价,均有类型补全 useShortcut('chat.clear', callback) useShortcut('shortcut.chat.clear', callback) + +// useShortcutDisplay 同样两种写法均有效 +useShortcutDisplay('chat.clear') ``` #### `utils.ts` — 纯函数工具集 @@ -180,12 +178,12 @@ useShortcut('shortcut.chat.clear', callback) ``` 输入值为 null/undefined → 使用 schema 默认值 -输入的 key 为空数组 → binding 为空(用户显式清空) +输入的 binding 为空数组 → binding 为空(用户显式清空) 输入的 enabled 非布尔 → 使用默认 enabled -editable/system → 始终从 definition 读取(不存储在偏好中) +editable → 始终从 definition 读取(不存储在偏好中) ``` -**设计决策**:禁用快捷键可以使用 `enabled: false`,也可以清空绑定(`key: []`)。想换键就录制覆盖,想重置就写回 `defaultBinding`。 +**设计决策**:禁用快捷键可以使用 `enabled: false`,也可以清空绑定(`binding: []`)。想换键就录制覆盖,想重置就写回 `defaultBinding`。 ### 2. 偏好层 (`preferenceSchemas.ts` + `preferenceTypes.ts`) @@ -194,18 +192,18 @@ editable/system → 始终从 definition 读取(不存储在偏好中 ```typescript // PreferenceShortcutType — 存储在 SQLite 中的数据结构 type PreferenceShortcutType = { - key: string[] // 用户自定义的键位绑定 + binding: string[] // 用户自定义的键位绑定 enabled: boolean // 启用/禁用 } ``` -**设计决策**:`editable` 和 `system` 不存储在偏好中,而是在运行时从 `ShortcutDefinition` 注入。这样修改定义不需要数据迁移。 +**设计决策**:`editable` 不存储在偏好中,而是在运行时从 `ShortcutDefinition` 注入。这样修改定义不需要数据迁移。 `preferenceSchemas.ts` 中为每个快捷键声明默认值: ```typescript -'shortcut.chat.clear': { enabled: true, key: ['CommandOrControl', 'L'] }, -'shortcut.chat.copy_last_message': { enabled: false, key: ['CommandOrControl', 'Shift', 'C'] }, +'shortcut.chat.clear': { enabled: true, binding: ['CommandOrControl', 'L'] }, +'shortcut.chat.copy_last_message': { enabled: false, binding: ['CommandOrControl', 'Shift', 'C'] }, ``` ### 3. 主进程服务层 (`ShortcutService`) @@ -241,21 +239,23 @@ this.handlers.set('shortcut.general.zoom_in', (window) => { └── blur 事件 → registerShortcuts(window, true) 仅保留 global ``` -`registerShortcuts` 的核心流程: +`registerShortcuts` 采用增量 diff 注册,避免重复 unregister/register: -1. `globalShortcut.unregisterAll()` 清空所有注册 -2. 遍历 `relevantDefinitions`(预过滤 `scope !== 'renderer'` 和 `supportedPlatforms`) -3. 对每个定义:读取偏好 → `coerceShortcutPreference` 归一化 → 检查 `enabled` + `enabledWhen` → 注册 handler -4. 如果定义有 `variants`,额外注册变体绑定 +1. 遍历 `relevantDefinitions`(预过滤 `scope !== 'renderer'` 和 `supportedPlatforms`),构建目标 accelerator → handler 映射 `desired` +2. 对每个定义:读取偏好 → `resolveShortcutPreference` 归一化 → 检查 `enabled` + binding 非空 +3. 若定义有 `variants`,将变体绑定一并加入 `desired` +4. 遍历 `registeredAccelerators`:目标中不存在或 handler 变化的调用 `globalShortcut.unregister` +5. 遍历 `desired`:尚未注册的调用 `globalShortcut.register`,检查返回值,失败时打日志 #### 偏好变更订阅 ```typescript for (const definition of relevantDefinitions) { - const unsub = preferenceService.subscribeChange(definition.key, () => { - this.reregisterShortcuts() // 整体重注册 - }) - this.registerDisposable({ dispose: unsub }) // 生命周期自动清理 + this.registerDisposable( + preferenceService.subscribeChange(definition.key, () => { + this.reregisterShortcuts() // 整体重注册 + }) + ) } ``` @@ -270,7 +270,7 @@ for (const definition of relevantDefinitions) { 1. `toFullKey()` 支持短 key 和完整 key 两种写法 2. `findShortcutDefinition()` 查找定义 3. `usePreference()` 读取当前偏好值 -4. `coerceShortcutPreference()` 归一化 +4. `resolveShortcutPreference()` 归一化 5. 检查 `scope === 'main'` → 跳过(主进程快捷键不在渲染进程注册) 6. 检查 `enabled` → 禁用则 hotkey 设为 `'none'` 7. `convertAcceleratorToHotkey()` 转换格式 @@ -306,7 +306,7 @@ const display = useShortcutDisplay('chat.clear') - 使用 `useMultiplePreferences()` 一次性读取所有 `shortcut.*` 偏好 - 返回 `ShortcutListItem[]`,每项包含 `definition`、`preference`、`defaultPreference`、`updatePreference` -- `updatePreference` 内部使用 `buildNextPreference` 合并 patch,仅写入 `{ key, enabled }` +- `updatePreference` 内部使用 `buildNextPreference` 合并 patch,仅写入 `{ binding, enabled }` ### 5. UI 层 (`ShortcutSettings.tsx`) @@ -317,7 +317,7 @@ const display = useShortcutDisplay('chat.clear') | **平台过滤** | 根据 `supportedPlatforms` 过滤不支持的快捷键 | | **快捷键录制** | `handleKeyDown` 捕获键盘事件 → `convertKeyToAccelerator` → `isValidShortcut` 校验 | | **冲突检测** | `isDuplicateShortcut` 检查已显示快捷键中是否存在相同绑定 | -| **清空绑定** | `updatePreference({ key: [] })` | +| **清空绑定** | `updatePreference({ binding: [] })` | | **重置单项** | 写入 `defaultPreference` 的 `binding` + `enabled` | | **重置全部** | `preferenceService.setMultiple()` 批量写入所有默认值 | | **启用/禁用** | `updatePreference({ enabled: !current })` | @@ -376,14 +376,14 @@ ShortcutService.onInit() ↓ handleKeyDown convertKeyToAccelerator() + isValidShortcut() + isDuplicateShortcut() ↓ 通过校验 -updatePreference({ key: newKeys }) +updatePreference({ binding: newKeys }) ↓ useMultiplePreferences.setValues() -preferenceService.set('shortcut.chat.clear', { key: [...], enabled: true }) +preferenceService.set('shortcut.chat.clear', { binding: [...], enabled: true }) ├── SQLite 持久化 ├── IPC 广播 → 所有渲染窗口自动更新 └── subscribeChange 回调 → ShortcutService.reregisterShortcuts() ↓ - globalShortcut.unregisterAll() → 按新配置重注册 + 增量 diff 注册(仅变化的 accelerator 重新注册) ``` ### 渲染进程快捷键触发 @@ -414,11 +414,11 @@ toggleMiniWindow() | Preference Key | 默认绑定 | 作用域 | 备注 | |---|---|---|---| -| `shortcut.general.show_main_window` | *(无)* | main | 失焦持久,系统级 | +| `shortcut.general.show_main_window` | *(无)* | main | 失焦持久 | | `shortcut.general.show_mini_window` | `Cmd/Ctrl+E` | main | 关联 quick_assistant 开关 | | `shortcut.general.show_settings` | `Cmd/Ctrl+,` | both | 不可编辑 | | `shortcut.general.toggle_sidebar` | `Cmd/Ctrl+[` | renderer | | -| `shortcut.general.exit_fullscreen` | `Escape` | 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 | | @@ -437,11 +437,11 @@ toggleMiniWindow() ### 话题 (`topic`) -| Preference Key | 默认绑定 | -|---|---| -| `shortcut.topic.new` | `Cmd/Ctrl+N` | -| `shortcut.topic.rename` | `Cmd/Ctrl+T` | -| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl+]` | +| Preference Key | 默认绑定 | 默认启用 | +|---|---|---| +| `shortcut.topic.new` | `Cmd/Ctrl+N` | 是 | +| `shortcut.topic.rename` | `Cmd/Ctrl+T` | 否 | +| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl+]` | 是 | ### 划词助手 (`selection`) @@ -460,7 +460,7 @@ toggleMiniWindow() ```typescript // packages/shared/data/preference/preferenceSchemas.ts -'shortcut.chat.regenerate': { enabled: true, key: ['CommandOrControl', 'Shift', 'R'] }, +'shortcut.chat.regenerate': { enabled: true, binding: ['CommandOrControl', 'Shift', 'R'] }, ``` > 注意:类型声明区也需要添加对应的类型声明行。 @@ -489,14 +489,13 @@ this.handlers.set('shortcut.chat.regenerate', () => { ... }) ### 条件启用 -使用 `enabledWhen` 让快捷键根据其他偏好动态启用/禁用: +需要条件启用的快捷键,在 handler 内自行读取偏好并直接返回: ```typescript -{ - key: 'shortcut.general.show_mini_window', - enabledWhen: (get) => !!get('feature.quick_assistant.enabled'), - // 当 quick_assistant 关闭时,此快捷键不会被注册 -} +this.handlers.set('shortcut.general.show_mini_window', () => { + if (!application.get('PreferenceService').get('feature.quick_assistant.enabled')) return + application.get('WindowService').toggleMiniWindow() +}) ``` ### 平台限制 @@ -517,7 +516,7 @@ this.handlers.set('shortcut.chat.regenerate', () => { ... }) | 旧组件 | 状态 | |--------|------| -| Redux `shortcuts` slice | 从 `combineReducers` 移除,文件保留供数据迁移 `initialState` 使用 | +| Redux `shortcuts` slice | 保留在 `combineReducers` 中供旧版本数据兼容读取,`PreferenceMigrator` 从其 `initialState` 中迁移快捷键到新的 `shortcut.*` key | | `IpcChannel.Shortcuts_Update` | 已删除 | | `window.api.shortcuts.update` (preload bridge) | 已删除 | | `configManager.getShortcuts()` / `setShortcuts()` | 已删除 | @@ -543,8 +542,8 @@ this.handlers.set('shortcut.chat.regenerate', () => { ... }) | `convertAcceleratorToHotkey` | 修饰键转换(CommandOrControl→mod, Ctrl→ctrl 等) | | `formatShortcutDisplay` | Mac 符号格式(⌘⇧⌥⌃)、非 Mac 文字格式 | | `isValidShortcut` | 空数组、含修饰键、特殊单键、普通单键 | -| `getDefaultShortcutPreference` | 默认值读取、`editable`/`system` 继承 | -| `coerceShortcutPreference` | null/undefined 回退、自定义 key、空数组回退、enabled 覆盖 | +| `getDefaultShortcut` | 默认值读取、`editable` 继承 | +| `resolveShortcutPreference` | null/undefined 回退、自定义 binding、空数组回退、enabled 覆盖 | --- diff --git a/v2-refactor-temp/tools/data-classify/data/classification.json b/v2-refactor-temp/tools/data-classify/data/classification.json index 7811eaf4bdf..66c750c953c 100644 --- a/v2-refactor-temp/tools/data-classify/data/classification.json +++ b/v2-refactor-temp/tools/data-classify/data/classification.json @@ -2476,11 +2476,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "=" - ] + ], + "enabled": true }, "originalKey": "zoom_in", "status": "classified", @@ -2490,11 +2490,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "-" - ] + ], + "enabled": true }, "originalKey": "zoom_out", "status": "classified", @@ -2504,11 +2504,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "0" - ] + ], + "enabled": true }, "originalKey": "zoom_reset", "status": "classified", @@ -2518,11 +2518,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "," - ] + ], + "enabled": true }, "originalKey": "show_settings", "status": "classified", @@ -2532,8 +2532,8 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [] + "binding": [], + "enabled": true }, "originalKey": "show_app", "status": "classified", @@ -2543,11 +2543,11 @@ { "category": "preferences", "defaultValue": { - "enabled": false, - "key": [ + "binding": [ "CommandOrControl", "E" - ] + ], + "enabled": false }, "originalKey": "mini_window", "status": "classified", @@ -2557,8 +2557,8 @@ { "category": "preferences", "defaultValue": { - "enabled": false, - "key": [] + "binding": [], + "enabled": false }, "originalKey": "selection_assistant_toggle", "status": "classified", @@ -2568,8 +2568,8 @@ { "category": "preferences", "defaultValue": { - "enabled": false, - "key": [] + "binding": [], + "enabled": false }, "originalKey": "selection_assistant_select_text", "status": "classified", @@ -2579,11 +2579,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "N" - ] + ], + "enabled": true }, "originalKey": "new_topic", "status": "classified", @@ -2593,11 +2593,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "T" - ] + ], + "enabled": false }, "originalKey": "rename_topic", "status": "classified", @@ -2607,11 +2607,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "]" - ] + ], + "enabled": true }, "originalKey": "toggle_show_topics", "status": "classified", @@ -2621,11 +2621,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "[" - ] + ], + "enabled": true }, "originalKey": "toggle_show_assistants", "status": "classified", @@ -2635,12 +2635,12 @@ { "category": "preferences", "defaultValue": { - "enabled": false, - "key": [ + "binding": [ "CommandOrControl", "Shift", "C" - ] + ], + "enabled": false }, "originalKey": "copy_last_message", "status": "classified", @@ -2650,12 +2650,12 @@ { "category": "preferences", "defaultValue": { - "enabled": false, - "key": [ + "binding": [ "CommandOrControl", "Shift", "E" - ] + ], + "enabled": false }, "originalKey": "edit_last_user_message", "status": "classified", @@ -2665,11 +2665,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "F" - ] + ], + "enabled": true }, "originalKey": "search_message_in_chat", "status": "classified", @@ -2679,12 +2679,12 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "Shift", "F" - ] + ], + "enabled": true }, "originalKey": "search_message", "status": "classified", @@ -2694,11 +2694,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "L" - ] + ], + "enabled": true }, "originalKey": "clear_topic", "status": "classified", @@ -2708,11 +2708,11 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "K" - ] + ], + "enabled": true }, "originalKey": "toggle_new_context", "status": "classified", @@ -2722,12 +2722,12 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "CommandOrControl", "Shift", "M" - ] + ], + "enabled": true }, "originalKey": "select_model", "status": "classified", @@ -2737,10 +2737,10 @@ { "category": "preferences", "defaultValue": { - "enabled": true, - "key": [ + "binding": [ "Escape" - ] + ], + "enabled": true }, "originalKey": "exit_fullscreen", "status": "classified", @@ -3618,4 +3618,4 @@ ] } } -} \ No newline at end of file +} From ed66528699051670b9ee6af19884652f5879957b Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 13 Apr 2026 16:07:26 +0800 Subject: [PATCH 17/37] Refactor shortcut system: remove default bindings, update tests, and improve migration handling - Removed defaultBinding from ShortcutDefinition and related tests. - Updated getDefaultShortcut to handle binding resolution without defaultBinding. - Enhanced transformShortcuts to prioritize renamed keys and skip malformed bindings. - Improved ShortcutService to handle window focus and closed events more robustly. - Added new tests for shortcut migration and validation. - Updated translations for shortcut-related messages. - Adjusted ShortcutSettings to reflect changes in editable state and error handling. --- .../shared/__tests__/shortcutUtils.test.ts | 15 ++-- packages/shared/shortcuts/definitions.ts | 22 +---- packages/shared/shortcuts/types.ts | 4 - packages/shared/shortcuts/utils.ts | 11 +-- .../v2/migrators/mappings/ShortcutMappings.ts | 18 ++++ .../__tests__/ShortcutMappings.test.ts | 90 +++++++++++++++++++ src/main/services/AppMenuService.ts | 28 +++--- src/main/services/ShortcutService.ts | 37 ++++++-- src/renderer/src/i18n/locales/en-us.json | 2 + src/renderer/src/i18n/locales/zh-cn.json | 2 + src/renderer/src/i18n/locales/zh-tw.json | 2 + src/renderer/src/i18n/translate/de-de.json | 2 + src/renderer/src/i18n/translate/el-gr.json | 2 + src/renderer/src/i18n/translate/es-es.json | 2 + src/renderer/src/i18n/translate/fr-fr.json | 2 + src/renderer/src/i18n/translate/ja-jp.json | 2 + src/renderer/src/i18n/translate/pt-pt.json | 2 + src/renderer/src/i18n/translate/ro-ro.json | 2 + src/renderer/src/i18n/translate/ru-ru.json | 2 + .../src/pages/settings/ShortcutSettings.tsx | 49 ++++++---- .../shortcuts/shortcut-system-refactor.md | 42 ++++----- 21 files changed, 240 insertions(+), 98 deletions(-) create mode 100644 src/main/data/migration/v2/migrators/mappings/__tests__/ShortcutMappings.test.ts diff --git a/packages/shared/__tests__/shortcutUtils.test.ts b/packages/shared/__tests__/shortcutUtils.test.ts index 2e364d3518e..a19b9e87a88 100644 --- a/packages/shared/__tests__/shortcutUtils.test.ts +++ b/packages/shared/__tests__/shortcutUtils.test.ts @@ -13,7 +13,6 @@ import { const makeDefinition = (overrides: Partial = {}): ShortcutDefinition => ({ key: 'shortcut.chat.clear', - defaultBinding: ['CommandOrControl', 'L'], scope: 'renderer', category: 'chat', labelKey: 'clear_topic', @@ -108,12 +107,6 @@ describe('getDefaultShortcut', () => { expect(result.binding).toEqual(['CommandOrControl', 'L']) expect(result.enabled).toBe(true) - expect(result.editable).toBe(true) - }) - - it('respects editable: false', () => { - const def = makeDefinition({ editable: false }) - expect(getDefaultShortcut(def).editable).toBe(false) }) }) @@ -183,6 +176,14 @@ describe('SHORTCUT_DEFINITIONS', () => { } }) + 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) diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index 4f424d2a78b..98f69d5fa1e 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -4,7 +4,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 应用级快捷键 ==================== { key: 'shortcut.general.show_main_window', - defaultBinding: [], scope: 'main', category: 'general', labelKey: 'show_app', @@ -12,7 +11,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.general.show_mini_window', - defaultBinding: ['CommandOrControl', 'E'], scope: 'main', category: 'general', labelKey: 'mini_window', @@ -20,22 +18,19 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.general.show_settings', - defaultBinding: ['CommandOrControl', ','], - scope: 'both', + scope: 'main', category: 'general', labelKey: 'show_settings', editable: false }, { key: 'shortcut.general.toggle_sidebar', - defaultBinding: ['CommandOrControl', '['], scope: 'renderer', category: 'general', labelKey: 'toggle_sidebar' }, { key: 'shortcut.general.exit_fullscreen', - defaultBinding: ['Escape'], scope: 'renderer', category: 'general', labelKey: 'exit_fullscreen', @@ -43,7 +38,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.general.zoom_in', - defaultBinding: ['CommandOrControl', '='], scope: 'main', category: 'general', labelKey: 'zoom_in', @@ -52,7 +46,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.general.zoom_out', - defaultBinding: ['CommandOrControl', '-'], scope: 'main', category: 'general', labelKey: 'zoom_out', @@ -61,7 +54,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.general.zoom_reset', - defaultBinding: ['CommandOrControl', '0'], scope: 'main', category: 'general', labelKey: 'zoom_reset', @@ -69,7 +61,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.general.search', - defaultBinding: ['CommandOrControl', 'Shift', 'F'], scope: 'renderer', category: 'general', labelKey: 'search_message' @@ -77,42 +68,36 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 聊天相关快捷键 ==================== { key: 'shortcut.chat.clear', - defaultBinding: ['CommandOrControl', 'L'], scope: 'renderer', category: 'chat', labelKey: 'clear_topic' }, { key: 'shortcut.chat.search_message', - defaultBinding: ['CommandOrControl', 'F'], scope: 'renderer', category: 'chat', labelKey: 'search_message_in_chat' }, { key: 'shortcut.chat.toggle_new_context', - defaultBinding: ['CommandOrControl', 'K'], scope: 'renderer', category: 'chat', labelKey: 'toggle_new_context' }, { key: 'shortcut.chat.copy_last_message', - defaultBinding: ['CommandOrControl', 'Shift', 'C'], scope: 'renderer', category: 'chat', labelKey: 'copy_last_message' }, { key: 'shortcut.chat.edit_last_user_message', - defaultBinding: ['CommandOrControl', 'Shift', 'E'], scope: 'renderer', category: 'chat', labelKey: 'edit_last_user_message' }, { key: 'shortcut.chat.select_model', - defaultBinding: ['CommandOrControl', 'Shift', 'M'], scope: 'renderer', category: 'chat', labelKey: 'select_model' @@ -120,21 +105,18 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 话题管理快捷键 ==================== { key: 'shortcut.topic.new', - defaultBinding: ['CommandOrControl', 'N'], scope: 'renderer', category: 'topic', labelKey: 'new_topic' }, { key: 'shortcut.topic.rename', - defaultBinding: ['CommandOrControl', 'T'], scope: 'renderer', category: 'topic', labelKey: 'rename_topic' }, { key: 'shortcut.topic.toggle_show_topics', - defaultBinding: ['CommandOrControl', ']'], scope: 'renderer', category: 'topic', labelKey: 'toggle_show_topics' @@ -142,7 +124,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ // ==================== 划词助手快捷键 ==================== { key: 'shortcut.feature.selection.toggle_enabled', - defaultBinding: [], scope: 'main', category: 'feature.selection', labelKey: 'selection_assistant_toggle', @@ -151,7 +132,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ }, { key: 'shortcut.feature.selection.get_text', - defaultBinding: [], scope: 'main', category: 'feature.selection', labelKey: 'selection_assistant_select_text', diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index 54b99c21558..4444dbf96b6 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -23,8 +23,6 @@ export type ShortcutKey = ShortcutPreferenceKey extends `shortcut.${infer Rest}` export interface ShortcutDefinition { /** Preference key in `shortcut.{category}.{name}` format for built-in shortcuts. Plugins use `shortcut.plugin.{pluginId}.{name}`. */ key: ShortcutPreferenceKey - /** Default key binding in Electron accelerator format (e.g. `['CommandOrControl', 'L']`). Empty array means no default binding. */ - defaultBinding: string[] /** 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`). */ @@ -47,6 +45,4 @@ export interface ResolvedShortcut { binding: string[] /** Whether this shortcut is currently enabled. */ enabled: boolean - /** Whether users can modify the binding. Injected from `ShortcutDefinition.editable`, not stored in preferences. */ - editable: boolean } diff --git a/packages/shared/shortcuts/utils.ts b/packages/shared/shortcuts/utils.ts index 032fc3e2a36..c0759b1cee3 100644 --- a/packages/shared/shortcuts/utils.ts +++ b/packages/shared/shortcuts/utils.ts @@ -107,13 +107,9 @@ const ensureBoolean = (value: unknown, fallback: boolean): boolean => (typeof va export const getDefaultShortcut = (definition: ShortcutDefinition): ResolvedShortcut => { const fallback = DefaultPreferences.default[definition.key] - const rawBinding = ensureArray(fallback?.binding) - const binding = rawBinding.length ? rawBinding : definition.defaultBinding - return { - binding, - enabled: ensureBoolean(fallback?.enabled, true), - editable: definition.editable !== false + binding: ensureArray(fallback?.binding), + enabled: ensureBoolean(fallback?.enabled, true) } } @@ -126,7 +122,6 @@ export const resolveShortcutPreference = ( return { binding, - enabled: ensureBoolean(value?.enabled, fallback.enabled), - editable: definition.editable !== false + enabled: ensureBoolean(value?.enabled, fallback.enabled) } } diff --git a/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts b/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts index 20836fa5fa4..e6f87ae02b7 100644 --- a/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts @@ -58,9 +58,15 @@ interface LegacyShortcutEntry { 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) { @@ -82,10 +88,22 @@ export const transformShortcuts: TransformFunction = (sources) => { 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__/ShortcutMappings.test.ts b/src/main/data/migration/v2/migrators/mappings/__tests__/ShortcutMappings.test.ts new file mode 100644 index 00000000000..71ee311b58b --- /dev/null +++ b/src/main/data/migration/v2/migrators/mappings/__tests__/ShortcutMappings.test.ts @@ -0,0 +1,90 @@ +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: 'show_settings', + shortcut: ['CommandOrControl', ','], + enabled: true + }, + { + key: 'selection_assistant_toggle', + shortcut: [], + enabled: false + } + ] + }) + + expect(result).toEqual({ + '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 8b1feac4103..d65c05efacd 100644 --- a/src/main/services/AppMenuService.ts +++ b/src/main/services/AppMenuService.ts @@ -16,11 +16,15 @@ const zoomShortcutKeys: ShortcutPreferenceKey[] = [ 'shortcut.general.zoom_reset' ] -const isShortcutEnabled = (key: ShortcutPreferenceKey): boolean => { +const getShortcutAccelerator = (key: ShortcutPreferenceKey): string | undefined => { const definition = findShortcutDefinition(key) - if (!definition) return true + if (!definition) return undefined const rawPref = application.get('PreferenceService').get(key) as PreferenceShortcutType | undefined - return resolveShortcutPreference(definition, rawPref).enabled + const resolved = resolveShortcutPreference(definition, rawPref) + if (!resolved.enabled || !resolved.binding.length) { + return undefined + } + return resolved.binding.join('+') } const getMainWindows = (): Electron.BrowserWindow[] => @@ -45,9 +49,9 @@ export class AppMenuService extends BaseService { const locale = locales[getAppLanguage()] const { appMenu } = locale.translation - const zoomInEnabled = isShortcutEnabled('shortcut.general.zoom_in') - const zoomOutEnabled = isShortcutEnabled('shortcut.general.zoom_out') - const zoomResetEnabled = isShortcutEnabled('shortcut.general.zoom_reset') + const zoomInAccelerator = getShortcutAccelerator('shortcut.general.zoom_in') + const zoomOutAccelerator = getShortcutAccelerator('shortcut.general.zoom_out') + const zoomResetAccelerator = getShortcutAccelerator('shortcut.general.zoom_reset') const template: MenuItemConstructorOptions[] = [ { @@ -99,20 +103,20 @@ export class AppMenuService extends BaseService { { type: 'separator' }, { label: appMenu.resetZoom, - accelerator: zoomResetEnabled ? 'CommandOrControl+0' : undefined, - enabled: zoomResetEnabled, + accelerator: zoomResetAccelerator, + enabled: !!zoomResetAccelerator, click: () => handleZoomFactor(getMainWindows(), 0, true) }, { label: appMenu.zoomIn, - accelerator: zoomInEnabled ? 'CommandOrControl+=' : undefined, - enabled: zoomInEnabled, + accelerator: zoomInAccelerator, + enabled: !!zoomInAccelerator, click: () => handleZoomFactor(getMainWindows(), 0.1) }, { label: appMenu.zoomOut, - accelerator: zoomOutEnabled ? 'CommandOrControl+-' : undefined, - enabled: zoomOutEnabled, + accelerator: zoomOutAccelerator, + enabled: !!zoomOutAccelerator, click: () => handleZoomFactor(getMainWindows(), -0.1) }, { type: 'separator' }, diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index c6b18204ad1..2ed534513d3 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -28,7 +28,7 @@ const relevantDefinitions = SHORTCUT_DEFINITIONS.filter( export class ShortcutService extends BaseService { private mainWindow: BrowserWindow | null = null private handlers = new Map() - private windowOnHandlers = new Map void; onBlur: () => void }>() + private windowOnHandlers = new Map void; onBlur: () => void; onClosed: () => void }>() private isRegisterOnBoot = true private registeredAccelerators = new Map() @@ -56,7 +56,8 @@ export class ShortcutService extends BaseService { }) this.handlers.set('shortcut.general.show_settings', () => { - let targetWindow = application.get('WindowService').getMainWindow() + const windowService = application.get('WindowService') + let targetWindow = windowService.getMainWindow() if ( !targetWindow || @@ -65,13 +66,23 @@ export class ShortcutService extends BaseService { !targetWindow.isVisible() || !targetWindow.isFocused() ) { - application.get('WindowService').showMainWindow() - targetWindow = application.get('WindowService').getMainWindow() + windowService.showMainWindow() + targetWindow = windowService.getMainWindow() } if (!targetWindow || targetWindow.isDestroyed()) return - targetWindow.webContents.send(IpcChannel.Windows_NavigateToSettings) + 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.general.show_mini_window', () => { @@ -128,9 +139,16 @@ export class ShortcutService extends BaseService { if (!this.windowOnHandlers.has(window)) { const onFocus = () => this.registerShortcuts(window, false) const onBlur = () => this.registerShortcuts(window, true) + const onClosed = () => { + this.windowOnHandlers.delete(window) + if (this.mainWindow === window) { + this.mainWindow = null + } + } window.on('focus', onFocus) window.on('blur', onBlur) - this.windowOnHandlers.set(window, { onFocus, onBlur }) + window.once('closed', onClosed) + this.windowOnHandlers.set(window, { onFocus, onBlur, onClosed }) } if (!window.isDestroyed() && window.isFocused()) { @@ -190,7 +208,11 @@ export class ShortcutService extends BaseService { try { const success = globalShortcut.register(accelerator, () => { const targetWindow = win?.isDestroyed?.() ? undefined : win - handler(targetWindow) + try { + handler(targetWindow) + } catch (error) { + logger.error(`Shortcut handler threw for accelerator: ${accelerator}`, error as Error) + } }) if (success) { this.registeredAccelerators.set(accelerator, handler) @@ -219,6 +241,7 @@ export class ShortcutService extends BaseService { this.windowOnHandlers.forEach((handlers, window) => { window.off('focus', handlers.onFocus) window.off('blur', handlers.onBlur) + window.off('closed', handlers.onClosed) }) this.windowOnHandlers.clear() for (const accelerator of this.registeredAccelerators.keys()) { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 43e18e53e3f..fe38a4d1009 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -5554,6 +5554,7 @@ "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}}\"", @@ -5571,6 +5572,7 @@ "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...", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 7026a6f97bb..00128285c5f 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -5554,6 +5554,7 @@ "shortcuts": { "action": "操作", "actions": "操作", + "bind_first_to_enable": "请先绑定快捷键,再调整启用状态", "clear_shortcut": "清除快捷键", "clear_topic": "清空消息", "conflict_with": "已被「{{name}}」使用", @@ -5571,6 +5572,7 @@ "reset_defaults_failed": "重置快捷键失败", "reset_to_default": "重置为默认", "save_failed": "保存快捷键失败", + "save_failed_with_name": "保存快捷键失败:{{name}}", "search_message": "搜索消息", "search_message_in_chat": "在当前对话中搜索消息", "search_placeholder": "搜索快捷键...", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 64c3c495f2c..4fd6e3a323c 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -5554,6 +5554,7 @@ "shortcuts": { "action": "操作", "actions": "操作", + "bind_first_to_enable": "請先綁定快捷鍵,再調整啟用狀態", "clear_shortcut": "清除快捷鍵", "clear_topic": "清除所有訊息", "conflict_with": "已被「{{name}}」使用", @@ -5571,6 +5572,7 @@ "reset_defaults_failed": "重設快捷鍵失敗", "reset_to_default": "重設為預設", "save_failed": "儲存快捷鍵失敗", + "save_failed_with_name": "儲存快捷鍵失敗:{{name}}", "search_message": "搜尋訊息", "search_message_in_chat": "在目前對話中搜尋訊息", "search_placeholder": "搜尋捷徑...", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 4d44f4714c6..a0b1be98183 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -5554,6 +5554,7 @@ "shortcuts": { "action": "Aktionen", "actions": "Aktionen", + "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", "clear_shortcut": "Shortcut löschen", "clear_topic": "Nachricht leeren", "conflict_with": "Bereits von „{{name}}“ verwendet", @@ -5571,6 +5572,7 @@ "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Auf Standard zurücksetzen", "save_failed": "[to be translated]:Failed to save shortcut", + "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", "search_message": "Nachricht suchen", "search_message_in_chat": "In aktuellem Chat suchen", "search_placeholder": "Suchverknüpfungen durchsuchen...", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index a10d813193e..c8ce2f239a4 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -5554,6 +5554,7 @@ "shortcuts": { "action": "Ενέργεια", "actions": "Λειτουργία", + "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", "clear_shortcut": "Καθαρισμός συντομού πλήκτρου", "clear_topic": "Άδειασμα μηνυμάτων", "conflict_with": "Ήδη χρησιμοποιείται από τον \"{{name}}\"", @@ -5571,6 +5572,7 @@ "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Επαναφορά στις προεπιλεγμένες", "save_failed": "[to be translated]:Failed to save shortcut", + "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", "search_message": "Αναζήτηση μηνυμάτων", "search_message_in_chat": "Αναζήτηση μηνύματος στην τρέχουσα συνομιλία", "search_placeholder": "Συντομεύσεις αναζήτησης...", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index dd427907327..aadac32719a 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -5554,6 +5554,7 @@ "shortcuts": { "action": "Acción", "actions": "operación", + "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", "clear_shortcut": "Borrar atajo", "clear_topic": "Vaciar mensaje", "conflict_with": "Ya usado por \"{{name}}\"", @@ -5571,6 +5572,7 @@ "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Restablecer a predeterminado", "save_failed": "[to be translated]:Failed to save shortcut", + "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", "search_message": "Buscar mensaje", "search_message_in_chat": "Buscar mensajes en la conversación actual", "search_placeholder": "Buscar accesos directos...", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 32945be4754..5f861d27e35 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -5554,6 +5554,7 @@ "shortcuts": { "action": "Action", "actions": "操作", + "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", "clear_shortcut": "Effacer raccourci clavier", "clear_topic": "Vider les messages", "conflict_with": "Déjà utilisé par \"{{name}}\"", @@ -5571,6 +5572,7 @@ "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Réinitialiser aux valeurs par défaut", "save_failed": "[to be translated]:Failed to save shortcut", + "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", "search_message": "Rechercher un message", "search_message_in_chat": "Rechercher un message dans la conversation actuelle", "search_placeholder": "Raccourcis de recherche...", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 7f5c4b5ecb2..1b0adaa1cd7 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -5554,6 +5554,7 @@ "shortcuts": { "action": "操作", "actions": "操作", + "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", "clear_shortcut": "ショートカットをクリア", "clear_topic": "メッセージを消去", "conflict_with": "既に「{{name}}」によって使用されています", @@ -5571,6 +5572,7 @@ "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "デフォルトにリセット", "save_failed": "[to be translated]:Failed to save shortcut", + "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", "search_message": "メッセージを検索", "search_message_in_chat": "現在のチャットでメッセージを検索", "search_placeholder": "検索ショートカット...", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index d16be5f66f8..856f200a316 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -5554,6 +5554,7 @@ "shortcuts": { "action": "Ação", "actions": "operação", + "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", "clear_shortcut": "Limpar atalho", "clear_topic": "Limpar mensagem", "conflict_with": "Já utilizado por \"{{name}}\"", @@ -5571,6 +5572,7 @@ "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Redefinir para padrão", "save_failed": "[to be translated]:Failed to save shortcut", + "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", "search_message": "Pesquisar mensagem", "search_message_in_chat": "Pesquisar mensagens nesta conversa", "search_placeholder": "Atalhos de pesquisa...", diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index 67f6d060d67..a96fabb2ce5 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -5554,6 +5554,7 @@ "shortcuts": { "action": "Acțiune", "actions": "Comandă", + "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", "clear_shortcut": "Șterge comanda rapidă", "clear_topic": "Șterge mesajele", "conflict_with": "Deja folosit de „{{name}}”", @@ -5571,6 +5572,7 @@ "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Resetează la implicit", "save_failed": "[to be translated]:Failed to save shortcut", + "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", "search_message": "Caută mesaj", "search_message_in_chat": "Caută mesaj în chat-ul curent", "search_placeholder": "Căutare comenzi rapide...", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index a500b1a822b..fffc6454398 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -5554,6 +5554,7 @@ "shortcuts": { "action": "Действие", "actions": "操作", + "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", "clear_shortcut": "Очистить сочетание клавиш", "clear_topic": "Очистить все сообщения", "conflict_with": "Уже используется \"{{name}}\"", @@ -5571,6 +5572,7 @@ "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", "reset_to_default": "Сбросить настройки по умолчанию", "save_failed": "[to be translated]:Failed to save shortcut", + "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", "search_message": "Поиск сообщения", "search_message_in_chat": "Поиск сообщения в текущем диалоге", "search_placeholder": "Поиск ярлыков...", diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index cf2118fbb6e..cc4e290039c 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -91,7 +91,7 @@ const ShortcutSettings: FC = () => { label, key: item.definition.key, enabled: item.preference.enabled, - editable: item.preference.editable, + editable: item.definition.editable !== false, displayKeys, updatePreference: item.updatePreference, defaultPreference: { @@ -140,16 +140,21 @@ const ShortcutSettings: FC = () => { const handleUpdateFailure = (record: ShortcutRecord, error: unknown) => { logger.error(`Failed to update shortcut preference: ${record.key}`, error as Error) - window.toast.error(t('settings.shortcuts.save_failed')) + window.toast.error(t('settings.shortcuts.save_failed_with_name', { name: record.label })) } - const handleResetShortcut = (record: ShortcutRecord) => { - record.updatePreference({ binding: record.defaultPreference.binding }).catch((error) => { + const handleResetShortcut = async (record: ShortcutRecord) => { + try { + await record.updatePreference({ + binding: record.defaultPreference.binding, + enabled: record.defaultPreference.enabled + }) + setEditingKey(null) + setPendingKeys([]) + setConflictLabel(null) + } catch (error) { handleUpdateFailure(record, error) - }) - setEditingKey(null) - setPendingKeys([]) - setConflictLabel(null) + } } const findDuplicateLabel = (keys: string[], currentKey: ShortcutPreferenceKey): string | null => { @@ -167,7 +172,7 @@ const ShortcutSettings: FC = () => { return null } - const handleKeyDown = (event: ReactKeyboardEvent, record: ShortcutRecord) => { + const handleKeyDown = async (event: ReactKeyboardEvent, record: ShortcutRecord) => { event.preventDefault() if (event.code === 'Escape') { @@ -208,11 +213,13 @@ const ShortcutSettings: FC = () => { } setConflictLabel(null) - record.updatePreference({ binding: keys }).catch((error) => { + try { + await record.updatePreference({ binding: keys, enabled: true }) + setEditingKey(null) + setPendingKeys([]) + } catch (error) { handleUpdateFailure(record, error) - }) - setEditingKey(null) - setPendingKeys([]) + } } const handleResetAllShortcuts = () => { @@ -256,7 +263,9 @@ const ShortcutSettings: FC = () => { 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) => handleKeyDown(event, record)} + onKeyDown={(event) => { + void handleKeyDown(event, record) + }} onBlur={(event) => { const isUndoClick = (event.relatedTarget as HTMLElement)?.closest('.shortcut-undo-icon') if (!isUndoClick) { @@ -283,7 +292,9 @@ const ShortcutSettings: FC = () => { handleResetShortcut(record)} + onClick={() => { + void handleResetShortcut(record) + }} /> )} @@ -332,7 +343,7 @@ const ShortcutSettings: FC = () => { {record.label} {renderShortcutCell(record)} - {record.displayKeys.length > 0 && ( + {record.displayKeys.length > 0 ? ( { }) }} /> + ) : ( + + + + + )} diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md index 069a28e3701..e89e746976b 100644 --- a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -1,6 +1,6 @@ # Cherry Studio 快捷键系统重构设计文档 -> 版本:v3.0(v2 Preference 架构) +> 版本:v2.0(v2 Preference 架构) > 更新日期:2026-04-03 > 分支:`refactor/shortcut` @@ -59,7 +59,7 @@ v1 快捷键系统存在以下架构缺陷: ``` ┌──────────────────────────────────────────────────────────────┐ -│ Shortcut System v3 │ +│ Shortcut System v2 │ ├──────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────────────────┐ │ @@ -67,14 +67,14 @@ v1 快捷键系统存在以下架构缺陷: │ │ packages/shared/shortcuts/ │ │ │ │ ├── types.ts 类型定义 │ │ │ │ ├── definitions.ts SHORTCUT_DEFINITIONS (真相之源) │ │ -│ │ └── utils.ts 转换 / 校验 / coerce 工具 │ │ +│ │ └── utils.ts 转换 / 校验 / 归一化工具 │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ 💾 Preference Layer │ │ │ │ packages/shared/data/preference/ │ │ -│ │ ├── preferenceSchemas.ts 默认值 (enabled + key) │ │ +│ │ ├── preferenceSchemas.ts 默认值 (enabled + binding)│ │ │ │ └── preferenceTypes.ts PreferenceShortcutType │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ │ @@ -111,7 +111,6 @@ v1 快捷键系统存在以下架构缺陷: ```typescript { key: 'shortcut.general.show_mini_window', // Preference key - defaultBinding: ['CommandOrControl', 'E'], // Electron accelerator 格式 scope: 'main', // main | renderer | both category: 'general', // 点分命名空间 UI 分组:general、chat、topic、plugin.xxx 等 labelKey: 'mini_window', // i18n label key @@ -126,7 +125,6 @@ v1 快捷键系统存在以下架构缺陷: | 字段 | 用途 | |------|------| | `key` | Preference key,内置快捷键用 `shortcut.{category}.{name}` 格式,插件用 `shortcut.plugin.{pluginId}.{name}` | -| `defaultBinding` | Electron accelerator 格式的默认绑定,空数组表示无默认绑定 | | `scope` | 决定快捷键注册在哪个进程:`main`(globalShortcut)、`renderer`(react-hotkeys-hook)、`both`(两者都注册) | | `category` | 点分命名空间 UI 分组(如 `general`、`chat`、`topic`、`plugin.translator`),类型为 `ShortcutCategory` 以支持插件扩展 | | `labelKey` | i18n label key,由 `getShortcutLabel()` 消费 | @@ -148,7 +146,6 @@ type ShortcutKey = ShortcutPreferenceKey extends `shortcut.${infer Rest}` ? Rest interface ResolvedShortcut { binding: string[] // 生效的绑定(用户自定义、默认值或空数组——显式清空) enabled: boolean // 是否启用 - editable: boolean // 来自 definition.editable,不存储在偏好中 } ``` @@ -180,10 +177,9 @@ useShortcutDisplay('chat.clear') 输入值为 null/undefined → 使用 schema 默认值 输入的 binding 为空数组 → binding 为空(用户显式清空) 输入的 enabled 非布尔 → 使用默认 enabled -editable → 始终从 definition 读取(不存储在偏好中) ``` -**设计决策**:禁用快捷键可以使用 `enabled: false`,也可以清空绑定(`binding: []`)。想换键就录制覆盖,想重置就写回 `defaultBinding`。 +**设计决策**:禁用快捷键可以使用 `enabled: false`,也可以清空绑定(`binding: []`)。想换键就录制覆盖,想重置就写回 schema 默认值。 ### 2. 偏好层 (`preferenceSchemas.ts` + `preferenceTypes.ts`) @@ -316,12 +312,12 @@ const display = useShortcutDisplay('chat.clear') |------|------| | **平台过滤** | 根据 `supportedPlatforms` 过滤不支持的快捷键 | | **快捷键录制** | `handleKeyDown` 捕获键盘事件 → `convertKeyToAccelerator` → `isValidShortcut` 校验 | -| **冲突检测** | `isDuplicateShortcut` 检查已显示快捷键中是否存在相同绑定 | +| **冲突检测** | `findDuplicateLabel` 检查已启用快捷键中是否存在相同绑定 | | **清空绑定** | `updatePreference({ binding: [] })` | | **重置单项** | 写入 `defaultPreference` 的 `binding` + `enabled` | | **重置全部** | `preferenceService.setMultiple()` 批量写入所有默认值 | | **启用/禁用** | `updatePreference({ enabled: !current })` | -| **修改标记** | `isShortcutModified` 比对当前值与默认值,决定重置按钮是否可用 | +| **修改标记** | `isBindingModified` 比对当前值与默认值,决定重置按钮是否可用 | --- @@ -374,7 +370,7 @@ ShortcutService.onInit() ``` 用户在设置页按下新快捷键 ↓ handleKeyDown -convertKeyToAccelerator() + isValidShortcut() + isDuplicateShortcut() +convertKeyToAccelerator() + isValidShortcut() + findDuplicateLabel() ↓ 通过校验 updatePreference({ binding: newKeys }) ↓ useMultiplePreferences.setValues() @@ -410,18 +406,18 @@ toggleMiniWindow() ## 默认快捷键一览 -### 应用级 (`app`) +### 应用级 (`general`) | Preference Key | 默认绑定 | 作用域 | 备注 | |---|---|---|---| | `shortcut.general.show_main_window` | *(无)* | main | 失焦持久 | -| `shortcut.general.show_mini_window` | `Cmd/Ctrl+E` | main | 关联 quick_assistant 开关 | -| `shortcut.general.show_settings` | `Cmd/Ctrl+,` | both | 不可编辑 | +| `shortcut.general.show_mini_window` | `Cmd/Ctrl+E` | main | 默认禁用,关联 quick_assistant 开关 | +| `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.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`) @@ -443,7 +439,7 @@ toggleMiniWindow() | `shortcut.topic.rename` | `Cmd/Ctrl+T` | 否 | | `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl+]` | 是 | -### 划词助手 (`selection`) +### 划词助手 (`feature.selection`) | Preference Key | 默认绑定 | 支持平台 | |---|---|---| @@ -471,7 +467,6 @@ toggleMiniWindow() // packages/shared/shortcuts/definitions.ts { key: 'shortcut.chat.regenerate', - defaultBinding: ['CommandOrControl', 'Shift', 'R'], scope: 'renderer', category: 'chat' } @@ -512,7 +507,7 @@ this.handlers.set('shortcut.general.show_mini_window', () => { ## 迁移清单 -### 已移除的旧组件 +### 旧组件处理状态 | 旧组件 | 状态 | |--------|------| @@ -532,7 +527,7 @@ this.handlers.set('shortcut.general.show_mini_window', () => { ## 测试覆盖 -### 单元测试 (`packages/shared/__tests__/shortcutUtils.test.ts`) +### 单元测试 覆盖 `utils.ts` 中所有导出函数,共 19 个测试用例: @@ -542,8 +537,9 @@ this.handlers.set('shortcut.general.show_mini_window', () => { | `convertAcceleratorToHotkey` | 修饰键转换(CommandOrControl→mod, Ctrl→ctrl 等) | | `formatShortcutDisplay` | Mac 符号格式(⌘⇧⌥⌃)、非 Mac 文字格式 | | `isValidShortcut` | 空数组、含修饰键、特殊单键、普通单键 | -| `getDefaultShortcut` | 默认值读取、`editable` 继承 | +| `getDefaultShortcut` | 默认值读取 | | `resolveShortcutPreference` | null/undefined 回退、自定义 binding、空数组回退、enabled 覆盖 | +| `ShortcutMappings` | 旧 key 重命名优先级、畸形 binding 跳过、非数组源兜底 | --- From a529579e7c665d646eb1e7cd54a4e1f0c85c4980 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 13 Apr 2026 19:19:20 +0800 Subject: [PATCH 18/37] refactor(shortcuts): enhance mini window shortcut handling and improve settings UI --- .../ComplexPreferenceMappings.test.ts | 2 - src/main/services/ShortcutService.ts | 12 ++++ src/renderer/src/hooks/useShortcuts.ts | 11 +++- .../src/pages/settings/ShortcutSettings.tsx | 60 ++++++++++++------- 4 files changed, 59 insertions(+), 26 deletions(-) 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 ab45812b0eb..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 @@ -105,8 +105,6 @@ describe('ComplexPreferenceMappings', () => { expect(keys).toContain('feature.code_cli.overrides') expect(keys).toContain('feature.file_processing.overrides') expect(keys).toContain('shortcut.general.zoom_in') - // 7 websearch compression + 1 provider overrides + 1 code_cli overrides - // + 1 file processing overrides + 20 shortcut keys expect(keys.length).toBe(30) }) diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 310b7127d31..4da83e1d199 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -27,6 +27,7 @@ import type { BrowserWindow } from 'electron' import { globalShortcut } from 'electron' const logger = loggerService.withContext('ShortcutService') +const MINI_WINDOW_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.general.show_mini_window' type ShortcutHandler = (window?: BrowserWindow) => void @@ -137,6 +138,13 @@ export class ShortcutService extends BaseService { }) ) } + + this.registerDisposable( + preferenceService.subscribeChange('feature.quick_assistant.enabled', () => { + logger.debug('Shortcut dependency changed: feature.quick_assistant.enabled') + this.reregisterShortcuts() + }) + ) } private registerForWindow(window: BrowserWindow): void { @@ -183,6 +191,10 @@ export class ShortcutService extends BaseService { for (const definition of relevantDefinitions) { if (onlyPersistent && !definition.global) continue + if (definition.key === MINI_WINDOW_SHORTCUT_KEY && !preferenceService.get('feature.quick_assistant.enabled')) { + continue + } + const rawPref = preferenceService.get(definition.key) as PreferenceShortcutType | undefined const pref = resolveShortcutPreference(definition, rawPref) if (!pref.enabled || !pref.binding.length) continue diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index ebb9200e5ee..f12dc822192 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -50,12 +50,17 @@ export const useShortcut = ( 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' } @@ -69,7 +74,7 @@ export const useShortcut = ( } return convertAcceleratorToHotkey(resolved.binding) - }, [definition, resolved]) + }, [definition, isExternallyEnabled, resolved]) useHotkeys( hotkey, @@ -84,10 +89,10 @@ export const useShortcut = ( { enableOnFormTags: optionsRef.current.enableOnFormTags, description: optionsRef.current.description ?? fullKey, - enabled: hotkey !== 'none', + enabled: isExternallyEnabled && hotkey !== 'none', enableOnContentEditable: optionsRef.current.enableOnContentEditable }, - [hotkey] + [hotkey, isExternallyEnabled] ) } diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index cc4e290039c..dd9f7c721f8 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -1,5 +1,6 @@ import { UndoOutlined } from '@ant-design/icons' import { Button, Input, RowFlex, Switch, Tooltip } from '@cherrystudio/ui' +import { usePreference } from '@data/hooks/usePreference' import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { isMac, platform } from '@renderer/config/constant' @@ -22,6 +23,7 @@ import { useTranslation } from 'react-i18next' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' const logger = loggerService.withContext('ShortcutSettings') +const MINI_WINDOW_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.general.show_mini_window' type ShortcutRecord = { id: string @@ -65,6 +67,7 @@ const ShortcutSettings: FC = () => { const { t } = useTranslation() const { theme } = useTheme() const shortcuts = useAllShortcuts() + const [quickAssistantEnabled] = usePreference('feature.quick_assistant.enabled') const inputRefs = useRef>({}) const [editingKey, setEditingKey] = useState(null) const [pendingKeys, setPendingKeys] = useState([]) @@ -249,6 +252,7 @@ const ShortcutSettings: FC = () => { const renderShortcutCell = (record: ShortcutRecord) => { const isEditing = editingKey === record.id const displayShortcut = record.displayKeys.length > 0 ? formatShortcutDisplay(record.displayKeys, isMac) : '' + const isMiniWindowShortcutDisabled = record.key === MINI_WINDOW_SHORTCUT_KEY && !quickAssistantEnabled if (isEditing) { const pendingDisplay = pendingKeys.length > 0 ? formatShortcutDisplay(pendingKeys, isMac) : '' @@ -299,8 +303,8 @@ const ShortcutSettings: FC = () => { )} record.editable && handleAddShortcut(record)}> + className={`items-center gap-1 rounded-lg bg-white/5 px-2 py-1 ${record.editable && !isMiniWindowShortcutDisabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'}`} + onClick={() => record.editable && !isMiniWindowShortcutDisabled && handleAddShortcut(record)}> {record.displayKeys.map((key) => ( { return ( record.editable && handleAddShortcut(record)}> + className={`rounded-lg bg-white/5 px-3 py-1 text-sm text-white/30 ${record.editable && !isMiniWindowShortcutDisabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'}`} + onClick={() => record.editable && !isMiniWindowShortcutDisabled && handleAddShortcut(record)}> {t('settings.shortcuts.press_shortcut')} ) @@ -336,17 +340,15 @@ const ShortcutSettings: FC = () => { />
- {filteredShortcuts.map((record, index) => ( -
- {record.label} - - {renderShortcutCell(record)} - {record.displayKeys.length > 0 ? ( + {filteredShortcuts.map((record, index) => + (() => { + const isMiniWindowShortcutDisabled = record.key === MINI_WINDOW_SHORTCUT_KEY && !quickAssistantEnabled + const switchNode = + record.displayKeys.length > 0 ? ( { record.updatePreference({ enabled: !record.enabled }).catch((error) => { handleUpdateFailure(record, error) @@ -354,15 +356,31 @@ const ShortcutSettings: FC = () => { }} /> ) : ( - - - - - - )} - -
- ))} + + ) + + let switchContent = switchNode + if (isMiniWindowShortcutDisabled) { + switchContent = ( + {switchNode} + ) + } else if (!record.displayKeys.length) { + switchContent = {switchNode} + } + + return ( +
+ {record.label} + + {renderShortcutCell(record)} + {switchContent} + +
+ ) + })() + )}
From 1461597aa2298d471ec1ed6a3555d14b4474869f Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 09:51:25 +0800 Subject: [PATCH 19/37] refactor(shortcuts): tighten shared shortcut metadata Signed-off-by: kangfenmao --- .../__tests__/shortcutUtils.test.ts | 6 +-- packages/shared/shortcuts/types.ts | 24 ++++++++++- src/renderer/src/i18n/label.ts | 41 ++++++++----------- .../src/pages/settings/ShortcutSettings.tsx | 15 +++---- 4 files changed, 50 insertions(+), 36 deletions(-) rename packages/shared/{ => shortcuts}/__tests__/shortcutUtils.test.ts (98%) diff --git a/packages/shared/__tests__/shortcutUtils.test.ts b/packages/shared/shortcuts/__tests__/shortcutUtils.test.ts similarity index 98% rename from packages/shared/__tests__/shortcutUtils.test.ts rename to packages/shared/shortcuts/__tests__/shortcutUtils.test.ts index a19b9e87a88..af8b2a2fb6d 100644 --- a/packages/shared/__tests__/shortcutUtils.test.ts +++ b/packages/shared/shortcuts/__tests__/shortcutUtils.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '../shortcuts/definitions' -import type { ShortcutDefinition } from '../shortcuts/types' +import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '../definitions' +import type { ShortcutDefinition } from '../types' import { convertAcceleratorToHotkey, convertKeyToAccelerator, @@ -9,7 +9,7 @@ import { getDefaultShortcut, isValidShortcut, resolveShortcutPreference -} from '../shortcuts/utils' +} from '../utils' const makeDefinition = (overrides: Partial = {}): ShortcutDefinition => ({ key: 'shortcut.chat.clear', diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index 4444dbf96b6..bbdc7ab5a7e 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -19,6 +19,28 @@ export type ShortcutPreferenceKey = Extract { return getLabel(sidebarIconKeyMap, key) } -const shortcutKeyMap = { - action: 'settings.shortcuts.action', - actions: 'settings.shortcuts.actions', - clear_shortcut: 'settings.shortcuts.clear_shortcut', +const shortcutLabelKeyMap: Record = { 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_sidebar: 'settings.shortcuts.toggle_sidebar', - toggle_show_topics: 'settings.shortcuts.toggle_show_topics', + exit_fullscreen: 'settings.shortcuts.exit_fullscreen', zoom_in: 'settings.shortcuts.zoom_in', zoom_out: 'settings.shortcuts.zoom_out', - zoom_reset: 'settings.shortcuts.zoom_reset' + zoom_reset: 'settings.shortcuts.zoom_reset', + search_message: 'settings.shortcuts.search_message', + search_message_in_chat: 'settings.shortcuts.search_message_in_chat', + toggle_new_context: 'settings.shortcuts.toggle_new_context', + edit_last_user_message: 'settings.shortcuts.edit_last_user_message', + select_model: 'settings.shortcuts.select_model', + new_topic: 'settings.shortcuts.new_topic', + mini_window: 'settings.shortcuts.mini_window', + selection_assistant_toggle: 'settings.shortcuts.selection_assistant_toggle', + selection_assistant_select_text: 'settings.shortcuts.selection_assistant_select_text', + toggle_show_topics: 'settings.shortcuts.toggle_show_topics' } as const -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/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index dd9f7c721f8..f4bc7f84761 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -9,7 +9,7 @@ import { useAllShortcuts } from '@renderer/hooks/useShortcuts' import { useTimer } from '@renderer/hooks/useTimer' import { getShortcutLabel } from '@renderer/i18n/label' import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' -import type { ShortcutPreferenceKey, SupportedPlatform } from '@shared/shortcuts/types' +import type { ShortcutDefinition, ShortcutPreferenceKey, SupportedPlatform } from '@shared/shortcuts/types' import { convertKeyToAccelerator, formatKeyDisplay, @@ -27,10 +27,10 @@ const MINI_WINDOW_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.general.show_m type ShortcutRecord = { id: string + definition: ShortcutDefinition label: string key: ShortcutPreferenceKey enabled: boolean - editable: boolean displayKeys: string[] updatePreference: (patch: Partial) => Promise defaultPreference: { @@ -91,10 +91,10 @@ const ShortcutSettings: FC = () => { return { id: item.definition.key, + definition: item.definition, label, key: item.definition.key, enabled: item.preference.enabled, - editable: item.definition.editable !== false, displayKeys, updatePreference: item.updatePreference, defaultPreference: { @@ -253,6 +253,7 @@ const ShortcutSettings: FC = () => { const isEditing = editingKey === record.id const displayShortcut = record.displayKeys.length > 0 ? formatShortcutDisplay(record.displayKeys, isMac) : '' const isMiniWindowShortcutDisabled = record.key === MINI_WINDOW_SHORTCUT_KEY && !quickAssistantEnabled + const isEditable = record.definition.editable !== false if (isEditing) { const pendingDisplay = pendingKeys.length > 0 ? formatShortcutDisplay(pendingKeys, isMac) : '' @@ -303,8 +304,8 @@ const ShortcutSettings: FC = () => { )} record.editable && !isMiniWindowShortcutDisabled && handleAddShortcut(record)}> + className={`items-center gap-1 rounded-lg bg-white/5 px-2 py-1 ${isEditable && !isMiniWindowShortcutDisabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'}`} + onClick={() => isEditable && !isMiniWindowShortcutDisabled && handleAddShortcut(record)}> {record.displayKeys.map((key) => ( { return ( record.editable && !isMiniWindowShortcutDisabled && handleAddShortcut(record)}> + className={`rounded-lg bg-white/5 px-3 py-1 text-sm text-white/30 ${isEditable && !isMiniWindowShortcutDisabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'}`} + onClick={() => isEditable && !isMiniWindowShortcutDisabled && handleAddShortcut(record)}> {t('settings.shortcuts.press_shortcut')} ) From 9ab7653fbcdd8b6d497eb9678778feff9eb4d2ca Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 10:10:26 +0800 Subject: [PATCH 20/37] test(shortcuts): cover shortcut service registration Signed-off-by: kangfenmao --- .../__tests__/ShortcutService.test.ts | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/main/services/__tests__/ShortcutService.test.ts diff --git a/src/main/services/__tests__/ShortcutService.test.ts b/src/main/services/__tests__/ShortcutService.test.ts new file mode 100644 index 00000000000..df84cccb3c3 --- /dev/null +++ b/src/main/services/__tests__/ShortcutService.test.ts @@ -0,0 +1,185 @@ +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 { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceService' + +import { ShortcutService } from '../ShortcutService' + +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.general.show_mini_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)) + }) +}) From 69b2708a940828b9d2ce9196cbd31f6fcb54a683 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 10:31:51 +0800 Subject: [PATCH 21/37] refactor(shortcuts): streamline shortcut handling and improve settings UI --- src/main/services/ShortcutService.ts | 27 +-- .../__tests__/ShortcutService.test.ts | 23 ++ src/renderer/src/hooks/useShortcuts.ts | 100 ++++----- .../src/pages/settings/ShortcutSettings.tsx | 206 ++++++++---------- 4 files changed, 166 insertions(+), 190 deletions(-) diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 4da83e1d199..33b48cb2d0a 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,19 +1,3 @@ -/** - * @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' @@ -30,6 +14,7 @@ const logger = loggerService.withContext('ShortcutService') const MINI_WINDOW_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.general.show_mini_window' type ShortcutHandler = (window?: BrowserWindow) => void +type RegisteredShortcut = { handler: ShortcutHandler; window: BrowserWindow } const toAccelerator = (keys: string[]): string => keys.join('+') @@ -47,7 +32,7 @@ export class ShortcutService extends BaseService { private handlers = new Map() private windowOnHandlers = new Map void; onBlur: () => void; onClosed: () => void }>() private isRegisterOnBoot = true - private registeredAccelerators = new Map() + private registeredAccelerators = new Map() protected async onInit() { this.registerBuiltInHandlers() @@ -186,7 +171,7 @@ export class ShortcutService extends BaseService { const preferenceService = application.get('PreferenceService') // Build the desired set of accelerators - const desired = new Map() + const desired = new Map() for (const definition of relevantDefinitions) { if (onlyPersistent && !definition.global) continue @@ -218,9 +203,9 @@ export class ShortcutService extends BaseService { } // Unregister shortcuts that are no longer needed or have a different handler - for (const [accelerator, prevHandler] of this.registeredAccelerators) { + for (const [accelerator, previous] of this.registeredAccelerators) { const entry = desired.get(accelerator) - if (!entry || entry.handler !== prevHandler) { + if (!entry || entry.handler !== previous.handler || entry.window !== previous.window) { try { globalShortcut.unregister(accelerator) } catch (error) { @@ -243,7 +228,7 @@ export class ShortcutService extends BaseService { } }) if (success) { - this.registeredAccelerators.set(accelerator, handler) + this.registeredAccelerators.set(accelerator, { handler, window: win }) } else { logger.warn(`Failed to register shortcut ${accelerator}: accelerator is held by another application`) } diff --git a/src/main/services/__tests__/ShortcutService.test.ts b/src/main/services/__tests__/ShortcutService.test.ts index df84cccb3c3..c8b14261fa6 100644 --- a/src/main/services/__tests__/ShortcutService.test.ts +++ b/src/main/services/__tests__/ShortcutService.test.ts @@ -69,6 +69,7 @@ vi.mock('electron', () => ({ globalShortcut: globalShortcutMock })) +import { handleZoomFactor } from '@main/utils/zoom' import { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceService' import { ShortcutService } from '../ShortcutService' @@ -182,4 +183,26 @@ describe('ShortcutService', () => { expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+E', 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) + }) }) diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index f12dc822192..5d660e44f42 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -32,18 +32,45 @@ const isFullKey = (key: string): key is ShortcutPreferenceKey => key.startsWith( 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 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: ShortcutKey | ShortcutPreferenceKey, callback: (event: KeyboardEvent) => void, options: UseShortcutOptions = defaultOptions ) => { const fullKey = toFullKey(shortcutKey) - const definition = useMemo(() => findShortcutDefinition(fullKey), [fullKey]) + const definition = findShortcutDefinition(fullKey) const [preference] = usePreference(fullKey) - const resolved = useMemo( - () => (definition ? resolveShortcutPreference(definition, preference) : null), - [definition, preference] - ) + const resolved = definition ? resolveShortcutPreference(definition, preference) : null const callbackRef = useRef(callback) callbackRef.current = callback @@ -98,12 +125,9 @@ export const useShortcut = ( export const useShortcutDisplay = (shortcutKey: ShortcutKey | ShortcutPreferenceKey): string => { const fullKey = toFullKey(shortcutKey) - const definition = useMemo(() => findShortcutDefinition(fullKey), [fullKey]) + const definition = findShortcutDefinition(fullKey) const [preference] = usePreference(fullKey) - const resolved = useMemo( - () => (definition ? resolveShortcutPreference(definition, preference) : null), - [definition, preference] - ) + const resolved = definition ? resolveShortcutPreference(definition, preference) : null return useMemo(() => { if (!definition || !resolved || !resolved.enabled || !resolved.binding.length) { @@ -122,44 +146,15 @@ export interface ShortcutListItem { } export const useAllShortcuts = (): ShortcutListItem[] => { - const keyMap = useMemo( - () => - SHORTCUT_DEFINITIONS.reduce>((acc, definition) => { - acc[definition.key] = definition.key - return acc - }, {}), - [] - ) + const [values, setValues] = useMultiplePreferences(shortcutPreferenceKeyMap) - const [values, setValues] = useMultiplePreferences(keyMap) - - const buildNextPreference = useCallback( - ( - state: ResolvedShortcut, - currentValue: PreferenceShortcutType | undefined, - patch: Partial - ): PreferenceShortcutType => { - const current = (currentValue ?? {}) as PreferenceShortcutType - - const nextBinding = Array.isArray(patch.binding) - ? patch.binding - : Array.isArray(current.binding) - ? current.binding - : state.binding - - const nextEnabled = - typeof patch.enabled === 'boolean' - ? patch.enabled - : typeof current.enabled === 'boolean' - ? current.enabled - : state.enabled - - return { - binding: nextBinding, - enabled: nextEnabled - } + const updatePreference = useCallback( + async (definition: ShortcutDefinition, state: ResolvedShortcut, patch: Partial) => { + const currentValue = values[definition.key] as PreferenceShortcutType | undefined + const nextValue = buildNextPreference(state, currentValue, patch) + await setValues({ [definition.key]: nextValue } as Partial>) }, - [] + [setValues, values] ) return useMemo( @@ -167,21 +162,14 @@ export const useAllShortcuts = (): ShortcutListItem[] => { SHORTCUT_DEFINITIONS.map((definition) => { const rawValue = values[definition.key] as PreferenceShortcutType | undefined const preference = resolveShortcutPreference(definition, rawValue) - const defaultPreference = getDefaultShortcut(definition) - - const updatePreference = async (patch: Partial) => { - const currentValue = values[definition.key] as PreferenceShortcutType | undefined - const nextValue = buildNextPreference(preference, currentValue, patch) - await setValues({ [definition.key]: nextValue } as Partial>) - } return { definition, preference, - defaultPreference, - updatePreference + defaultPreference: getDefaultShortcut(definition), + updatePreference: (patch: Partial) => updatePreference(definition, preference, patch) } }), - [buildNextPreference, setValues, values] + [updatePreference, values] ) } diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index f4bc7f84761..ae8db909c00 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -24,9 +24,9 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' const logger = loggerService.withContext('ShortcutSettings') const MINI_WINDOW_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.general.show_mini_window' +const SELECTION_SHORTCUT_CATEGORY = 'feature.selection' type ShortcutRecord = { - id: string definition: ShortcutDefinition label: string key: ShortcutPreferenceKey @@ -68,6 +68,7 @@ const ShortcutSettings: FC = () => { const { theme } = useTheme() const shortcuts = useAllShortcuts() const [quickAssistantEnabled] = usePreference('feature.quick_assistant.enabled') + const [selectionAssistantEnabled] = usePreference('feature.selection.enabled') const inputRefs = useRef>({}) const [editingKey, setEditingKey] = useState(null) const [pendingKeys, setPendingKeys] = useState([]) @@ -75,63 +76,68 @@ const ShortcutSettings: FC = () => { const [searchQuery, setSearchQuery] = useState('') const { setTimeoutTimer, clearTimeoutTimer } = useTimer() - const displayedShortcuts = useMemo(() => { - const filtered = shortcuts.filter((item) => { + const visibleShortcuts = useMemo(() => { + const query = searchQuery.toLowerCase() + return shortcuts.flatMap((item) => { const supported = item.definition.supportedPlatforms if (supported && platform && !supported.includes(platform as SupportedPlatform)) { - return false + return [] + } + if (item.definition.key === MINI_WINDOW_SHORTCUT_KEY && !quickAssistantEnabled) { + return [] + } + if (item.definition.category === SELECTION_SHORTCUT_CATEGORY && !selectionAssistantEnabled) { + return [] } - return true - }) - - return filtered.map((item) => { - const label = getShortcutLabel(item.definition.labelKey) - - const displayKeys = item.preference.binding - return { - id: item.definition.key, + const record = { definition: item.definition, - label, + label: getShortcutLabel(item.definition.labelKey), key: item.definition.key, enabled: item.preference.enabled, - displayKeys, + displayKeys: item.preference.binding, updatePreference: item.updatePreference, - defaultPreference: { - binding: item.defaultPreference.binding, - enabled: item.defaultPreference.enabled - } + defaultPreference: item.defaultPreference } - }) - }, [shortcuts]) - const filteredShortcuts = useMemo(() => { - if (!searchQuery.trim()) { - return displayedShortcuts - } - const query = searchQuery.toLowerCase() - return displayedShortcuts.filter((record) => { - if (record.label.toLowerCase().includes(query)) { - return true + if (!query) { + return [record] } - if (record.displayKeys.length > 0) { - const display = formatShortcutDisplay(record.displayKeys, isMac).toLowerCase() - if (display.includes(query)) { - return true - } - } - return false + + const display = + record.displayKeys.length > 0 ? formatShortcutDisplay(record.displayKeys, isMac).toLowerCase() : '' + return record.label.toLowerCase().includes(query) || display.includes(query) ? [record] : [] }) - }, [displayedShortcuts, searchQuery]) + }, [quickAssistantEnabled, searchQuery, selectionAssistantEnabled, shortcuts]) - const handleAddShortcut = (record: ShortcutRecord) => { - setEditingKey(record.id) + const duplicateBindingLabels = useMemo(() => { + const lookup = new Map() + + 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.definition.key, + label: getShortcutLabel(shortcut.definition.labelKey) + }) + } + + return lookup + }, [shortcuts]) + + const clearEditingState = () => { + clearTimeoutTimer('conflict-clear') + setEditingKey(null) setPendingKeys([]) setConflictLabel(null) + } + + const handleAddShortcut = (record: ShortcutRecord) => { + clearEditingState() + setEditingKey(record.key) setTimeoutTimer( - `focus-${record.id}`, + `focus-${record.key}`, () => { - inputRefs.current[record.id]?.focus() + inputRefs.current[record.key]?.focus() }, 0 ) @@ -152,36 +158,22 @@ const ShortcutSettings: FC = () => { binding: record.defaultPreference.binding, enabled: record.defaultPreference.enabled }) - setEditingKey(null) - setPendingKeys([]) - setConflictLabel(null) + clearEditingState() } catch (error) { handleUpdateFailure(record, error) } } const findDuplicateLabel = (keys: string[], currentKey: ShortcutPreferenceKey): string | null => { - const normalized = keys.map((key) => key.toLowerCase()).join('+') - - for (const shortcut of shortcuts) { - if (shortcut.definition.key === currentKey) continue - if (!shortcut.preference.enabled) continue - const binding = shortcut.preference.binding - if (!binding.length) continue - if (binding.map((key) => key.toLowerCase()).join('+') === normalized) { - return getShortcutLabel(shortcut.definition.labelKey) - } - } - return null + const duplicate = duplicateBindingLabels.get(keys.map((key) => key.toLowerCase()).join('+')) + return duplicate && duplicate.key !== currentKey ? duplicate.label : null } const handleKeyDown = async (event: ReactKeyboardEvent, record: ShortcutRecord) => { event.preventDefault() if (event.code === 'Escape') { - setEditingKey(null) - setPendingKeys([]) - setConflictLabel(null) + clearEditingState() return } @@ -218,8 +210,7 @@ const ShortcutSettings: FC = () => { setConflictLabel(null) try { await record.updatePreference({ binding: keys, enabled: true }) - setEditingKey(null) - setPendingKeys([]) + clearEditingState() } catch (error) { handleUpdateFailure(record, error) } @@ -250,9 +241,8 @@ const ShortcutSettings: FC = () => { } const renderShortcutCell = (record: ShortcutRecord) => { - const isEditing = editingKey === record.id + const isEditing = editingKey === record.key const displayShortcut = record.displayKeys.length > 0 ? formatShortcutDisplay(record.displayKeys, isMac) : '' - const isMiniWindowShortcutDisabled = record.key === MINI_WINDOW_SHORTCUT_KEY && !quickAssistantEnabled const isEditable = record.definition.editable !== false if (isEditing) { @@ -263,7 +253,7 @@ const ShortcutSettings: FC = () => {
{ - if (el) inputRefs.current[record.id] = el + 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} @@ -274,10 +264,7 @@ const ShortcutSettings: FC = () => { onBlur={(event) => { const isUndoClick = (event.relatedTarget as HTMLElement)?.closest('.shortcut-undo-icon') if (!isUndoClick) { - clearTimeoutTimer('conflict-clear') - setEditingKey(null) - setPendingKeys([]) - setConflictLabel(null) + clearEditingState() } }} /> @@ -304,8 +291,8 @@ const ShortcutSettings: FC = () => { )} isEditable && !isMiniWindowShortcutDisabled && handleAddShortcut(record)}> + className={`items-center gap-1 rounded-lg bg-white/5 px-2 py-1 ${isEditable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'}`} + onClick={() => isEditable && handleAddShortcut(record)}> {record.displayKeys.map((key) => ( { return ( isEditable && !isMiniWindowShortcutDisabled && handleAddShortcut(record)}> + className={`rounded-lg bg-white/5 px-3 py-1 text-sm text-white/30 ${isEditable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'}`} + onClick={() => isEditable && handleAddShortcut(record)}> {t('settings.shortcuts.press_shortcut')} ) } + const renderShortcutRow = (record: ShortcutRecord, isLast: boolean) => { + const switchNode = ( + { + record.updatePreference({ enabled: !record.enabled }).catch((error) => { + handleUpdateFailure(record, error) + }) + }} + /> + ) + + const switchContent = !record.displayKeys.length ? ( + {switchNode} + ) : ( + switchNode + ) + + return ( +
+ {record.label} + +
{renderShortcutCell(record)}
+ {switchContent} +
+
+ ) + } + return ( @@ -341,47 +361,7 @@ const ShortcutSettings: FC = () => { />
- {filteredShortcuts.map((record, index) => - (() => { - const isMiniWindowShortcutDisabled = record.key === MINI_WINDOW_SHORTCUT_KEY && !quickAssistantEnabled - const switchNode = - record.displayKeys.length > 0 ? ( - { - record.updatePreference({ enabled: !record.enabled }).catch((error) => { - handleUpdateFailure(record, error) - }) - }} - /> - ) : ( - - ) - - let switchContent = switchNode - if (isMiniWindowShortcutDisabled) { - switchContent = ( - {switchNode} - ) - } else if (!record.displayKeys.length) { - switchContent = {switchNode} - } - - return ( -
- {record.label} - - {renderShortcutCell(record)} - {switchContent} - -
- ) - })() - )} + {visibleShortcuts.map((record, index) => renderShortcutRow(record, index === visibleShortcuts.length - 1))}
From e55955b9b3e829dffc6e382577dd9e84f8247fbe Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 10:44:34 +0800 Subject: [PATCH 22/37] refactor(shortcuts): update shortcut enabling logic and enhance settings layout --- .../data/preference/preferenceSchemas.ts | 2 +- .../src/pages/settings/ShortcutSettings.tsx | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/shared/data/preference/preferenceSchemas.ts b/packages/shared/data/preference/preferenceSchemas.ts index 3a092b7149d..7ee78d8061e 100644 --- a/packages/shared/data/preference/preferenceSchemas.ts +++ b/packages/shared/data/preference/preferenceSchemas.ts @@ -731,7 +731,7 @@ export const DefaultPreferences: PreferenceSchemas = { '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: true }, + 'shortcut.general.show_main_window': { binding: [], enabled: false }, 'shortcut.general.show_mini_window': { binding: ['CommandOrControl', 'E'], enabled: false }, 'shortcut.general.show_settings': { binding: ['CommandOrControl', ','], enabled: true }, 'shortcut.general.toggle_sidebar': { binding: ['CommandOrControl', '['], enabled: true }, diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index ae8db909c00..1ac2b54477a 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -94,7 +94,7 @@ const ShortcutSettings: FC = () => { definition: item.definition, label: getShortcutLabel(item.definition.labelKey), key: item.definition.key, - enabled: item.preference.enabled, + enabled: item.preference.enabled && item.preference.binding.length > 0, displayKeys: item.preference.binding, updatePreference: item.updatePreference, defaultPreference: item.defaultPreference @@ -328,21 +328,21 @@ const ShortcutSettings: FC = () => { /> ) - const switchContent = !record.displayKeys.length ? ( - {switchNode} - ) : ( - switchNode - ) - return (
+ className={`grid grid-cols-[minmax(0,1fr)_14rem_2.5rem] items-center gap-3 py-3.5 ${isLast ? '' : 'border-white/10 border-b'}`}> {record.label} - -
{renderShortcutCell(record)}
- {switchContent} -
+
{renderShortcutCell(record)}
+ + {!record.displayKeys.length ? ( + + {switchNode} + + ) : ( + {switchNode} + )} +
) } From 6245e3f372207df214c8cc1ac325b2d82445d8dc Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 10:50:06 +0800 Subject: [PATCH 23/37] refactor(shortcuts): enhance shortcut handling and streamline settings integration --- src/renderer/src/hooks/useShortcuts.ts | 59 ++++++--- .../src/pages/settings/ShortcutSettings.tsx | 117 ++++++------------ 2 files changed, 80 insertions(+), 96 deletions(-) diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index 5d660e44f42..d903f86bf20 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -1,8 +1,9 @@ import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference' -import { isMac } from '@renderer/config/constant' +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, ShortcutDefinition, ShortcutKey, ShortcutPreferenceKey } from '@shared/shortcuts/types' +import type { ResolvedShortcut, ShortcutKey, ShortcutPreferenceKey, SupportedPlatform } from '@shared/shortcuts/types' import { convertAcceleratorToHotkey, formatShortcutDisplay, @@ -139,37 +140,65 @@ export const useShortcutDisplay = (shortcutKey: ShortcutKey | ShortcutPreference } export interface ShortcutListItem { - definition: ShortcutDefinition + key: ShortcutPreferenceKey + label: string + definition: (typeof SHORTCUT_DEFINITIONS)[number] preference: ResolvedShortcut defaultPreference: ResolvedShortcut - updatePreference: (patch: Partial) => Promise } -export const useAllShortcuts = (): ShortcutListItem[] => { +const MINI_WINDOW_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.general.show_mini_window' +const SELECTION_SHORTCUT_CATEGORY = 'feature.selection' + +export const useAllShortcuts = () => { const [values, setValues] = useMultiplePreferences(shortcutPreferenceKeyMap) + const [quickAssistantEnabled] = usePreference('feature.quick_assistant.enabled') + const [selectionAssistantEnabled] = usePreference('feature.selection.enabled') const updatePreference = useCallback( - async (definition: ShortcutDefinition, state: ResolvedShortcut, patch: Partial) => { + 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] ) - return useMemo( + const shortcuts = useMemo( () => - SHORTCUT_DEFINITIONS.map((definition) => { + SHORTCUT_DEFINITIONS.flatMap((definition) => { + const supported = definition.supportedPlatforms + if (supported && platform && !supported.includes(platform as SupportedPlatform)) { + return [] + } + if (definition.key === MINI_WINDOW_SHORTCUT_KEY && !quickAssistantEnabled) { + return [] + } + if (definition.category === SELECTION_SHORTCUT_CATEGORY && !selectionAssistantEnabled) { + return [] + } + const rawValue = values[definition.key] as PreferenceShortcutType | undefined const preference = resolveShortcutPreference(definition, rawValue) - return { - definition, - preference, - defaultPreference: getDefaultShortcut(definition), - updatePreference: (patch: Partial) => updatePreference(definition, preference, patch) - } + return [ + { + key: definition.key, + label: getShortcutLabel(definition.labelKey), + definition, + preference: { + binding: preference.binding, + enabled: preference.enabled && preference.binding.length > 0 + }, + defaultPreference: getDefaultShortcut(definition) + } + ] }), - [updatePreference, values] + [quickAssistantEnabled, selectionAssistantEnabled, values] ) + + return { shortcuts, updatePreference } } diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index 1ac2b54477a..d13b817e504 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -1,15 +1,13 @@ import { UndoOutlined } from '@ant-design/icons' import { Button, Input, RowFlex, Switch, Tooltip } from '@cherrystudio/ui' -import { usePreference } from '@data/hooks/usePreference' import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' -import { isMac, platform } from '@renderer/config/constant' +import { isMac } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllShortcuts } from '@renderer/hooks/useShortcuts' import { useTimer } from '@renderer/hooks/useTimer' -import { getShortcutLabel } from '@renderer/i18n/label' import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' -import type { ShortcutDefinition, ShortcutPreferenceKey, SupportedPlatform } from '@shared/shortcuts/types' +import type { ShortcutPreferenceKey } from '@shared/shortcuts/types' import { convertKeyToAccelerator, formatKeyDisplay, @@ -23,21 +21,6 @@ import { useTranslation } from 'react-i18next' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' const logger = loggerService.withContext('ShortcutSettings') -const MINI_WINDOW_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.general.show_mini_window' -const SELECTION_SHORTCUT_CATEGORY = 'feature.selection' - -type ShortcutRecord = { - definition: ShortcutDefinition - label: string - key: ShortcutPreferenceKey - enabled: boolean - displayKeys: string[] - updatePreference: (patch: Partial) => Promise - defaultPreference: { - binding: string[] - enabled: boolean - } -} const isBindingEqual = (a: string[], b: string[]): boolean => a.length === b.length && a.every((key, index) => key === b[index]) @@ -66,9 +49,7 @@ const usableEndKeys = (code: string): string | null => { const ShortcutSettings: FC = () => { const { t } = useTranslation() const { theme } = useTheme() - const shortcuts = useAllShortcuts() - const [quickAssistantEnabled] = usePreference('feature.quick_assistant.enabled') - const [selectionAssistantEnabled] = usePreference('feature.selection.enabled') + const { shortcuts, updatePreference } = useAllShortcuts() const inputRefs = useRef>({}) const [editingKey, setEditingKey] = useState(null) const [pendingKeys, setPendingKeys] = useState([]) @@ -76,39 +57,17 @@ const ShortcutSettings: FC = () => { const [searchQuery, setSearchQuery] = useState('') const { setTimeoutTimer, clearTimeoutTimer } = useTimer() - const visibleShortcuts = useMemo(() => { + const visibleShortcuts = useMemo(() => { const query = searchQuery.toLowerCase() - return shortcuts.flatMap((item) => { - const supported = item.definition.supportedPlatforms - if (supported && platform && !supported.includes(platform as SupportedPlatform)) { - return [] - } - if (item.definition.key === MINI_WINDOW_SHORTCUT_KEY && !quickAssistantEnabled) { - return [] - } - if (item.definition.category === SELECTION_SHORTCUT_CATEGORY && !selectionAssistantEnabled) { - return [] - } - - const record = { - definition: item.definition, - label: getShortcutLabel(item.definition.labelKey), - key: item.definition.key, - enabled: item.preference.enabled && item.preference.binding.length > 0, - displayKeys: item.preference.binding, - updatePreference: item.updatePreference, - defaultPreference: item.defaultPreference - } - - if (!query) { - return [record] - } - + return shortcuts.filter((record) => { + if (!query) return true const display = - record.displayKeys.length > 0 ? formatShortcutDisplay(record.displayKeys, isMac).toLowerCase() : '' - return record.label.toLowerCase().includes(query) || display.includes(query) ? [record] : [] + record.preference.binding.length > 0 + ? formatShortcutDisplay(record.preference.binding, isMac).toLowerCase() + : '' + return record.label.toLowerCase().includes(query) || display.includes(query) }) - }, [quickAssistantEnabled, searchQuery, selectionAssistantEnabled, shortcuts]) + }, [searchQuery, shortcuts]) const duplicateBindingLabels = useMemo(() => { const lookup = new Map() @@ -116,8 +75,8 @@ const ShortcutSettings: FC = () => { 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.definition.key, - label: getShortcutLabel(shortcut.definition.labelKey) + key: shortcut.key, + label: shortcut.label }) } @@ -131,30 +90,26 @@ const ShortcutSettings: FC = () => { setConflictLabel(null) } - const handleAddShortcut = (record: ShortcutRecord) => { + const handleAddShortcut = (key: ShortcutPreferenceKey) => { clearEditingState() - setEditingKey(record.key) + setEditingKey(key) setTimeoutTimer( - `focus-${record.key}`, + `focus-${key}`, () => { - inputRefs.current[record.key]?.focus() + inputRefs.current[key]?.focus() }, 0 ) } - const isBindingModified = (record: ShortcutRecord) => { - return !isBindingEqual(record.displayKeys, record.defaultPreference.binding) - } - - const handleUpdateFailure = (record: ShortcutRecord, error: unknown) => { + 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 = async (record: ShortcutRecord) => { + const handleResetShortcut = async (record: (typeof shortcuts)[number]) => { try { - await record.updatePreference({ + await updatePreference(record.key, { binding: record.defaultPreference.binding, enabled: record.defaultPreference.enabled }) @@ -169,7 +124,7 @@ const ShortcutSettings: FC = () => { return duplicate && duplicate.key !== currentKey ? duplicate.label : null } - const handleKeyDown = async (event: ReactKeyboardEvent, record: ShortcutRecord) => { + const handleKeyDown = async (event: ReactKeyboardEvent, record: (typeof shortcuts)[number]) => { event.preventDefault() if (event.code === 'Escape') { @@ -209,7 +164,7 @@ const ShortcutSettings: FC = () => { setConflictLabel(null) try { - await record.updatePreference({ binding: keys, enabled: true }) + await updatePreference(record.key, { binding: keys, enabled: true }) clearEditingState() } catch (error) { handleUpdateFailure(record, error) @@ -240,10 +195,12 @@ const ShortcutSettings: FC = () => { }) } - const renderShortcutCell = (record: ShortcutRecord) => { + const renderShortcutCell = (record: (typeof shortcuts)[number]) => { const isEditing = editingKey === record.key - const displayShortcut = record.displayKeys.length > 0 ? formatShortcutDisplay(record.displayKeys, isMac) : '' + 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) if (isEditing) { const pendingDisplay = pendingKeys.length > 0 ? formatShortcutDisplay(pendingKeys, isMac) : '' @@ -258,9 +215,7 @@ const ShortcutSettings: FC = () => { 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) - }} + onKeyDown={(event) => void handleKeyDown(event, record)} onBlur={(event) => { const isUndoClick = (event.relatedTarget as HTMLElement)?.closest('.shortcut-undo-icon') if (!isUndoClick) { @@ -280,7 +235,7 @@ const ShortcutSettings: FC = () => { if (displayShortcut) { return ( - {isBindingModified(record) && ( + {isBindingModified && ( { )} isEditable && handleAddShortcut(record)}> - {record.displayKeys.map((key) => ( + onClick={() => isEditable && handleAddShortcut(record.key)}> + {displayKeys.map((key) => ( @@ -308,20 +263,20 @@ const ShortcutSettings: FC = () => { return ( isEditable && handleAddShortcut(record)}> + onClick={() => isEditable && handleAddShortcut(record.key)}> {t('settings.shortcuts.press_shortcut')} ) } - const renderShortcutRow = (record: ShortcutRecord, isLast: boolean) => { + const renderShortcutRow = (record: (typeof shortcuts)[number], isLast: boolean) => { const switchNode = ( { - record.updatePreference({ enabled: !record.enabled }).catch((error) => { + updatePreference(record.key, { enabled: !record.preference.enabled }).catch((error) => { handleUpdateFailure(record, error) }) }} @@ -335,7 +290,7 @@ const ShortcutSettings: FC = () => { {record.label}
{renderShortcutCell(record)}
- {!record.displayKeys.length ? ( + {!record.preference.binding.length ? ( {switchNode} From 6edcd010ae6573c56122e7a6403d8562bc1fefce Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 10:57:01 +0800 Subject: [PATCH 24/37] refactor(shortcuts): update shortcut system documentation for clarity and structure --- .../shortcuts/shortcut-system-refactor.md | 634 ++++++------------ 1 file changed, 190 insertions(+), 444 deletions(-) diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md index e89e746976b..7f4ebc08d88 100644 --- a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -1,556 +1,302 @@ -# Cherry Studio 快捷键系统重构设计文档 +# Cherry Studio 快捷键系统重构设计 -> 版本:v2.0(v2 Preference 架构) -> 更新日期:2026-04-03 -> 分支:`refactor/shortcut` +> 版本:v2.0 +> 更新日期:2026-04-14 +> 分支:`refactor/v2/shortcuts` -## 目录 +## 背景 -- [背景与目标](#背景与目标) -- [核心设计原则](#核心设计原则) -- [架构总览](#架构总览) -- [分层详解](#分层详解) -- [类型系统](#类型系统) -- [数据流](#数据流) -- [默认快捷键一览](#默认快捷键一览) -- [扩展指南](#扩展指南) -- [迁移清单](#迁移清单) -- [测试覆盖](#测试覆盖) -- [后续演进方向](#后续演进方向) +v1 快捷键系统的主要问题有 5 个: ---- +- 数据源分散在 Redux 和 `configManager` +- 主进程与渲染进程靠手动 IPC 同步 +- 新增快捷键需要改多处 `switch-case` +- 定义分散,缺少统一入口 +- 类型约束弱,运行时容易出现脏数据 -## 背景与目标 +v2 的目标很明确: -### 旧版问题 +- 用 `SHORTCUT_DEFINITIONS` 统一描述所有快捷键元数据 +- 用 Preference 存储用户配置,不再维护第二套状态 +- main / renderer 各自按职责注册,但共用同一套定义和工具 +- 通过 `resolveShortcutPreference` 保证读取结果始终完整可用 +- 让新增快捷键尽量收敛为“定义 + 默认值 + 使用”三步 -v1 快捷键系统存在以下架构缺陷: +## 核心模型 -| 问题 | 影响 | -|------|------| -| 双数据源(Redux store + `configManager`)| 主/渲染进程状态不一致 | -| `IpcChannel.Shortcuts_Update` 手动同步 | 多窗口场景下丢失更新 | -| `switch-case` 硬编码处理器 | 可维护性差,新增快捷键需改动多处 | -| 定义分散在多个文件 | 缺乏单一真相源 | -| 弱类型(`Record`)| 运行时类型不安全 | +系统由 4 层组成: -### 新版目标 +1. 定义层:`packages/shared/shortcuts/definitions.ts` +2. 工具层:`packages/shared/shortcuts/utils.ts` +3. 存储层:`packages/shared/data/preference/preferenceSchemas.ts` +4. 消费层:`ShortcutService` 与 `useShortcuts.ts` -- **单一真相源**:`SHORTCUT_DEFINITIONS` 数组为所有快捷键元数据的唯一来源 -- **Preference 优先**:运行时状态完全托管于 `preferenceService`(SQLite + 内存缓存 + IPC 广播) -- **全链路类型安全**:从定义到存储到消费,TypeScript 严格约束 -- **处理器注册表**:`Map` 替代 `switch-case` -- **三步扩展**:新增快捷键仅需「定义 → Schema 默认值 → 注册使用」 -- **多窗口自动同步**:借助 `preferenceService` 的 IPC 广播机制 -- **平台感知**:`supportedPlatforms` 字段过滤不支持的系统快捷键 +它们各自负责的事情很简单: ---- +| 层 | 作用 | +| --- | --- | +| 定义层 | 描述快捷键是什么 | +| 工具层 | 做格式转换、校验、默认值和归一化 | +| 存储层 | 保存用户真正可变的配置 | +| 消费层 | 在 main / renderer 中注册并使用快捷键 | -## 核心设计原则 +### 1. 定义层 -1. **关注点分离** — 定义层(静态元数据)、偏好层(用户配置)、服务层(注册与生命周期)、UI 层(展示与编辑)各司其职 -2. **复用基础设施** — 所有持久化依赖 `preferenceService`,不引入新的存储通道 -3. **防御性 coerce** — 所有偏好读取均经过 `resolveShortcutPreference` 归一化,保证缺失字段有合理 fallback -4. **声明式驱动** — 注册逻辑遍历 `SHORTCUT_DEFINITIONS`,不硬编码具体快捷键 +`SHORTCUT_DEFINITIONS` 是快捷键系统的单一真相源。每条定义描述一个快捷键的静态元数据,例如: ---- - -## 架构总览 - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Shortcut System v2 │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 📋 Shared Definition Layer │ │ -│ │ packages/shared/shortcuts/ │ │ -│ │ ├── types.ts 类型定义 │ │ -│ │ ├── definitions.ts SHORTCUT_DEFINITIONS (真相之源) │ │ -│ │ └── utils.ts 转换 / 校验 / 归一化工具 │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 💾 Preference Layer │ │ -│ │ packages/shared/data/preference/ │ │ -│ │ ├── preferenceSchemas.ts 默认值 (enabled + binding)│ │ -│ │ └── preferenceTypes.ts PreferenceShortcutType │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌────────────────────┐ ┌──────────────────────────┐ │ -│ │ ⚙️ Main Process │ │ 🖥️ Renderer Process │ │ -│ │ ShortcutService │ │ useShortcut │ │ -│ │ ├ Handler Map │ │ useShortcutDisplay │ │ -│ │ ├ Focus/Blur 生命周期│ │ useAllShortcuts │ │ -│ │ ├ Preference 订阅 │ │ (react-hotkeys-hook) │ │ -│ │ └ globalShortcut │ └──────────────────────────┘ │ -│ └────────────────────┘ │ │ -│ ▼ │ -│ ┌──────────────────────┐ │ -│ │ 🎨 UI Layer │ │ -│ │ ShortcutSettings │ │ -│ │ ├ 录制 / 清空 / 重置 │ │ -│ │ ├ 冲突检测 │ │ -│ │ └ 启用 / 禁用 │ │ -│ └──────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` - ---- - -## 分层详解 - -### 1. 共享定义层 (`packages/shared/shortcuts/`) - -#### `definitions.ts` — 单一真相源 - -所有快捷键以 `ShortcutDefinition[]` 数组集中声明,每条定义包含完整的静态元数据: - -```typescript +```ts { - key: 'shortcut.general.show_mini_window', // Preference key - scope: 'main', // main | renderer | both - category: 'general', // 点分命名空间 UI 分组:general、chat、topic、plugin.xxx 等 - labelKey: 'mini_window', // i18n label key - editable: true, // 用户可修改绑定(默认 true) - global: true, // 全局快捷键(窗口失焦后仍然生效) + key: 'shortcut.general.show_mini_window', + scope: 'main', + category: 'general', + labelKey: 'mini_window', + global: true, supportedPlatforms: ['darwin', 'win32'] } ``` -**关键字段说明:** - -| 字段 | 用途 | -|------|------| -| `key` | Preference key,内置快捷键用 `shortcut.{category}.{name}` 格式,插件用 `shortcut.plugin.{pluginId}.{name}` | -| `scope` | 决定快捷键注册在哪个进程:`main`(globalShortcut)、`renderer`(react-hotkeys-hook)、`both`(两者都注册) | -| `category` | 点分命名空间 UI 分组(如 `general`、`chat`、`topic`、`plugin.translator`),类型为 `ShortcutCategory` 以支持插件扩展 | -| `labelKey` | i18n label key,由 `getShortcutLabel()` 消费 | -| `editable` | 设为 `false` 表示用户不可修改绑定(如 Escape 退出全屏),默认 `true` | -| `global` | 全局快捷键,窗口失焦时是否保留注册(如 `show_main_window` 需要在任何时候响应) | -| `variants` | 同一快捷键的多组绑定(如 zoom_in 同时绑定 `=` 和小键盘 `+`) | -| `supportedPlatforms` | 限制快捷键仅在指定操作系统上注册和显示,类型为 `SupportedPlatform[]`(`'darwin' | 'win32' | 'linux'`) | - -#### `types.ts` — 类型体系 +常用字段: -```typescript -// 从 PreferenceKeyType 推导出所有 shortcut.* 前缀的 key -type ShortcutPreferenceKey = Extract +| 字段 | 说明 | +| --- | --- | +| `key` | Preference key,格式通常为 `shortcut.{category}.{name}` | +| `scope` | `main`、`renderer` 或 `both` | +| `category` | 设置页分组,如 `general`、`chat`、`topic`、`feature.selection` | +| `labelKey` | i18n 文案 key | +| `editable` | 是否允许用户修改绑定 | +| `global` | 是否在窗口失焦后仍需保留注册 | +| `variants` | 同一快捷键的额外绑定 | +| `supportedPlatforms` | 平台限制 | -// 去掉 shortcut. 前缀的短 key,用于调用侧简化 -type ShortcutKey = ShortcutPreferenceKey extends `shortcut.${infer Rest}` ? Rest : never +### 2. 存储层 -// 运行时归一化后的完整状态 -interface ResolvedShortcut { - binding: string[] // 生效的绑定(用户自定义、默认值或空数组——显式清空) - enabled: boolean // 是否启用 -} -``` - -`ShortcutKey` 类型使得调用侧可以使用短 key: - -```typescript -// 两种写法等价,均有类型补全 -useShortcut('chat.clear', callback) -useShortcut('shortcut.chat.clear', callback) - -// useShortcutDisplay 同样两种写法均有效 -useShortcutDisplay('chat.clear') -``` +快捷键偏好只保存用户真正会改的部分: -#### `utils.ts` — 纯函数工具集 - -| 函数 | 职责 | -|------|------| -| `convertKeyToAccelerator` | DOM `event.code` → Electron accelerator 格式 | -| `convertAcceleratorToHotkey` | Electron accelerator → `react-hotkeys-hook` 字符串 | -| `formatShortcutDisplay` | accelerator → 用户友好的显示字符串(Mac 用符号,其他用文字) | -| `isValidShortcut` | 校验快捷键有效性(须含修饰键,或为特殊单键如 F1-F12、Escape) | -| `getDefaultShortcut` | 从 `DefaultPreferences` 读取 schema 默认值并归一化 | -| `resolveShortcutPreference` | **核心归一化函数**:将任意偏好值 + 定义 → 完整的 `ResolvedShortcut` | - -`resolveShortcutPreference` 的防御逻辑: - -``` -输入值为 null/undefined → 使用 schema 默认值 -输入的 binding 为空数组 → binding 为空(用户显式清空) -输入的 enabled 非布尔 → 使用默认 enabled -``` - -**设计决策**:禁用快捷键可以使用 `enabled: false`,也可以清空绑定(`binding: []`)。想换键就录制覆盖,想重置就写回 schema 默认值。 - -### 2. 偏好层 (`preferenceSchemas.ts` + `preferenceTypes.ts`) - -偏好值的存储结构经过精简,只持久化用户可变部分: - -```typescript -// PreferenceShortcutType — 存储在 SQLite 中的数据结构 +```ts type PreferenceShortcutType = { - binding: string[] // 用户自定义的键位绑定 - enabled: boolean // 启用/禁用 + binding: string[] + enabled: boolean } ``` -**设计决策**:`editable` 不存储在偏好中,而是在运行时从 `ShortcutDefinition` 注入。这样修改定义不需要数据迁移。 +默认值定义在 `preferenceSchemas.ts`。例如: -`preferenceSchemas.ts` 中为每个快捷键声明默认值: - -```typescript -'shortcut.chat.clear': { enabled: true, binding: ['CommandOrControl', 'L'] }, -'shortcut.chat.copy_last_message': { enabled: false, binding: ['CommandOrControl', 'Shift', 'C'] }, -``` - -### 3. 主进程服务层 (`ShortcutService`) - -基于 v2 Lifecycle 架构实现,使用 `@Injectable`、`BaseService`、`@DependsOn` 等装饰器: - -```typescript -@Injectable('ShortcutService') -@ServicePhase(Phase.WhenReady) -@DependsOn(['WindowService', 'SelectionService', 'PreferenceService']) -export class ShortcutService extends BaseService { ... } +```ts +'shortcut.chat.clear': { enabled: true, binding: ['CommandOrControl', 'L'] } +'shortcut.general.show_main_window': { enabled: false, binding: [] } ``` -#### 处理器注册表 - -使用 `Map` 存储处理器,在 `onInit` 时注册所有内置处理器: +这里不存 `editable`、`scope`、`labelKey` 这类静态信息,它们始终从 `SHORTCUT_DEFINITIONS` 读取。 -```typescript -private handlers = new Map() - -// 注册示例 -this.handlers.set('shortcut.general.zoom_in', (window) => { - if (window) handleZoomFactor([window], 0.1) -}) -``` +### 3. 工具层 -#### 窗口生命周期管理 +`utils.ts` 里最重要的是 4 类能力: -``` -窗口创建 → registerForWindow(window) - ├── 首次创建 + tray_on_launch → ready-to-show 时仅注册 global 快捷键 - ├── focus 事件 → registerShortcuts(window, false) 注册全部 - └── blur 事件 → registerShortcuts(window, true) 仅保留 global -``` +- 格式转换:Electron accelerator 与 renderer hotkey 之间互转 +- 显示格式化:把绑定转成 `⌘L` / `Ctrl+L` +- 合法性校验:限制无效单键 +- 偏好归一化:把任意原始值整理成稳定的 `ResolvedShortcut` -`registerShortcuts` 采用增量 diff 注册,避免重复 unregister/register: +核心函数是 `resolveShortcutPreference(definition, value)`。它负责: -1. 遍历 `relevantDefinitions`(预过滤 `scope !== 'renderer'` 和 `supportedPlatforms`),构建目标 accelerator → handler 映射 `desired` -2. 对每个定义:读取偏好 → `resolveShortcutPreference` 归一化 → 检查 `enabled` + binding 非空 -3. 若定义有 `variants`,将变体绑定一并加入 `desired` -4. 遍历 `registeredAccelerators`:目标中不存在或 handler 变化的调用 `globalShortcut.unregister` -5. 遍历 `desired`:尚未注册的调用 `globalShortcut.register`,检查返回值,失败时打日志 +- 值缺失时回退到 schema 默认值 +- 保留用户显式清空的 `binding: []` +- 当 `enabled` 字段异常时回退到默认值 -#### 偏好变更订阅 +对调用侧来说,拿到的始终是: -```typescript -for (const definition of relevantDefinitions) { - this.registerDisposable( - preferenceService.subscribeChange(definition.key, () => { - this.reregisterShortcuts() // 整体重注册 - }) - ) +```ts +type ResolvedShortcut = { + binding: string[] + enabled: boolean } ``` -### 4. 渲染进程 Hook 层 (`useShortcuts.ts`) +## 运行方式 -提供三个核心 Hook: +### 主进程 -#### `useShortcut(key, callback, options)` +`ShortcutService` 负责所有 `scope !== 'renderer'` 的快捷键。 -注册渲染进程快捷键,核心逻辑: +它的实现重点只有 3 件事: -1. `toFullKey()` 支持短 key 和完整 key 两种写法 -2. `findShortcutDefinition()` 查找定义 -3. `usePreference()` 读取当前偏好值 -4. `resolveShortcutPreference()` 归一化 -5. 检查 `scope === 'main'` → 跳过(主进程快捷键不在渲染进程注册) -6. 检查 `enabled` → 禁用则 hotkey 设为 `'none'` -7. `convertAcceleratorToHotkey()` 转换格式 -8. 传递给 `react-hotkeys-hook` 的 `useHotkeys` +1. 注册内置 handler:用 `Map` 代替 `switch-case` +2. 监听 Preference 变化:配置变更后重算当前应注册的快捷键 +3. 管理窗口生命周期:窗口 focus / blur 时切换“全部快捷键”与“仅全局快捷键” -```typescript -// 调用侧简洁用法 -useShortcut('chat.clear', () => clearChat()) -useShortcut('topic.new', () => createTopic(), { enableOnFormTags: false }) -``` +当前注册逻辑是增量 diff: -**Options:** +- 先根据定义、平台、功能开关和偏好算出目标 accelerator 集合 +- 再和已注册集合比较 +- 只卸载或重绑真正变化的项 -| 选项 | 默认值 | 说明 | -|------|--------|------| -| `preventDefault` | `true` | 阻止浏览器默认行为 | -| `enableOnFormTags` | `true` | 在 input/textarea 中是否生效 | -| `enabled` | `true` | 外部控制启用/禁用 | -| `enableOnContentEditable` | `false` | 在 contentEditable 元素中是否生效 | +这样可以避免无意义的全量 unregister / register。 -#### `useShortcutDisplay(key)` +### 渲染进程 -返回格式化后的快捷键显示字符串,用于 UI 提示(如 Tooltip): +`useShortcuts.ts` 提供 3 个 Hook: -```typescript -const display = useShortcutDisplay('chat.clear') -// Mac: "⌘L" Windows: "Ctrl+L" -``` +| Hook | 作用 | +| --- | --- | +| `useShortcut` | 注册单个 renderer 快捷键 | +| `useShortcutDisplay` | 返回格式化后的展示文案 | +| `useAllShortcuts` | 为设置页提供完整快捷键列表和统一更新入口 | -#### `useAllShortcuts()` +其中 `useAllShortcuts()` 现在会直接处理: -供设置页使用,批量读取所有快捷键配置: +- 平台过滤 +- 依赖功能开关的过滤 +- label 生成 +- “无绑定不算启用”的展示态收敛 -- 使用 `useMultiplePreferences()` 一次性读取所有 `shortcut.*` 偏好 -- 返回 `ShortcutListItem[]`,每项包含 `definition`、`preference`、`defaultPreference`、`updatePreference` -- `updatePreference` 内部使用 `buildNextPreference` 合并 patch,仅写入 `{ binding, enabled }` +设置页只需要继续做搜索、录制、冲突提示和渲染。 -### 5. UI 层 (`ShortcutSettings.tsx`) +## 关键规则 -设置页面直接消费 `useAllShortcuts()` Hook,支持以下操作: +有几条规则是这个系统稳定运行的关键: -| 功能 | 实现 | -|------|------| -| **平台过滤** | 根据 `supportedPlatforms` 过滤不支持的快捷键 | -| **快捷键录制** | `handleKeyDown` 捕获键盘事件 → `convertKeyToAccelerator` → `isValidShortcut` 校验 | -| **冲突检测** | `findDuplicateLabel` 检查已启用快捷键中是否存在相同绑定 | -| **清空绑定** | `updatePreference({ binding: [] })` | -| **重置单项** | 写入 `defaultPreference` 的 `binding` + `enabled` | -| **重置全部** | `preferenceService.setMultiple()` 批量写入所有默认值 | -| **启用/禁用** | `updatePreference({ enabled: !current })` | -| **修改标记** | `isBindingModified` 比对当前值与默认值,决定重置按钮是否可用 | +### 单一真相源 ---- +快捷键的静态信息只来自 `SHORTCUT_DEFINITIONS`,不要在页面、服务或迁移代码里再维护另一份描述。 -## 类型系统 - -### 类型推导链 - -``` -preferenceSchemas.ts 中声明 key - ↓ 代码生成 -PreferenceKeyType(所有偏好 key 的联合类型) - ↓ Extract<..., `shortcut.${string}`> -ShortcutPreferenceKey(如 'shortcut.chat.clear') - ↓ Template literal infer -ShortcutKey(如 'chat.clear') -``` +### Preference 优先 -### 调用侧类型安全 +运行时状态只从 Preference 读写。不要再引入 Redux、临时配置文件或额外 IPC 作为第二数据源。 -```typescript -// ✅ 编译通过 — 'chat.clear' 是合法的 ShortcutKey -useShortcut('chat.clear', callback) +### 无绑定即不可触发 -// ✅ 编译通过 — 完整 key 也被接受 -useShortcut('shortcut.chat.clear', callback) +即使某项 `enabled` 为 `true`,只要 `binding` 为空,它也不应被注册,也不应在设置页显示为启用。 -// ❌ 编译报错 — 'chat.invalid' 不在 ShortcutKey 联合类型中 -useShortcut('chat.invalid', callback) -``` +### 功能关闭时不显示对应快捷键 ---- +当前有两类依赖功能状态的快捷键: -## 数据流 +- `shortcut.general.show_mini_window` 依赖 `feature.quick_assistant.enabled` +- `feature.selection.*` 依赖 `feature.selection.enabled` -### 启动阶段 +功能关闭时,这些快捷键不会注册,也不会在设置页显示。 -``` -PreferenceService.initialize() - ↓ SQLite → 内存缓存 -ShortcutService.onInit() - ├── registerBuiltInHandlers() 注册 Map - ├── subscribeToPreferenceChanges() 订阅每个 shortcut.* key - └── registerForWindow(mainWindow) - ├── focus → registerShortcuts(window, false) - └── blur → registerShortcuts(window, true) -``` +### 平台限制要同时作用于注册与展示 -### 用户修改快捷键 +`supportedPlatforms` 不只是 UI 过滤条件,也决定快捷键是否会在当前系统上注册。 -``` -用户在设置页按下新快捷键 - ↓ handleKeyDown -convertKeyToAccelerator() + isValidShortcut() + findDuplicateLabel() - ↓ 通过校验 -updatePreference({ binding: newKeys }) - ↓ useMultiplePreferences.setValues() -preferenceService.set('shortcut.chat.clear', { binding: [...], enabled: true }) - ├── SQLite 持久化 - ├── IPC 广播 → 所有渲染窗口自动更新 - └── subscribeChange 回调 → ShortcutService.reregisterShortcuts() - ↓ - 增量 diff 注册(仅变化的 accelerator 重新注册) -``` +## 默认快捷键 -### 渲染进程快捷键触发 +下表只保留最常用的信息:key、默认绑定、scope、默认启用状态。 -``` -用户按下 Cmd+L - ↓ react-hotkeys-hook -useHotkeys('mod+l', callback) - ↓ 匹配成功 -callback(event) // 如 clearChat() -``` +### general -### 主进程快捷键触发 +| Key | 默认绑定 | Scope | 默认启用 | +| --- | --- | --- | --- | +| `shortcut.general.show_main_window` | *(无)* | main | 否 | +| `shortcut.general.show_mini_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 | 是 | -``` -用户按下 Cmd+E(窗口失焦状态) - ↓ Electron globalShortcut -handlers.get('shortcut.general.show_mini_window') - ↓ -toggleMiniWindow() -``` +### 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 -### 应用级 (`general`) +| Key | 默认绑定 | Scope | 默认启用 | +| --- | --- | --- | --- | +| `shortcut.topic.new` | `Cmd/Ctrl+N` | renderer | 是 | +| `shortcut.topic.rename` | `Cmd/Ctrl+T` | renderer | 否 | +| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl+]` | renderer | 是 | -| Preference Key | 默认绑定 | 作用域 | 备注 | -|---|---|---|---| -| `shortcut.general.show_main_window` | *(无)* | main | 失焦持久 | -| `shortcut.general.show_mini_window` | `Cmd/Ctrl+E` | main | 默认禁用,关联 quick_assistant 开关 | -| `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 | | +### feature.selection -### 聊天 (`chat`) +| Key | 默认绑定 | Scope | 默认启用 | +| --- | --- | --- | --- | +| `shortcut.feature.selection.toggle_enabled` | *(无)* | main | 否 | +| `shortcut.feature.selection.get_text` | *(无)* | main | 否 | -| Preference Key | 默认绑定 | 默认启用 | 备注 | -|---|---|---|---| -| `shortcut.chat.clear` | `Cmd/Ctrl+L` | 是 | | -| `shortcut.chat.search_message` | `Cmd/Ctrl+F` | 是 | | -| `shortcut.chat.toggle_new_context` | `Cmd/Ctrl+K` | 是 | | -| `shortcut.chat.copy_last_message` | `Cmd/Ctrl+Shift+C` | 否 | | -| `shortcut.chat.edit_last_user_message` | `Cmd/Ctrl+Shift+E` | 否 | | -| `shortcut.chat.select_model` | `Cmd/Ctrl+Shift+M` | 是 | | +## 扩展方式 -### 话题 (`topic`) +新增一个快捷键,原则上只需要 3 步。 -| Preference Key | 默认绑定 | 默认启用 | -|---|---|---| -| `shortcut.topic.new` | `Cmd/Ctrl+N` | 是 | -| `shortcut.topic.rename` | `Cmd/Ctrl+T` | 否 | -| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl+]` | 是 | +### 1. 添加默认值 -### 划词助手 (`feature.selection`) +在 `preferenceSchemas.ts` 中增加 schema 默认值: -| Preference Key | 默认绑定 | 支持平台 | -|---|---|---| -| `shortcut.feature.selection.toggle_enabled` | *(无)* | macOS, Windows | -| `shortcut.feature.selection.get_text` | *(无)* | macOS, Windows | - ---- - -## 扩展指南 - -### 新增一个快捷键(三步) - -**Step 1:声明 Schema 默认值** - -```typescript -// packages/shared/data/preference/preferenceSchemas.ts -'shortcut.chat.regenerate': { enabled: true, binding: ['CommandOrControl', 'Shift', 'R'] }, +```ts +'shortcut.chat.regenerate': { + enabled: true, + binding: ['CommandOrControl', 'Shift', 'R'] +} ``` -> 注意:类型声明区也需要添加对应的类型声明行。 +### 2. 添加定义 -**Step 2:添加静态定义** +在 `definitions.ts` 中加入静态元数据: -```typescript -// packages/shared/shortcuts/definitions.ts +```ts { key: 'shortcut.chat.regenerate', scope: 'renderer', - category: 'chat' + category: 'chat', + labelKey: 'regenerate' } ``` -**Step 3:在目标模块使用** +### 3. 在目标位置使用 -```typescript -// 渲染进程 -useShortcut('chat.regenerate', () => regenerateLastMessage()) +渲染进程: -// 或主进程(在 ShortcutService.registerBuiltInHandlers 中) -this.handlers.set('shortcut.chat.regenerate', () => { ... }) +```ts +useShortcut('chat.regenerate', () => regenerateLastMessage()) ``` -### 条件启用 - -需要条件启用的快捷键,在 handler 内自行读取偏好并直接返回: +主进程: -```typescript -this.handlers.set('shortcut.general.show_mini_window', () => { - if (!application.get('PreferenceService').get('feature.quick_assistant.enabled')) return - application.get('WindowService').toggleMiniWindow() +```ts +this.handlers.set('shortcut.chat.regenerate', () => { + // ... }) ``` -### 平台限制 - -```typescript -{ - key: 'shortcut.feature.selection.toggle_enabled', - supportedPlatforms: ['darwin', 'win32'], - // Linux 上不会注册,设置页也不会显示 -} -``` - ---- - -## 迁移清单 - -### 旧组件处理状态 - -| 旧组件 | 状态 | -|--------|------| -| Redux `shortcuts` slice | 保留在 `combineReducers` 中供旧版本数据兼容读取,`PreferenceMigrator` 从其 `initialState` 中迁移快捷键到新的 `shortcut.*` key | -| `IpcChannel.Shortcuts_Update` | 已删除 | -| `window.api.shortcuts.update` (preload bridge) | 已删除 | -| `configManager.getShortcuts()` / `setShortcuts()` | 已删除 | -| `ConfigKeys.Shortcuts` | 已删除 | +如果是条件型快捷键,不要把条件写进定义层,应该在消费层做过滤或在 handler 内做早返回。 -### 数据迁移 +## 迁移与测试 -- `store/migrate.ts` 中引入 `MigrationState` 类型(`RootState & { shortcuts?: ShortcutsState }`),兼容旧 Redux 状态结构 -- 已有用户偏好通过 `PreferenceMigrator` 从旧 key 映射到新 `shortcut.*` key -- 未持久化的快捷键自动继承 `preferenceSchemas.ts` 中的默认值 +### 迁移现状 ---- +- 旧的 Redux `shortcuts` slice 只保留为迁移输入 +- `IpcChannel.Shortcuts_Update`、旧 preload bridge、`configManager` 快捷键接口都已移除 +- 旧数据通过 `PreferenceMigrator` 映射到新的 `shortcut.*` key -## 测试覆盖 +### 当前测试重点 -### 单元测试 +现有测试主要覆盖: -覆盖 `utils.ts` 中所有导出函数,共 19 个测试用例: +- `utils.ts` 的格式转换、校验和归一化 +- 旧 key 到新 key 的迁移映射 +- `ShortcutService` 的重注册行为 -| 测试组 | 覆盖内容 | -|--------|----------| -| `convertKeyToAccelerator` | 已知 key 映射、未知 key 透传 | -| `convertAcceleratorToHotkey` | 修饰键转换(CommandOrControl→mod, Ctrl→ctrl 等) | -| `formatShortcutDisplay` | Mac 符号格式(⌘⇧⌥⌃)、非 Mac 文字格式 | -| `isValidShortcut` | 空数组、含修饰键、特殊单键、普通单键 | -| `getDefaultShortcut` | 默认值读取 | -| `resolveShortcutPreference` | null/undefined 回退、自定义 binding、空数组回退、enabled 覆盖 | -| `ShortcutMappings` | 旧 key 重命名优先级、畸形 binding 跳过、非数组源兜底 | +后续如果继续扩展,优先补下面两类测试: ---- +- 设置页的录制、冲突和显示逻辑 +- 主进程全局快捷键的端到端行为 -## 后续演进方向 +## 总结 -1. **跨进程冲突检测** — 主进程与渲染进程联动校验绑定冲突并在设置页提示 -2. **导入/导出** — 允许用户批量备份和恢复自定义快捷键配置 -3. **多作用域绑定** — 同一逻辑按窗口类型或上下文切换不同绑定 -4. **i18n label 自动化** — 消除 `labelKeyMap` 硬编码,从 definition key 自动推导 i18n key -5. **E2E 快捷键测试** — 通过 Playwright 验证主进程 globalShortcut 的端到端行为 +这套重构的核心不是“把快捷键做复杂”,而是把复杂度收拢到共享定义、统一归一化和清晰分层里。 ---- +对日常开发来说,只需要记住 3 件事: -> 如需扩展或有疑问,请在仓库中提交 Issue。 +1. 静态信息看 `SHORTCUT_DEFINITIONS` +2. 用户配置看 Preference +3. main / renderer 按各自职责消费同一套定义 From 2ce92c65f87a245711a442fdb89ba31e8cac1417 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 11:15:22 +0800 Subject: [PATCH 25/37] refactor(shortcuts): update shortcut definitions to improve clarity and consistency --- packages/shared/shortcuts/definitions.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index 98f69d5fa1e..99e21e9a1a9 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -1,7 +1,7 @@ import type { ShortcutDefinition, ShortcutPreferenceKey } from './types' export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ - // ==================== 应用级快捷键 ==================== + // ==================== Application shortcuts ==================== { key: 'shortcut.general.show_main_window', scope: 'main', @@ -65,7 +65,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'general', labelKey: 'search_message' }, - // ==================== 聊天相关快捷键 ==================== + // ==================== Chat shortcuts ==================== { key: 'shortcut.chat.clear', scope: 'renderer', @@ -102,7 +102,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'chat', labelKey: 'select_model' }, - // ==================== 话题管理快捷键 ==================== + // ==================== Topic shortcuts ==================== { key: 'shortcut.topic.new', scope: 'renderer', @@ -121,7 +121,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'topic', labelKey: 'toggle_show_topics' }, - // ==================== 划词助手快捷键 ==================== + // ==================== Selection assistant shortcuts ==================== { key: 'shortcut.feature.selection.toggle_enabled', scope: 'main', From a94b0067b2f3a060bdd7b9d3b2e6d3985e4b4bec Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 12:10:28 +0800 Subject: [PATCH 26/37] refactor(shortcuts): update shortcut keys and mappings for quick assistant feature --- packages/shared/data/bootConfig/bootConfigSchemas.ts | 2 +- packages/shared/data/preference/preferenceSchemas.ts | 8 ++++---- packages/shared/shortcuts/definitions.ts | 4 ++-- packages/shared/shortcuts/types.ts | 4 ++-- .../v2/migrators/mappings/BootConfigMappings.ts | 2 +- .../v2/migrators/mappings/PreferencesMappings.ts | 4 ++-- .../migration/v2/migrators/mappings/ShortcutMappings.ts | 2 +- .../mappings/__tests__/ShortcutMappings.test.ts | 9 +++++++++ src/main/services/ShortcutService.ts | 9 ++++++--- src/main/services/__tests__/ShortcutService.test.ts | 2 +- src/renderer/src/hooks/useShortcuts.ts | 4 ++-- .../docs/shortcuts/shortcut-system-refactor.md | 6 +++--- .../tools/data-classify/data/classification.json | 4 ++-- 13 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/shared/data/bootConfig/bootConfigSchemas.ts b/packages/shared/data/bootConfig/bootConfigSchemas.ts index 3cc1756607e..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-13T08:12:56.611Z + * 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 7ee78d8061e..cb285a609af 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-13T08:12:56.605Z + * 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: @@ -436,6 +436,8 @@ export interface PreferenceSchemas { '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 @@ -446,8 +448,6 @@ export interface PreferenceSchemas { 'shortcut.general.search': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.show_app 'shortcut.general.show_main_window': PreferenceTypes.PreferenceShortcutType - // redux/shortcuts/shortcuts.mini_window - 'shortcut.general.show_mini_window': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.show_settings 'shortcut.general.show_settings': PreferenceTypes.PreferenceShortcutType // redux/shortcuts/shortcuts.toggle_show_assistants @@ -727,12 +727,12 @@ export const DefaultPreferences: PreferenceSchemas = { '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_mini_window': { binding: ['CommandOrControl', 'E'], 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 }, diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index 99e21e9a1a9..6748c44d6b7 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -10,9 +10,9 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ global: true }, { - key: 'shortcut.general.show_mini_window', + key: 'shortcut.feature.quick_assistant.toggle_window', scope: 'main', - category: 'general', + category: 'feature.quick_assistant', labelKey: 'mini_window', global: true }, diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index bbdc7ab5a7e..ed6cac8bc54 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -3,11 +3,11 @@ 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.selection' +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.selection`. + * Built-in: `general`, `chat`, `topic`, `feature.quick_assistant`, `feature.selection`. * Plugins: `plugin.{pluginId}` (e.g. `plugin.translator`). */ export type ShortcutCategory = BuiltinShortcutCategory | `plugin.${string}` diff --git a/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts b/src/main/data/migration/v2/migrators/mappings/BootConfigMappings.ts index a7d196ee8cb..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-13T08:12:56.613Z + * 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/PreferencesMappings.ts b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts index f0d20c80d2a..5967e88b512 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-13T08:12:56.613Z + * 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 @@ -770,7 +770,7 @@ export const REDUX_STORE_MAPPINGS = { }, { originalKey: 'shortcuts.mini_window', - targetKey: 'shortcut.general.show_mini_window' + targetKey: 'shortcut.feature.quick_assistant.toggle_window' }, { originalKey: 'shortcuts.selection_assistant_toggle', diff --git a/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts b/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts index e6f87ae02b7..23138b178fa 100644 --- a/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts @@ -29,7 +29,7 @@ const LEGACY_KEY_TO_TARGET_KEY: Record = { zoom_reset: 'shortcut.general.zoom_reset', show_settings: 'shortcut.general.show_settings', show_app: 'shortcut.general.show_main_window', - mini_window: 'shortcut.general.show_mini_window', + 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', new_topic: 'shortcut.topic.new', 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 index 71ee311b58b..6289a6c0891 100644 --- a/src/main/data/migration/v2/migrators/mappings/__tests__/ShortcutMappings.test.ts +++ b/src/main/data/migration/v2/migrators/mappings/__tests__/ShortcutMappings.test.ts @@ -15,6 +15,11 @@ 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', ','], @@ -29,6 +34,10 @@ describe('transformShortcuts', () => { }) expect(result).toEqual({ + 'shortcut.feature.quick_assistant.toggle_window': { + binding: ['CommandOrControl', 'E'], + enabled: false + }, 'shortcut.general.show_settings': { binding: ['CommandOrControl', ','], enabled: true diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 33b48cb2d0a..bed5d0779d1 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -11,7 +11,7 @@ import type { BrowserWindow } from 'electron' import { globalShortcut } from 'electron' const logger = loggerService.withContext('ShortcutService') -const MINI_WINDOW_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.general.show_mini_window' +const QUICK_ASSISTANT_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.feature.quick_assistant.toggle_window' type ShortcutHandler = (window?: BrowserWindow) => void type RegisteredShortcut = { handler: ShortcutHandler; window: BrowserWindow } @@ -87,7 +87,7 @@ export class ShortcutService extends BaseService { navigateToSettings() }) - this.handlers.set('shortcut.general.show_mini_window', () => { + this.handlers.set('shortcut.feature.quick_assistant.toggle_window', () => { if (!application.get('PreferenceService').get('feature.quick_assistant.enabled')) return application.get('WindowService').toggleMiniWindow() }) @@ -176,7 +176,10 @@ export class ShortcutService extends BaseService { for (const definition of relevantDefinitions) { if (onlyPersistent && !definition.global) continue - if (definition.key === MINI_WINDOW_SHORTCUT_KEY && !preferenceService.get('feature.quick_assistant.enabled')) { + if ( + definition.key === QUICK_ASSISTANT_SHORTCUT_KEY && + !preferenceService.get('feature.quick_assistant.enabled') + ) { continue } diff --git a/src/main/services/__tests__/ShortcutService.test.ts b/src/main/services/__tests__/ShortcutService.test.ts index c8b14261fa6..2d871856fdc 100644 --- a/src/main/services/__tests__/ShortcutService.test.ts +++ b/src/main/services/__tests__/ShortcutService.test.ts @@ -168,7 +168,7 @@ describe('ShortcutService', () => { }) it('reacts to quick assistant enablement changes for mini window shortcut', async () => { - MockMainPreferenceServiceUtils.setPreferenceValue('shortcut.general.show_mini_window', { + MockMainPreferenceServiceUtils.setPreferenceValue('shortcut.feature.quick_assistant.toggle_window', { binding: ['CommandOrControl', 'E'], enabled: true }) diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index d903f86bf20..dbc4d67f842 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -147,7 +147,7 @@ export interface ShortcutListItem { defaultPreference: ResolvedShortcut } -const MINI_WINDOW_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.general.show_mini_window' +const QUICK_ASSISTANT_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.feature.quick_assistant.toggle_window' const SELECTION_SHORTCUT_CATEGORY = 'feature.selection' export const useAllShortcuts = () => { @@ -174,7 +174,7 @@ export const useAllShortcuts = () => { if (supported && platform && !supported.includes(platform as SupportedPlatform)) { return [] } - if (definition.key === MINI_WINDOW_SHORTCUT_KEY && !quickAssistantEnabled) { + if (definition.key === QUICK_ASSISTANT_SHORTCUT_KEY && !quickAssistantEnabled) { return [] } if (definition.category === SELECTION_SHORTCUT_CATEGORY && !selectionAssistantEnabled) { diff --git a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md index 7f4ebc08d88..99c73d0804a 100644 --- a/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md +++ b/v2-refactor-temp/docs/shortcuts/shortcut-system-refactor.md @@ -46,7 +46,7 @@ v2 的目标很明确: ```ts { - key: 'shortcut.general.show_mini_window', + key: 'shortcut.feature.quick_assistant.toggle_window', scope: 'main', category: 'general', labelKey: 'mini_window', @@ -171,7 +171,7 @@ type ResolvedShortcut = { 当前有两类依赖功能状态的快捷键: -- `shortcut.general.show_mini_window` 依赖 `feature.quick_assistant.enabled` +- `shortcut.feature.quick_assistant.toggle_window` 依赖 `feature.quick_assistant.enabled` - `feature.selection.*` 依赖 `feature.selection.enabled` 功能关闭时,这些快捷键不会注册,也不会在设置页显示。 @@ -189,7 +189,7 @@ type ResolvedShortcut = { | Key | 默认绑定 | Scope | 默认启用 | | --- | --- | --- | --- | | `shortcut.general.show_main_window` | *(无)* | main | 否 | -| `shortcut.general.show_mini_window` | `Cmd/Ctrl+E` | 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 | 是 | diff --git a/v2-refactor-temp/tools/data-classify/data/classification.json b/v2-refactor-temp/tools/data-classify/data/classification.json index 18151ac002b..1d9316c7cbe 100644 --- a/v2-refactor-temp/tools/data-classify/data/classification.json +++ b/v2-refactor-temp/tools/data-classify/data/classification.json @@ -2533,7 +2533,7 @@ "category": "preferences", "defaultValue": { "binding": [], - "enabled": true + "enabled": false }, "originalKey": "show_app", "status": "classified", @@ -2551,7 +2551,7 @@ }, "originalKey": "mini_window", "status": "classified", - "targetKey": "shortcut.general.show_mini_window", + "targetKey": "shortcut.feature.quick_assistant.toggle_window", "type": "PreferenceTypes.PreferenceShortcutType" }, { From ac742973ad45a7e0d4199a301c5bacae952ad613 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 12:18:15 +0800 Subject: [PATCH 27/37] refactor(shortcuts): reorganize and reintroduce quick assistant toggle shortcut definition --- packages/shared/shortcuts/definitions.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index 6748c44d6b7..c1d9dcc5d28 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -9,13 +9,6 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ labelKey: 'show_app', global: true }, - { - key: 'shortcut.feature.quick_assistant.toggle_window', - scope: 'main', - category: 'feature.quick_assistant', - labelKey: 'mini_window', - global: true - }, { key: 'shortcut.general.show_settings', scope: 'main', @@ -121,7 +114,14 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'topic', labelKey: 'toggle_show_topics' }, - // ==================== Selection assistant shortcuts ==================== + // ==================== Feature shortcuts ==================== + { + key: 'shortcut.feature.quick_assistant.toggle_window', + scope: 'main', + category: 'feature.quick_assistant', + labelKey: 'mini_window', + global: true + }, { key: 'shortcut.feature.selection.toggle_enabled', scope: 'main', From 37879ec75c9ecdaad37b6d996b74e27e3c144218 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 12:22:52 +0800 Subject: [PATCH 28/37] refactor(shortcuts): reorganize shortcut mappings and restore mini window shortcuts --- packages/shared/shortcuts/types.ts | 2 +- .../migrators/mappings/PreferencesMappings.ts | 24 +++++++++---------- .../v2/migrators/mappings/ShortcutMappings.ts | 8 +++---- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index ed6cac8bc54..92a1ea80ec2 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -21,7 +21,6 @@ export type ShortcutKey = ShortcutPreferenceKey extends `shortcut.${infer Rest}` export type ShortcutLabelKey = | 'show_app' - | 'mini_window' | 'show_settings' | 'toggle_sidebar' | 'exit_fullscreen' @@ -38,6 +37,7 @@ export type ShortcutLabelKey = | 'new_topic' | 'rename_topic' | 'toggle_show_topics' + | 'mini_window' | 'selection_assistant_toggle' | 'selection_assistant_select_text' diff --git a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts index 5967e88b512..97190c8b893 100644 --- a/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/PreferencesMappings.ts @@ -768,18 +768,6 @@ export const REDUX_STORE_MAPPINGS = { originalKey: 'shortcuts.show_app', targetKey: 'shortcut.general.show_main_window' }, - { - 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' - }, { originalKey: 'shortcuts.new_topic', targetKey: 'shortcut.topic.new' @@ -827,6 +815,18 @@ export const REDUX_STORE_MAPPINGS = { { originalKey: 'shortcuts.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: [ diff --git a/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts b/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts index 23138b178fa..78d957b5df1 100644 --- a/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts +++ b/src/main/data/migration/v2/migrators/mappings/ShortcutMappings.ts @@ -29,9 +29,6 @@ const LEGACY_KEY_TO_TARGET_KEY: Record = { zoom_reset: 'shortcut.general.zoom_reset', show_settings: 'shortcut.general.show_settings', show_app: 'shortcut.general.show_main_window', - 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', new_topic: 'shortcut.topic.new', rename_topic: 'shortcut.topic.rename', toggle_show_topics: 'shortcut.topic.toggle_show_topics', @@ -44,7 +41,10 @@ const LEGACY_KEY_TO_TARGET_KEY: Record = { 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' + 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))] From 96325fb10ba350368eb749e50ab5e3c3ca223c96 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 12:33:50 +0800 Subject: [PATCH 29/37] refactor(shortcuts): implement feature toggle for shortcut definitions and enhance shortcut dependency handling --- .../shortcuts/__tests__/shortcutUtils.test.ts | 17 ++++++++++ packages/shared/shortcuts/definitions.ts | 5 ++- packages/shared/shortcuts/types.ts | 3 ++ packages/shared/shortcuts/utils.ts | 11 +++++++ src/main/services/ShortcutService.ts | 31 ++++++++++-------- .../__tests__/ShortcutService.test.ts | 17 ++++++++++ src/renderer/src/hooks/useShortcuts.ts | 32 ++++++++++++------- 7 files changed, 91 insertions(+), 25 deletions(-) diff --git a/packages/shared/shortcuts/__tests__/shortcutUtils.test.ts b/packages/shared/shortcuts/__tests__/shortcutUtils.test.ts index af8b2a2fb6d..bdcce86d1df 100644 --- a/packages/shared/shortcuts/__tests__/shortcutUtils.test.ts +++ b/packages/shared/shortcuts/__tests__/shortcutUtils.test.ts @@ -7,6 +7,7 @@ import { convertKeyToAccelerator, formatShortcutDisplay, getDefaultShortcut, + isShortcutDefinitionEnabled, isValidShortcut, resolveShortcutPreference } from '../utils' @@ -157,6 +158,22 @@ describe('resolveShortcutPreference', () => { }) }) +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) diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index c1d9dcc5d28..cafa09b06a8 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -120,7 +120,8 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ scope: 'main', category: 'feature.quick_assistant', labelKey: 'mini_window', - global: true + global: true, + enabledWhen: 'feature.quick_assistant.enabled' }, { key: 'shortcut.feature.selection.toggle_enabled', @@ -128,6 +129,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'feature.selection', labelKey: 'selection_assistant_toggle', global: true, + enabledWhen: 'feature.selection.enabled', supportedPlatforms: ['darwin', 'win32'] }, { @@ -136,6 +138,7 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ category: 'feature.selection', labelKey: 'selection_assistant_select_text', global: true, + enabledWhen: 'feature.selection.enabled', supportedPlatforms: ['darwin', 'win32'] } ] as const diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index 92a1ea80ec2..17ad6638365 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -16,6 +16,7 @@ export type ShortcutCategory = BuiltinShortcutCategory | `plugin.${string}` export type SupportedPlatform = Extract export type ShortcutPreferenceKey = Extract +export type ShortcutDependencyPreferenceKey = Extract export type ShortcutKey = ShortcutPreferenceKey extends `shortcut.${infer Rest}` ? Rest : never @@ -59,6 +60,8 @@ export interface ShortcutDefinition { 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. */ diff --git a/packages/shared/shortcuts/utils.ts b/packages/shared/shortcuts/utils.ts index c0759b1cee3..48b79307001 100644 --- a/packages/shared/shortcuts/utils.ts +++ b/packages/shared/shortcuts/utils.ts @@ -125,3 +125,14 @@ export const resolveShortcutPreference = ( 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/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index bed5d0779d1..23c42a05910 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -6,13 +6,11 @@ import type { PreferenceShortcutType } from '@shared/data/preference/preferenceT import { IpcChannel } from '@shared/IpcChannel' import { SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions' import type { ShortcutPreferenceKey, SupportedPlatform } from '@shared/shortcuts/types' -import { resolveShortcutPreference } from '@shared/shortcuts/utils' +import { isShortcutDefinitionEnabled, resolveShortcutPreference } from '@shared/shortcuts/utils' import type { BrowserWindow } from 'electron' import { globalShortcut } from 'electron' const logger = loggerService.withContext('ShortcutService') -const QUICK_ASSISTANT_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.feature.quick_assistant.toggle_window' - type ShortcutHandler = (window?: BrowserWindow) => void type RegisteredShortcut = { handler: ShortcutHandler; window: BrowserWindow } @@ -124,12 +122,22 @@ export class ShortcutService extends BaseService { ) } - this.registerDisposable( - preferenceService.subscribeChange('feature.quick_assistant.enabled', () => { - logger.debug('Shortcut dependency changed: feature.quick_assistant.enabled') - 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 { @@ -176,10 +184,7 @@ export class ShortcutService extends BaseService { for (const definition of relevantDefinitions) { if (onlyPersistent && !definition.global) continue - if ( - definition.key === QUICK_ASSISTANT_SHORTCUT_KEY && - !preferenceService.get('feature.quick_assistant.enabled') - ) { + if (!isShortcutDefinitionEnabled(definition, (key) => preferenceService.get(key))) { continue } diff --git a/src/main/services/__tests__/ShortcutService.test.ts b/src/main/services/__tests__/ShortcutService.test.ts index 2d871856fdc..3ca28489125 100644 --- a/src/main/services/__tests__/ShortcutService.test.ts +++ b/src/main/services/__tests__/ShortcutService.test.ts @@ -184,6 +184,23 @@ describe('ShortcutService', () => { 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) + + expect(globalShortcutMock.register).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() diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index dbc4d67f842..fdd03998fc8 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -3,11 +3,18 @@ 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, ShortcutKey, ShortcutPreferenceKey, SupportedPlatform } from '@shared/shortcuts/types' +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' @@ -41,6 +48,16 @@ const shortcutPreferenceKeyMap = SHORTCUT_DEFINITIONS.reduce>( + (acc, definition) => { + if (definition.enabledWhen) { + acc[definition.enabledWhen] = definition.enabledWhen + } + return acc + }, + {} +) + const buildNextPreference = ( state: ResolvedShortcut, currentValue: PreferenceShortcutType | undefined, @@ -147,13 +164,9 @@ export interface ShortcutListItem { defaultPreference: ResolvedShortcut } -const QUICK_ASSISTANT_SHORTCUT_KEY: ShortcutPreferenceKey = 'shortcut.feature.quick_assistant.toggle_window' -const SELECTION_SHORTCUT_CATEGORY = 'feature.selection' - export const useAllShortcuts = () => { const [values, setValues] = useMultiplePreferences(shortcutPreferenceKeyMap) - const [quickAssistantEnabled] = usePreference('feature.quick_assistant.enabled') - const [selectionAssistantEnabled] = usePreference('feature.selection.enabled') + const [dependencyValues] = useMultiplePreferences(shortcutDependencyPreferenceKeyMap) const updatePreference = useCallback( async (key: ShortcutPreferenceKey, patch: Partial) => { @@ -174,10 +187,7 @@ export const useAllShortcuts = () => { if (supported && platform && !supported.includes(platform as SupportedPlatform)) { return [] } - if (definition.key === QUICK_ASSISTANT_SHORTCUT_KEY && !quickAssistantEnabled) { - return [] - } - if (definition.category === SELECTION_SHORTCUT_CATEGORY && !selectionAssistantEnabled) { + if (!isShortcutDefinitionEnabled(definition, (key) => dependencyValues[key])) { return [] } @@ -197,7 +207,7 @@ export const useAllShortcuts = () => { } ] }), - [quickAssistantEnabled, selectionAssistantEnabled, values] + [dependencyValues, values] ) return { shortcuts, updatePreference } From 39651e74b763ce45c8322c7ebf0bb5fc766f9d0b Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 12:41:31 +0800 Subject: [PATCH 30/37] refactor(shortcuts): streamline default shortcut preference retrieval and update reset logic --- src/main/services/AppMenuService.ts | 3 --- src/renderer/src/hooks/useShortcuts.ts | 14 ++++++++++++++ .../src/pages/settings/ShortcutSettings.tsx | 11 ++--------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/main/services/AppMenuService.ts b/src/main/services/AppMenuService.ts index 61924169b3d..5f45610fc95 100644 --- a/src/main/services/AppMenuService.ts +++ b/src/main/services/AppMenuService.ts @@ -104,19 +104,16 @@ export class AppMenuService extends BaseService { { label: appMenu.resetZoom, accelerator: zoomResetAccelerator, - enabled: !!zoomResetAccelerator, click: () => handleZoomFactor(getMainWindows(), 0, true) }, { label: appMenu.zoomIn, accelerator: zoomInAccelerator, - enabled: !!zoomInAccelerator, click: () => handleZoomFactor(getMainWindows(), 0.1) }, { label: appMenu.zoomOut, accelerator: zoomOutAccelerator, - enabled: !!zoomOutAccelerator, click: () => handleZoomFactor(getMainWindows(), -0.1) }, { type: 'separator' }, diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index fdd03998fc8..a95e6442cd5 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -164,6 +164,20 @@ export interface ShortcutListItem { 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) diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index d13b817e504..fe3017e382e 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -4,7 +4,7 @@ import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { isMac } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' -import { useAllShortcuts } from '@renderer/hooks/useShortcuts' +import { getAllShortcutDefaultPreferences, useAllShortcuts } from '@renderer/hooks/useShortcuts' import { useTimer } from '@renderer/hooks/useTimer' import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes' import type { ShortcutPreferenceKey } from '@shared/shortcuts/types' @@ -176,14 +176,7 @@ const ShortcutSettings: FC = () => { title: t('settings.shortcuts.reset_defaults_confirm'), centered: true, onOk: async () => { - const updates: Record = {} - - shortcuts.forEach((item) => { - updates[item.definition.key] = { - binding: item.defaultPreference.binding, - enabled: item.defaultPreference.enabled - } - }) + const updates: Record = getAllShortcutDefaultPreferences() try { await preferenceService.setMultiple(updates) From c8ea7fe2e3c8c9939226260c7ffaecce4116cd27 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 12:49:14 +0800 Subject: [PATCH 31/37] refactor(shortcuts): enhance selection shortcut registration logic based on platform support --- src/main/services/__tests__/ShortcutService.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/services/__tests__/ShortcutService.test.ts b/src/main/services/__tests__/ShortcutService.test.ts index 3ca28489125..819b1767e81 100644 --- a/src/main/services/__tests__/ShortcutService.test.ts +++ b/src/main/services/__tests__/ShortcutService.test.ts @@ -74,6 +74,8 @@ import { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceServi import { ShortcutService } from '../ShortcutService' +const supportsSelectionShortcuts = ['darwin', 'win32'].includes(process.platform) + class MockBrowserWindow { private readonly events = new EventEmitter() private readonly webContentsEvents = new EventEmitter() @@ -198,7 +200,11 @@ describe('ShortcutService', () => { globalShortcutMock.register.mockClear() MockMainPreferenceServiceUtils.setPreferenceValue('feature.selection.enabled', true) - expect(globalShortcutMock.register).toHaveBeenCalledWith('CommandOrControl+Shift+S', expect.any(Function)) + 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 () => { From e986bc685f524c51a31123fd6c0c0f5fc026a3b6 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 14:17:42 +0800 Subject: [PATCH 32/37] refactor: rename toggle_sidebar shortcut to toggle_show_assistants and update shortcut management logic - Changed the key of the shortcut from 'toggle_sidebar' to 'toggle_show_assistants'. - Introduced a new function getSerializableShortcuts to prepare shortcuts for serialization. - Updated the updateShortcut, toggleShortcut, and resetShortcuts reducers to use the new serialization function when updating shortcuts. --- src/renderer/src/store/migrate.ts | 401 ++++++++++++++-------------- src/renderer/src/store/shortcuts.ts | 18 +- 2 files changed, 213 insertions(+), 206 deletions(-) diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 34e698d8485..f79013452cd 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -63,15 +63,12 @@ import { mcpSlice } from './mcp' import { initialState as notesInitialState } from './note' // import { defaultActionItems } from './selectionStore' import { initialState as settingsInitialState } from './settings' -import { initialState as shortcutsInitialState, type ShortcutsState } from './shortcuts' +import { initialState as shortcutsInitialState } from './shortcuts' import { defaultWebSearchProviders } from './websearch' const logger = loggerService.withContext('Migrate') -// Legacy state type for migrations — includes `shortcuts` which was removed from the active store -type MigrationState = RootState & { shortcuts?: ShortcutsState } - // remove logo base64 data to reduce the size of the state -function removeMiniAppIconsFromState(state: MigrationState) { +function removeMiniAppIconsFromState(state: RootState) { if (state.minapps) { state.minapps.enabled = state.minapps.enabled.map((app) => ({ ...app, @@ -118,7 +115,7 @@ function addProvider(state: RootState, id: string) { } // Fix missing provider -function fixMissingProvider(state: MigrationState) { +function fixMissingProvider(state: RootState) { SYSTEM_PROVIDERS.forEach((p) => { if (!state.llm.providers.find((provider) => provider.id === p.id)) { state.llm.providers.push(p) @@ -187,7 +184,7 @@ function addSelectionAction(state: RootState, id: string) { * if afterId is 'first', add to the first * if afterId is 'last', add to the last */ -function addShortcuts(state: MigrationState, ids: string[], afterId: string) { +function addShortcuts(state: RootState, ids: string[], afterId: string) { const defaultShortcuts = shortcutsInitialState.shortcuts // 确保 state.shortcuts 存在 @@ -237,7 +234,7 @@ function addPreprocessProviders(state: RootState, id: string) { } const migrateConfig = { - '2': (state: MigrationState) => { + '2': (state: RootState) => { try { addProvider(state, 'yi') return state @@ -245,7 +242,7 @@ const migrateConfig = { return state } }, - '3': (state: MigrationState) => { + '3': (state: RootState) => { try { addProvider(state, 'zhipu') return state @@ -253,7 +250,7 @@ const migrateConfig = { return state } }, - '4': (state: MigrationState) => { + '4': (state: RootState) => { try { addProvider(state, 'ollama') return state @@ -261,7 +258,7 @@ const migrateConfig = { return state } }, - '5': (state: MigrationState) => { + '5': (state: RootState) => { try { addProvider(state, 'moonshot') return state @@ -269,7 +266,7 @@ const migrateConfig = { return state } }, - '6': (state: MigrationState) => { + '6': (state: RootState) => { try { addProvider(state, 'openrouter') return state @@ -277,7 +274,7 @@ const migrateConfig = { return state } }, - '7': (state: MigrationState) => { + '7': (state: RootState) => { try { return { ...state, @@ -290,7 +287,7 @@ const migrateConfig = { return state } }, - '8': (state: MigrationState) => { + '8': (state: RootState) => { try { const fixAssistantName = (assistant: Assistant) => { // 2025/07/25 这俩键早没了,从远古版本迁移包出错的 @@ -320,7 +317,7 @@ const migrateConfig = { return state } }, - '9': (state: MigrationState) => { + '9': (state: RootState) => { try { return { ...state, @@ -338,7 +335,7 @@ const migrateConfig = { return state } }, - '10': (state: MigrationState) => { + '10': (state: RootState) => { try { addProvider(state, 'baichuan') return state @@ -346,7 +343,7 @@ const migrateConfig = { return state } }, - '11': (state: MigrationState) => { + '11': (state: RootState) => { try { addProvider(state, 'dashscope') addProvider(state, 'anthropic') @@ -355,7 +352,7 @@ const migrateConfig = { return state } }, - '12': (state: MigrationState) => { + '12': (state: RootState) => { try { addProvider(state, 'aihubmix') return state @@ -363,7 +360,7 @@ const migrateConfig = { return state } }, - '13': (state: MigrationState) => { + '13': (state: RootState) => { try { return { ...state, @@ -381,7 +378,7 @@ const migrateConfig = { return state } }, - '14': (state: MigrationState) => { + '14': (state: RootState) => { try { return { ...state, @@ -395,7 +392,7 @@ const migrateConfig = { return state } }, - '15': (state: MigrationState) => { + '15': (state: RootState) => { try { return { ...state, @@ -409,7 +406,7 @@ const migrateConfig = { return state } }, - '16': (state: MigrationState) => { + '16': (state: RootState) => { try { return { ...state, @@ -423,7 +420,7 @@ const migrateConfig = { return state } }, - '17': (state: MigrationState) => { + '17': (state: RootState) => { try { return { ...state, @@ -436,7 +433,7 @@ const migrateConfig = { return state } }, - '19': (state: MigrationState) => { + '19': (state: RootState) => { try { return { ...state, @@ -456,7 +453,7 @@ const migrateConfig = { return state } }, - '20': (state: MigrationState) => { + '20': (state: RootState) => { try { return { ...state, @@ -469,7 +466,7 @@ const migrateConfig = { return state } }, - '21': (state: MigrationState) => { + '21': (state: RootState) => { try { addProvider(state, 'gemini') addProvider(state, 'stepfun') @@ -479,7 +476,7 @@ const migrateConfig = { return state } }, - '22': (state: MigrationState) => { + '22': (state: RootState) => { try { addProvider(state, 'minimax') return state @@ -487,7 +484,7 @@ const migrateConfig = { return state } }, - '23': (state: MigrationState) => { + '23': (state: RootState) => { try { return { ...state, @@ -501,7 +498,7 @@ const migrateConfig = { return state } }, - '24': (state: MigrationState) => { + '24': (state: RootState) => { try { return { ...state, @@ -525,7 +522,7 @@ const migrateConfig = { return state } }, - '25': (state: MigrationState) => { + '25': (state: RootState) => { try { addProvider(state, 'github') return state @@ -533,7 +530,7 @@ const migrateConfig = { return state } }, - '26': (state: MigrationState) => { + '26': (state: RootState) => { try { addProvider(state, 'ocoolai') return state @@ -541,7 +538,7 @@ const migrateConfig = { return state } }, - '27': (state: MigrationState) => { + '27': (state: RootState) => { try { return { ...state, @@ -554,7 +551,7 @@ const migrateConfig = { return state } }, - '28': (state: MigrationState) => { + '28': (state: RootState) => { try { addProvider(state, 'together') addProvider(state, 'fireworks') @@ -566,7 +563,7 @@ const migrateConfig = { return state } }, - '29': (state: MigrationState) => { + '29': (state: RootState) => { try { return { ...state, @@ -585,7 +582,7 @@ const migrateConfig = { return state } }, - '30': (state: MigrationState) => { + '30': (state: RootState) => { try { addProvider(state, 'azure-openai') return state @@ -593,7 +590,7 @@ const migrateConfig = { return state } }, - '31': (state: MigrationState) => { + '31': (state: RootState) => { try { return { ...state, @@ -614,7 +611,7 @@ const migrateConfig = { return state } }, - '32': (state: MigrationState) => { + '32': (state: RootState) => { try { addProvider(state, 'hunyuan') return state @@ -622,7 +619,7 @@ const migrateConfig = { return state } }, - '33': (state: MigrationState) => { + '33': (state: RootState) => { try { state.assistants.defaultAssistant.type = 'assistant' @@ -652,7 +649,7 @@ const migrateConfig = { return state } }, - '34': (state: MigrationState) => { + '34': (state: RootState) => { try { state.assistants.assistants.forEach((assistant) => { assistant.topics.forEach((topic) => { @@ -674,7 +671,7 @@ const migrateConfig = { return state } }, - '35': (state: MigrationState) => { + '35': (state: RootState) => { try { state.settings.mathEngine = 'KaTeX' return state @@ -682,7 +679,7 @@ const migrateConfig = { return state } }, - '36': (state: MigrationState) => { + '36': (state: RootState) => { try { state.settings.topicPosition = 'left' return state @@ -690,7 +687,7 @@ const migrateConfig = { return state } }, - '37': (state: MigrationState) => { + '37': (state: RootState) => { try { state.settings.messageStyle = 'plain' return state @@ -698,7 +695,7 @@ const migrateConfig = { return state } }, - '38': (state: MigrationState) => { + '38': (state: RootState) => { try { addProvider(state, 'grok') addProvider(state, 'hyperbolic') @@ -708,7 +705,7 @@ const migrateConfig = { return state } }, - '39': (state: MigrationState) => { + '39': (state: RootState) => { try { // @ts-ignore eslint-disable-next-line state.settings.codeStyle = 'auto' @@ -717,7 +714,7 @@ const migrateConfig = { return state } }, - '40': (state: MigrationState) => { + '40': (state: RootState) => { try { state.settings.tray = true return state @@ -725,7 +722,7 @@ const migrateConfig = { return state } }, - '41': (state: MigrationState) => { + '41': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'gemini') { @@ -741,7 +738,7 @@ const migrateConfig = { return state } }, - '42': (state: MigrationState) => { + '42': (state: RootState) => { try { state.settings.proxyMode = state.settings.proxyUrl ? 'custom' : 'none' return state @@ -749,7 +746,7 @@ const migrateConfig = { return state } }, - '43': (state: MigrationState) => { + '43': (state: RootState) => { try { if (state.settings.proxyMode === 'none') { state.settings.proxyMode = 'system' @@ -759,7 +756,7 @@ const migrateConfig = { return state } }, - '44': (state: MigrationState) => { + '44': (state: RootState) => { try { state.settings.translateModelPrompt = TRANSLATE_PROMPT return state @@ -767,11 +764,11 @@ const migrateConfig = { return state } }, - '45': (state: MigrationState) => { + '45': (state: RootState) => { state.settings.enableTopicNaming = true return state }, - '46': (state: MigrationState) => { + '46': (state: RootState) => { try { if ( state.settings?.translateModelPrompt?.includes( @@ -785,7 +782,7 @@ const migrateConfig = { return state } }, - '47': (state: MigrationState) => { + '47': (state: RootState) => { try { state.llm.providers.forEach((provider) => { provider.models.forEach((model) => { @@ -797,14 +794,14 @@ const migrateConfig = { return state } }, - '48': (state: MigrationState) => { + '48': (state: RootState) => { try { if (state.shortcuts) { state.shortcuts.shortcuts.forEach((shortcut) => { shortcut.system = shortcut.key !== 'new_topic' }) state.shortcuts.shortcuts.push({ - key: 'toggle_sidebar', + key: 'toggle_show_assistants', shortcut: [isMac ? 'Command' : 'Ctrl', '['], editable: true, enabled: true, @@ -823,7 +820,7 @@ const migrateConfig = { return state } }, - '49': (state: MigrationState) => { + '49': (state: RootState) => { try { state.settings.pasteLongTextThreshold = 1500 if (state.shortcuts) { @@ -843,7 +840,7 @@ const migrateConfig = { return state } }, - '50': (state: MigrationState) => { + '50': (state: RootState) => { try { addProvider(state, 'jina') return state @@ -851,11 +848,11 @@ const migrateConfig = { return state } }, - '51': (state: MigrationState) => { + '51': (state: RootState) => { state.settings.topicNamingPrompt = '' return state }, - '54': (state: MigrationState) => { + '54': (state: RootState) => { try { if (state.shortcuts) { state.shortcuts.shortcuts.push({ @@ -875,7 +872,7 @@ const migrateConfig = { return state } }, - '55': (state: MigrationState) => { + '55': (state: RootState) => { try { if (!state.settings.sidebarIcons) { state.settings.sidebarIcons = { @@ -888,7 +885,7 @@ const migrateConfig = { return state } }, - '57': (state: MigrationState) => { + '57': (state: RootState) => { try { if (state.shortcuts) { state.shortcuts.shortcuts.push({ @@ -915,7 +912,7 @@ const migrateConfig = { return state } }, - '58': (state: MigrationState) => { + '58': (state: RootState) => { try { if (state.shortcuts) { state.shortcuts.shortcuts.push( @@ -940,7 +937,7 @@ const migrateConfig = { return state } }, - '59': (state: MigrationState) => { + '59': (state: RootState) => { try { addMiniApp(state, 'flowith') return state @@ -948,7 +945,7 @@ const migrateConfig = { return state } }, - '60': (state: MigrationState) => { + '60': (state: RootState) => { try { state.settings.multiModelMessageStyle = 'fold' return state @@ -956,7 +953,7 @@ const migrateConfig = { return state } }, - '61': (state: MigrationState) => { + '61': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'qwenlm') { @@ -969,7 +966,7 @@ const migrateConfig = { return state } }, - '62': (state: MigrationState) => { + '62': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'azure-openai') { @@ -982,7 +979,7 @@ const migrateConfig = { return state } }, - '63': (state: MigrationState) => { + '63': (state: RootState) => { try { addMiniApp(state, '3mintop') return state @@ -990,7 +987,7 @@ const migrateConfig = { return state } }, - '64': (state: MigrationState) => { + '64': (state: RootState) => { try { state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'qwenlm') addProvider(state, 'baidu-cloud') @@ -999,7 +996,7 @@ const migrateConfig = { return state } }, - '65': (state: MigrationState) => { + '65': (state: RootState) => { try { // @ts-ignore expect error state.settings.targetLanguage = 'english' @@ -1008,7 +1005,7 @@ const migrateConfig = { return state } }, - '66': (state: MigrationState) => { + '66': (state: RootState) => { try { addProvider(state, 'gitee-ai') addProvider(state, 'ppio') @@ -1020,7 +1017,7 @@ const migrateConfig = { return state } }, - '67': (state: MigrationState) => { + '67': (state: RootState) => { try { addMiniApp(state, 'xiaoyi') addProvider(state, 'modelscope') @@ -1038,7 +1035,7 @@ const migrateConfig = { return state } }, - '68': (state: MigrationState) => { + '68': (state: RootState) => { try { addMiniApp(state, 'notebooklm') addProvider(state, 'modelscope') @@ -1048,7 +1045,7 @@ const migrateConfig = { return state } }, - '69': (state: MigrationState) => { + '69': (state: RootState) => { try { addMiniApp(state, 'coze') state.settings.gridColumns = 2 @@ -1058,7 +1055,7 @@ const migrateConfig = { return state } }, - '70': (state: MigrationState) => { + '70': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'dmxapi') { @@ -1070,7 +1067,7 @@ const migrateConfig = { return state } }, - '71': (state: MigrationState) => { + '71': (state: RootState) => { try { const appIds = ['dify', 'wpslingxi', 'lechat', 'abacus', 'lambdachat', 'baidu-ai-search'] @@ -1093,7 +1090,7 @@ const migrateConfig = { return state } }, - '72': (state: MigrationState) => { + '72': (state: RootState) => { try { addMiniApp(state, 'monica') @@ -1111,7 +1108,7 @@ const migrateConfig = { return state } }, - '73': (state: MigrationState) => { + '73': (state: RootState) => { try { if (state.websearch) { state.websearch.searchWithTime = true @@ -1154,7 +1151,7 @@ const migrateConfig = { return state } }, - '74': (state: MigrationState) => { + '74': (state: RootState) => { try { addProvider(state, 'xirang') return state @@ -1162,7 +1159,7 @@ const migrateConfig = { return state } }, - '75': (state: MigrationState) => { + '75': (state: RootState) => { try { addMiniApp(state, 'you') addMiniApp(state, 'cici') @@ -1172,7 +1169,7 @@ const migrateConfig = { return state } }, - '76': (state: MigrationState) => { + '76': (state: RootState) => { try { addProvider(state, 'tencent-cloud-ti') return state @@ -1180,7 +1177,7 @@ const migrateConfig = { return state } }, - '77': (state: MigrationState) => { + '77': (state: RootState) => { try { addWebSearchProvider(state, 'searxng') addWebSearchProvider(state, 'exa') @@ -1195,7 +1192,7 @@ const migrateConfig = { return state } }, - '78': (state: MigrationState) => { + '78': (state: RootState) => { try { state.llm.providers = moveProvider(state.llm.providers, 'ppio', 9) state.llm.providers = moveProvider(state.llm.providers, 'infini', 10) @@ -1205,7 +1202,7 @@ const migrateConfig = { return state } }, - '79': (state: MigrationState) => { + '79': (state: RootState) => { try { addProvider(state, 'gpustack') return state @@ -1213,7 +1210,7 @@ const migrateConfig = { return state } }, - '80': (state: MigrationState) => { + '80': (state: RootState) => { try { addProvider(state, 'alayanew') state.llm.providers = moveProvider(state.llm.providers, 'alayanew', 10) @@ -1222,7 +1219,7 @@ const migrateConfig = { return state } }, - '81': (state: MigrationState) => { + '81': (state: RootState) => { try { addProvider(state, 'copilot') return state @@ -1230,7 +1227,7 @@ const migrateConfig = { return state } }, - '82': (state: MigrationState) => { + '82': (state: RootState) => { try { const runtimeState = state.runtime as any if (runtimeState?.webdavSync) { @@ -1250,7 +1247,7 @@ const migrateConfig = { return state } }, - '83': (state: MigrationState) => { + '83': (state: RootState) => { try { state.settings.messageNavigation = 'buttons' state.settings.launchOnBoot = false @@ -1262,7 +1259,7 @@ const migrateConfig = { return state } }, - '84': (state: MigrationState) => { + '84': (state: RootState) => { try { addProvider(state, 'voyageai') return state @@ -1271,7 +1268,7 @@ const migrateConfig = { return state } }, - '85': (state: MigrationState) => { + '85': (state: RootState) => { try { // @ts-ignore eslint-disable-next-line state.settings.autoCheckUpdate = !state.settings.manualUpdateCheck @@ -1283,7 +1280,7 @@ const migrateConfig = { return state } }, - '86': (state: MigrationState) => { + '86': (state: RootState) => { try { if (state?.mcp?.servers) { state.mcp.servers = state.mcp.servers.map((server) => ({ @@ -1296,7 +1293,7 @@ const migrateConfig = { } return state }, - '87': (state: MigrationState) => { + '87': (state: RootState) => { try { state.settings.maxKeepAliveMinapps = 3 state.settings.showOpenedMinappsInSidebar = true @@ -1305,7 +1302,7 @@ const migrateConfig = { return state } }, - '88': (state: MigrationState) => { + '88': (state: RootState) => { try { if (state?.mcp?.servers) { const hasAutoInstall = state.mcp.servers.some((server) => server.name === '@cherry/mcp-auto-install') @@ -1319,7 +1316,7 @@ const migrateConfig = { return state } }, - '89': (state: MigrationState) => { + '89': (state: RootState) => { try { removeMiniAppFromState(state, 'aistudio') return state @@ -1327,7 +1324,7 @@ const migrateConfig = { return state } }, - '90': (state: MigrationState) => { + '90': (state: RootState) => { try { state.settings.enableDataCollection = true return state @@ -1335,7 +1332,7 @@ const migrateConfig = { return state } }, - '91': (state: MigrationState) => { + '91': (state: RootState) => { try { // @ts-ignore eslint-disable-next-line state.settings.codeCacheable = false @@ -1351,7 +1348,7 @@ const migrateConfig = { return state } }, - '92': (state: MigrationState) => { + '92': (state: RootState) => { try { addMiniApp(state, 'dangbei') state.llm.providers = moveProvider(state.llm.providers, 'qiniu', 12) @@ -1360,7 +1357,7 @@ const migrateConfig = { return state } }, - '93': (state: MigrationState) => { + '93': (state: RootState) => { try { if (!state?.settings?.exportMenuOptions) { state.settings.exportMenuOptions = settingsInitialState.exportMenuOptions @@ -1371,7 +1368,7 @@ const migrateConfig = { return state } }, - '94': (state: MigrationState) => { + '94': (state: RootState) => { try { state.settings.enableQuickPanelTriggers = false return state @@ -1379,7 +1376,7 @@ const migrateConfig = { return state } }, - '95': (state: MigrationState) => { + '95': (state: RootState) => { try { addWebSearchProvider(state, 'local-google') addWebSearchProvider(state, 'local-bing') @@ -1400,7 +1397,7 @@ const migrateConfig = { return state } }, - '96': (state: MigrationState) => { + '96': (state: RootState) => { try { // @ts-ignore eslint-disable-next-line state.settings.assistantIconType = state.settings?.showAssistantIcon ? 'model' : 'emoji' @@ -1411,7 +1408,7 @@ const migrateConfig = { return state } }, - '97': (state: MigrationState) => { + '97': (state: RootState) => { try { addMiniApp(state, 'zai') state.settings.webdavMaxBackups = 0 @@ -1426,7 +1423,7 @@ const migrateConfig = { return state } }, - '98': (state: MigrationState) => { + '98': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.type === 'openai' && provider.id !== 'openai') { @@ -1439,7 +1436,7 @@ const migrateConfig = { return state } }, - '99': (state: MigrationState) => { + '99': (state: RootState) => { try { state.settings.showPrompt = true @@ -1471,7 +1468,7 @@ const migrateConfig = { return state } }, - '100': (state: MigrationState) => { + '100': (state: RootState) => { try { state.llm.providers.forEach((provider) => { // @ts-ignore eslint-disable-next-line @@ -1491,7 +1488,7 @@ const migrateConfig = { return state } }, - '101': (state: MigrationState) => { + '101': (state: RootState) => { try { state.assistants.assistants.forEach((assistant) => { if (assistant.settings) { @@ -1519,7 +1516,7 @@ const migrateConfig = { return state } }, - '102': (state: MigrationState) => { + '102': (state: RootState) => { try { state.settings.openAI = { // @ts-expect-error it's a removed type. migrated on 177 @@ -1571,7 +1568,7 @@ const migrateConfig = { return state } }, - '103': (state: MigrationState) => { + '103': (state: RootState) => { try { if (state.shortcuts) { if (!state.shortcuts.shortcuts.find((shortcut) => shortcut.key === 'search_message_in_chat')) { @@ -1600,7 +1597,7 @@ const migrateConfig = { return state } }, - '104': (state: MigrationState) => { + '104': (state: RootState) => { try { addProvider(state, 'burncloud') state.llm.providers = moveProvider(state.llm.providers, 'burncloud', 10) @@ -1610,7 +1607,7 @@ const migrateConfig = { return state } }, - '105': (state: MigrationState) => { + '105': (state: RootState) => { try { state.settings.notification = settingsInitialState.notification addMiniApp(state, 'google') @@ -1628,7 +1625,7 @@ const migrateConfig = { return state } }, - '106': (state: MigrationState) => { + '106': (state: RootState) => { try { addProvider(state, 'tokenflux') state.llm.providers = moveProvider(state.llm.providers, 'tokenflux', 15) @@ -1638,7 +1635,7 @@ const migrateConfig = { return state } }, - '107': (state: MigrationState) => { + '107': (state: RootState) => { try { if (state.paintings && !state.paintings.dmxapi_paintings) { state.paintings.dmxapi_paintings = [] @@ -1649,7 +1646,7 @@ const migrateConfig = { return state } }, - '108': (state: MigrationState) => { + '108': (state: RootState) => { try { // @ts-ignore state.inputTools.toolOrder = DEFAULT_TOOL_ORDER @@ -1660,7 +1657,7 @@ const migrateConfig = { return state } }, - '109': (state: MigrationState) => { + '109': (state: RootState) => { try { state.settings.userTheme = settingsInitialState.userTheme return state @@ -1669,7 +1666,7 @@ const migrateConfig = { return state } }, - '110': (state: MigrationState) => { + '110': (state: RootState) => { try { if (state.paintings && !state.paintings.tokenflux_paintings) { state.paintings.tokenflux_paintings = [] @@ -1681,7 +1678,7 @@ const migrateConfig = { return state } }, - '111': (state: MigrationState) => { + '111': (state: RootState) => { try { addSelectionAction(state, 'quote') if ( @@ -1700,7 +1697,7 @@ const migrateConfig = { return state } }, - '112': (state: MigrationState) => { + '112': (state: RootState) => { try { addProvider(state, 'cephalon') addProvider(state, '302ai') @@ -1714,7 +1711,7 @@ const migrateConfig = { return state } }, - '113': (state: MigrationState) => { + '113': (state: RootState) => { try { addProvider(state, 'vertexai') if (!state.llm.settings.vertexai) { @@ -1732,7 +1729,7 @@ const migrateConfig = { return state } }, - '114': (state: MigrationState) => { + '114': (state: RootState) => { try { if (state.settings && state.settings.exportMenuOptions) { if (typeof state.settings.exportMenuOptions.plain_text === 'undefined') { @@ -1749,7 +1746,7 @@ const migrateConfig = { return state } }, - '115': (state: MigrationState) => { + '115': (state: RootState) => { try { state.assistants.assistants.forEach((assistant) => { if (!assistant.settings) { @@ -1770,7 +1767,7 @@ const migrateConfig = { return state } }, - '116': (state: MigrationState) => { + '116': (state: RootState) => { try { if (state.websearch) { // migrate contentLimit to cutoffLimit @@ -1802,7 +1799,7 @@ const migrateConfig = { return state } }, - '117': (state: MigrationState) => { + '117': (state: RootState) => { try { const ppioProvider = state.llm.providers.find((provider) => provider.id === 'ppio') const modelsToRemove = [ @@ -1840,7 +1837,7 @@ const migrateConfig = { return state } }, - '118': (state: MigrationState) => { + '118': (state: RootState) => { try { addProvider(state, 'ph8') state.llm.providers = moveProvider(state.llm.providers, 'ph8', 14) @@ -1861,7 +1858,7 @@ const migrateConfig = { return state } }, - '119': (state: MigrationState) => { + '119': (state: RootState) => { try { addProvider(state, 'new-api') state.llm.providers = moveProvider(state.llm.providers, 'new-api', 16) @@ -1879,7 +1876,7 @@ const migrateConfig = { return state } }, - '120': (state: MigrationState) => { + '120': (state: RootState) => { try { // migrate to remove memory feature from sidebar (moved to settings) if (state.settings && state.settings.sidebarIcons) { @@ -1927,7 +1924,7 @@ const migrateConfig = { return state } }, - '121': (state: MigrationState) => { + '121': (state: RootState) => { try { const { toolOrder } = state.inputTools const urlContextKey = 'url_context' @@ -1965,7 +1962,7 @@ const migrateConfig = { return state } }, - '122': (state: MigrationState) => { + '122': (state: RootState) => { try { state.settings.navbarPosition = 'left' return state @@ -1975,7 +1972,7 @@ const migrateConfig = { } }, - '123': (state: MigrationState) => { + '123': (state: RootState) => { try { state.llm.providers.forEach((provider) => { provider.models.forEach((model) => { @@ -2000,7 +1997,7 @@ const migrateConfig = { return state } }, // 1.5.4 - '124': (state: MigrationState) => { + '124': (state: RootState) => { try { state.assistants.assistants.forEach((assistant) => { if (assistant.settings && !assistant.settings.toolUseMode) { @@ -2049,7 +2046,7 @@ const migrateConfig = { return state } }, - '125': (state: MigrationState) => { + '125': (state: RootState) => { try { // Initialize API server configuration if not present if (!state.settings.apiServer) { @@ -2066,7 +2063,7 @@ const migrateConfig = { return state } }, - '126': (state: MigrationState) => { + '126': (state: RootState) => { try { state.knowledge.bases.forEach((base) => { // @ts-ignore eslint-disable-next-line @@ -2088,7 +2085,7 @@ const migrateConfig = { return state } }, - '127': (state: MigrationState) => { + '127': (state: RootState) => { try { addProvider(state, 'poe') @@ -2129,7 +2126,7 @@ const migrateConfig = { return state } }, - '128': (state: MigrationState) => { + '128': (state: RootState) => { try { // 迁移 service tier 设置 const openai = state.llm.providers.find((provider) => provider.id === SystemProviderIds.openai) @@ -2155,7 +2152,7 @@ const migrateConfig = { return state } }, - '129': (state: MigrationState) => { + '129': (state: RootState) => { try { // 聚合 api options state.llm.providers.forEach((p) => { @@ -2177,7 +2174,7 @@ const migrateConfig = { return state } }, - '130': (state: MigrationState) => { + '130': (state: RootState) => { try { if (state.settings && state.settings.openAI && !state.settings.openAI.verbosity) { state.settings.openAI.verbosity = 'medium' @@ -2192,7 +2189,7 @@ const migrateConfig = { return state } }, - '131': (state: MigrationState) => { + '131': (state: RootState) => { try { state.settings.mathEnableSingleDollar = true return state @@ -2201,7 +2198,7 @@ const migrateConfig = { return state } }, - '132': (state: MigrationState) => { + '132': (state: RootState) => { try { state.llm.providers.forEach((p) => { // 如果原本是undefined则不做改动,静默从默认支持改为默认不支持 @@ -2218,7 +2215,7 @@ const migrateConfig = { return state } }, - '133': (state: MigrationState) => { + '133': (state: RootState) => { try { state.settings.sidebarIcons.visible.push('code_tools') if (state.codeTools) { @@ -2234,7 +2231,7 @@ const migrateConfig = { return state } }, - '134': (state: MigrationState) => { + '134': (state: RootState) => { try { state.llm.quickModel = state.llm.topicNamingModel @@ -2244,7 +2241,7 @@ const migrateConfig = { return state } }, - '135': (state: MigrationState) => { + '135': (state: RootState) => { try { if (!state.assistants.defaultAssistant.settings) { state.assistants.defaultAssistant.settings = DEFAULT_ASSISTANT_SETTINGS @@ -2257,7 +2254,7 @@ const migrateConfig = { return state } }, - '136': (state: MigrationState) => { + '136': (state: RootState) => { try { state.settings.sidebarIcons.visible = [...new Set(state.settings.sidebarIcons.visible)].filter((icon) => DefaultPreferences.default['ui.sidebar.icons.visible'].includes(icon) @@ -2271,7 +2268,7 @@ const migrateConfig = { return state } }, - '137': (state: MigrationState) => { + '137': (state: RootState) => { try { state.ocr = { providers: BUILTIN_OCR_PROVIDERS, @@ -2284,7 +2281,7 @@ const migrateConfig = { return state } }, - '138': (state: MigrationState) => { + '138': (state: RootState) => { try { addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.system) return state @@ -2293,7 +2290,7 @@ const migrateConfig = { return state } }, - '139': (state: MigrationState) => { + '139': (state: RootState) => { try { addProvider(state, 'cherryin') state.llm.providers = moveProvider(state.llm.providers, 'cherryin', 1) @@ -2333,7 +2330,7 @@ const migrateConfig = { return state } }, - '140': (state: MigrationState) => { + '140': (state: RootState) => { try { // @ts-ignore state.paintings = { @@ -2363,7 +2360,7 @@ const migrateConfig = { return state } }, - '141': (state: MigrationState) => { + '141': (state: RootState) => { try { if (state.settings && state.settings.sidebarIcons) { // Check if 'notes' is not already in visible icons @@ -2377,7 +2374,7 @@ const migrateConfig = { return state } }, - '142': (state: MigrationState) => { + '142': (state: RootState) => { try { // Initialize notes settings if not present if (!state.note) { @@ -2389,7 +2386,7 @@ const migrateConfig = { return state } }, - '143': (state: MigrationState) => { + '143': (state: RootState) => { try { addMiniApp(state, 'longcat') return state @@ -2397,7 +2394,7 @@ const migrateConfig = { return state } }, - '144': (state: MigrationState) => { + '144': (state: RootState) => { try { if (state.settings) { state.settings.confirmDeleteMessage = settingsInitialState.confirmDeleteMessage @@ -2409,7 +2406,7 @@ const migrateConfig = { return state } }, - '145': (state: MigrationState) => { + '145': (state: RootState) => { try { if (state.settings) { if (state.settings.showMessageOutline === undefined || state.settings.showMessageOutline === null) { @@ -2422,7 +2419,7 @@ const migrateConfig = { return state } }, - '146': (state: MigrationState) => { + '146': (state: RootState) => { try { // Migrate showWorkspace from settings to note store if (state.settings && state.note) { @@ -2445,7 +2442,7 @@ const migrateConfig = { return state } }, - '147': (state: MigrationState) => { + '147': (state: RootState) => { try { state.knowledge.bases.forEach((base) => { if ((base as any).framework) { @@ -2458,7 +2455,7 @@ const migrateConfig = { return state } }, - '148': (state: MigrationState) => { + '148': (state: RootState) => { try { addOcrProvider(state, BUILTIN_OCR_PROVIDERS_MAP.paddleocr) return state @@ -2467,7 +2464,7 @@ const migrateConfig = { return state } }, - '149': (state: MigrationState) => { + '149': (state: RootState) => { try { state.knowledge.bases.forEach((base) => { if ((base as any).framework) { @@ -2480,7 +2477,7 @@ const migrateConfig = { return state } }, - '150': (state: MigrationState) => { + '150': (state: RootState) => { try { addShortcuts(state, ['rename_topic'], 'new_topic') addShortcuts(state, ['edit_last_user_message'], 'copy_last_message') @@ -2490,7 +2487,7 @@ const migrateConfig = { return state } }, - '151': (state: MigrationState) => { + '151': (state: RootState) => { try { if (state.settings) { state.settings.codeFancyBlock = true @@ -2501,7 +2498,7 @@ const migrateConfig = { return state } }, - '152': (state: MigrationState) => { + '152': (state: RootState) => { try { state.translate.settings = { autoCopy: false @@ -2512,7 +2509,7 @@ const migrateConfig = { return state } }, - '153': (state: MigrationState) => { + '153': (state: RootState) => { try { if (state.note.settings) { state.note.settings.fontSize = notesInitialState.settings.fontSize @@ -2524,7 +2521,7 @@ const migrateConfig = { return state } }, - '154': (state: MigrationState) => { + '154': (state: RootState) => { try { if (state.settings.userTheme) { state.settings.userTheme.userFontFamily = settingsInitialState.userTheme.userFontFamily @@ -2536,7 +2533,7 @@ const migrateConfig = { return state } }, - '155': (state: MigrationState) => { + '155': (state: RootState) => { try { state.knowledge.bases.forEach((base) => { if ((base as any).framework) { @@ -2549,7 +2546,7 @@ const migrateConfig = { return state } }, - '156': (state: MigrationState) => { + '156': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.anthropic) { @@ -2564,7 +2561,7 @@ const migrateConfig = { return state } }, - '157': (state: MigrationState) => { + '157': (state: RootState) => { try { addProvider(state, 'aionly') state.llm.providers = moveProvider(state.llm.providers, 'aionly', 10) @@ -2616,7 +2613,7 @@ const migrateConfig = { return state } }, - '158': (state: MigrationState) => { + '158': (state: RootState) => { try { state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'cherryin') addProvider(state, 'longcat') @@ -2626,7 +2623,7 @@ const migrateConfig = { return state } }, - '159': (state: MigrationState) => { + '159': (state: RootState) => { try { addProvider(state, 'ovms') fixMissingProvider(state) @@ -2636,7 +2633,7 @@ const migrateConfig = { return state } }, - '161': (state: MigrationState) => { + '161': (state: RootState) => { try { removeMiniAppFromState(state, 'nm-search') removeMiniAppFromState(state, 'hika') @@ -2649,7 +2646,7 @@ const migrateConfig = { return state } }, - '167': (state: MigrationState) => { + '167': (state: RootState) => { try { addProvider(state, 'huggingface') return state @@ -2658,7 +2655,7 @@ const migrateConfig = { return state } }, - '168': (state: MigrationState) => { + '168': (state: RootState) => { try { addPreprocessProviders(state, 'open-mineru') return state @@ -2667,7 +2664,7 @@ const migrateConfig = { return state } }, - '169': (state: MigrationState) => { + '169': (state: RootState) => { try { if (state?.mcp?.servers) { state.mcp.servers = state.mcp.servers.map((server) => { @@ -2684,7 +2681,7 @@ const migrateConfig = { return state } }, - '170': (state: MigrationState) => { + '170': (state: RootState) => { try { addProvider(state, 'sophnet') state.llm.providers = moveProvider(state.llm.providers, 'sophnet', 17) @@ -2695,7 +2692,7 @@ const migrateConfig = { return state } }, - '171': (state: MigrationState) => { + '171': (state: RootState) => { try { // Ensure aws-bedrock provider exists addProvider(state, 'aws-bedrock') @@ -2718,7 +2715,7 @@ const migrateConfig = { return state } }, - '172': (state: MigrationState) => { + '172': (state: RootState) => { try { // Add ling and huggingchat mini apps addMiniApp(state, 'ling') @@ -2810,7 +2807,7 @@ const migrateConfig = { return state } }, - '173': (state: MigrationState) => { + '173': (state: RootState) => { try { // Migrate toolOrder from global state to scope-based state if (state.inputTools && !state.inputTools.sessionToolOrder) { @@ -2822,7 +2819,7 @@ const migrateConfig = { return state } }, - '174': (state: MigrationState) => { + '174': (state: RootState) => { try { addProvider(state, SystemProviderIds.longcat) @@ -2839,7 +2836,7 @@ const migrateConfig = { return state } }, - '175': (state: MigrationState) => { + '175': (state: RootState) => { try { state.assistants.assistants.forEach((assistant) => { // @ts-ignore @@ -2860,7 +2857,7 @@ const migrateConfig = { return state } }, - '176': (state: MigrationState) => { + '176': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.qiniu) { @@ -2876,7 +2873,7 @@ const migrateConfig = { return state } }, - '177': (state: MigrationState) => { + '177': (state: RootState) => { try { // @ts-expect-error it's a removed type if (state.settings.openAI.summaryText === 'off') { @@ -2889,7 +2886,7 @@ const migrateConfig = { return state } }, - '178': (state: MigrationState) => { + '178': (state: RootState) => { try { const groq = state.llm.providers.find((p) => p.id === SystemProviderIds.groq) if (groq) { @@ -2902,7 +2899,7 @@ const migrateConfig = { return state } }, - '179': (state: MigrationState) => { + '179': (state: RootState) => { try { state.llm.providers.forEach((provider) => { switch (provider.id) { @@ -2924,7 +2921,7 @@ const migrateConfig = { return state } }, - '181': (state: MigrationState) => { + '181': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'ai-gateway') { @@ -2948,7 +2945,7 @@ const migrateConfig = { return state } }, - '182': (state: MigrationState) => { + '182': (state: RootState) => { try { // Initialize streamOptions in settings.openAI if not exists if (!state.settings.openAI.streamOptions) { @@ -2963,7 +2960,7 @@ const migrateConfig = { return state } }, - '183': (state: MigrationState) => { + '183': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.cherryin) { @@ -2979,7 +2976,7 @@ const migrateConfig = { return state } }, - '184': (state: MigrationState) => { + '184': (state: RootState) => { try { // Add exa-mcp (free) web search provider if not exists const exaMcpExists = state.websearch.providers.some((p) => p.id === 'exa-mcp') @@ -3004,7 +3001,7 @@ const migrateConfig = { return state } }, - '185': (state: MigrationState) => { + '185': (state: RootState) => { try { // Reset toolUseMode to function for default assistant if (state.assistants.defaultAssistant.settings?.toolUseMode) { @@ -3025,7 +3022,7 @@ const migrateConfig = { return state } }, - '186': (state: MigrationState) => { + '186': (state: RootState) => { try { if (state.settings.apiServer) { state.settings.apiServer.host = API_SERVER_DEFAULTS.HOST @@ -3050,7 +3047,7 @@ const migrateConfig = { return state } }, - '187': (state: MigrationState) => { + '187': (state: RootState) => { try { state.assistants.assistants.forEach((assistant) => { if (assistant.settings && assistant.settings.reasoning_effort === undefined) { @@ -3066,7 +3063,7 @@ const migrateConfig = { } }, // 1.7.7 - '188': (state: MigrationState) => { + '188': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.openrouter) { @@ -3081,7 +3078,7 @@ const migrateConfig = { } }, // 1.7.7 - '189': (state: MigrationState) => { + '189': (state: RootState) => { try { void window.api.memory.migrateMemoryDb() // @ts-ignore @@ -3110,7 +3107,7 @@ const migrateConfig = { } }, // 1.7.8 - '190': (state: MigrationState) => { + '190': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === SystemProviderIds.ollama) { @@ -3124,7 +3121,7 @@ const migrateConfig = { return state } }, - '191': (state: MigrationState) => { + '191': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'tokenflux') { @@ -3139,7 +3136,7 @@ const migrateConfig = { return state } }, - '192': (state: MigrationState) => { + '192': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === '302ai') { @@ -3154,7 +3151,7 @@ const migrateConfig = { return state } }, - '193': (state: MigrationState) => { + '193': (state: RootState) => { try { addPreprocessProviders(state, 'paddleocr') logger.info('migrate 193 success') @@ -3164,7 +3161,7 @@ const migrateConfig = { return state } }, - '194': (state: MigrationState) => { + '194': (state: RootState) => { try { const GLM_4_5_FLASH_MODEL = 'glm-4.5-flash' if (state.llm.defaultModel?.provider === 'cherryai' && state.llm.defaultModel?.id === GLM_4_5_FLASH_MODEL) { @@ -3192,7 +3189,7 @@ const migrateConfig = { return state } }, - '195': (state: MigrationState) => { + '195': (state: RootState) => { try { if (state.settings && state.settings.sidebarIcons) { // Add 'openclaw' to visible icons if not already present @@ -3207,7 +3204,7 @@ const migrateConfig = { return state } }, - '196': (state: MigrationState) => { + '196': (state: RootState) => { try { if (state.paintings && !state.paintings.ppio_draw) { state.paintings.ppio_draw = [] @@ -3222,7 +3219,7 @@ const migrateConfig = { return state } }, - '197': (state: MigrationState) => { + '197': (state: RootState) => { try { if (state.openclaw?.gatewayPort === 18789) { state.openclaw.gatewayPort = 18790 @@ -3234,7 +3231,7 @@ const migrateConfig = { return state } }, - '198': (state: MigrationState) => { + '198': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.id === 'minimax') { @@ -3248,7 +3245,7 @@ const migrateConfig = { return state } }, - '199': (state: MigrationState) => { + '199': (state: RootState) => { try { addShortcuts(state, ['select_model'], 'toggle_new_context') return state @@ -3257,7 +3254,7 @@ const migrateConfig = { return state } }, - '200': (state: MigrationState) => { + '200': (state: RootState) => { try { state.llm.providers.forEach((provider) => { if (provider.type === 'ollama') { @@ -3294,7 +3291,7 @@ const migrateConfig = { return state } }, - '201': (state: MigrationState) => { + '201': (state: RootState) => { try { addWebSearchProvider(state, 'querit') return state @@ -3303,7 +3300,7 @@ const migrateConfig = { return state } }, - '202': (state: MigrationState) => { + '202': (state: RootState) => { try { const filesystemServer = state.mcp?.servers?.find((s: any) => s.name === '@cherry/filesystem') if (filesystemServer && filesystemServer.disabledAutoApproveTools === undefined) { @@ -3315,7 +3312,7 @@ const migrateConfig = { return state } }, - '203': (state: MigrationState) => { + '203': (state: RootState) => { try { if (state.settings && state.settings.sidebarIcons) { // Add 'agents' to visible icons if not already present @@ -3348,7 +3345,7 @@ const migrateConfig = { return state } }, - '204': (state: MigrationState) => { + '204': (state: RootState) => { try { if (state.llm.defaultModel?.provider === 'cherryai') { state.llm.defaultModel = qwenModel @@ -3374,7 +3371,7 @@ const migrateConfig = { return state } }, - '205': (state: MigrationState) => { + '205': (state: RootState) => { try { localStorage.setItem('onboarding-completed', 'true') @@ -3395,7 +3392,7 @@ const migrateConfig = { return state } }, - '206': (state: MigrationState) => { + '206': (state: RootState) => { try { const { sessionToolOrder } = state.inputTools const permissionModeKey = 'permission_mode' diff --git a/src/renderer/src/store/shortcuts.ts b/src/renderer/src/store/shortcuts.ts index c2afb2e60f0..fc2009100ba 100644 --- a/src/renderer/src/store/shortcuts.ts +++ b/src/renderer/src/store/shortcuts.ts @@ -78,7 +78,7 @@ const initialState: ShortcutsState = { system: false }, { - key: 'toggle_sidebar', + key: 'toggle_show_assistants', shortcut: ['CommandOrControl', '['], editable: true, enabled: true, @@ -151,21 +151,31 @@ const initialState: ShortcutsState = { ] } +const getSerializableShortcuts = (shortcuts: Shortcut[]) => { + return shortcuts.map((shortcut) => ({ + key: shortcut.key, + shortcut: [...shortcut.shortcut], + enabled: shortcut.enabled, + system: shortcut.system, + editable: shortcut.editable + })) +} + const shortcutsSlice = createSlice({ name: 'shortcuts', initialState, reducers: { updateShortcut: (state, action: PayloadAction) => { state.shortcuts = state.shortcuts.map((s) => (s.key === action.payload.key ? action.payload : s)) - // Shortcuts are now managed via PreferenceService — this slice is kept only for migration + void window.api.shortcuts.update(getSerializableShortcuts(state.shortcuts)) }, toggleShortcut: (state, action: PayloadAction) => { state.shortcuts = state.shortcuts.map((s) => (s.key === action.payload ? { ...s, enabled: !s.enabled } : s)) - // Shortcuts are now managed via PreferenceService — this slice is kept only for migration + void window.api.shortcuts.update(getSerializableShortcuts(state.shortcuts)) }, resetShortcuts: (state) => { state.shortcuts = initialState.shortcuts - // Shortcuts are now managed via PreferenceService — this slice is kept only for migration + void window.api.shortcuts.update(getSerializableShortcuts(state.shortcuts)) } } }) From 0bf173f8207ae9a64d8b5babc3043bb66c92907e Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 14:21:56 +0800 Subject: [PATCH 33/37] refactor(shortcuts): add methods for managing shortcuts in ConfigManager and update ShortcutService dependencies --- src/main/services/ConfigManager.ts | 14 ++++++++++++++ src/main/services/ShortcutService.ts | 2 +- src/renderer/src/store/shortcuts.ts | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 74808ea9c85..9a5d0395d9d 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -14,6 +14,8 @@ * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 * -------------------------------------------------------------------------- */ +import { ZOOM_SHORTCUTS } from '@shared/config/constant' +import type { Shortcut } from '@types' import Store from 'electron-store' export enum ConfigKeys { @@ -23,6 +25,7 @@ export enum ConfigKeys { Tray = 'tray', TrayOnClose = 'trayOnClose', ZoomFactor = 'ZoomFactor', + Shortcuts = 'shortcuts', ClickTrayToShowQuickAssistant = 'clickTrayToShowQuickAssistant', EnableQuickAssistant = 'enableQuickAssistant', AutoUpdate = 'autoUpdate', @@ -125,6 +128,17 @@ export class ConfigManager { } } + getShortcuts() { + return this.get(ConfigKeys.Shortcuts, ZOOM_SHORTCUTS) as Shortcut[] | [] + } + + setShortcuts(shortcuts: Shortcut[]) { + this.setAndNotify( + ConfigKeys.Shortcuts, + shortcuts.filter((shortcut) => shortcut.system) + ) + } + // getClickTrayToShowQuickAssistant(): boolean { // return this.get(ConfigKeys.ClickTrayToShowQuickAssistant, false) // } diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 23c42a05910..f238e61f952 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -24,7 +24,7 @@ const relevantDefinitions = SHORTCUT_DEFINITIONS.filter( @Injectable('ShortcutService') @ServicePhase(Phase.WhenReady) -@DependsOn(['WindowService', 'SelectionService', 'PreferenceService']) +@DependsOn(['WindowService', 'SelectionService']) export class ShortcutService extends BaseService { private mainWindow: BrowserWindow | null = null private handlers = new Map() 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 * -------------------------------------------------------------------------- From e3dd377c8f52c9d3afad5c56995872fb42fc029a Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 14:49:13 +0800 Subject: [PATCH 34/37] refactor(shortcuts): implement shortcut registration conflict handling and update UI notifications --- packages/shared/IpcChannel.ts | 1 + src/main/services/ShortcutService.ts | 102 ++++++++++++----- .../__tests__/ShortcutService.test.ts | 25 +++++ src/preload/index.ts | 17 +++ src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/de-de.json | 9 +- src/renderer/src/i18n/translate/el-gr.json | 9 +- src/renderer/src/i18n/translate/es-es.json | 9 +- src/renderer/src/i18n/translate/fr-fr.json | 9 +- src/renderer/src/i18n/translate/ja-jp.json | 9 +- src/renderer/src/i18n/translate/pt-pt.json | 9 +- src/renderer/src/i18n/translate/ro-ro.json | 9 +- src/renderer/src/i18n/translate/ru-ru.json | 9 +- .../src/pages/settings/ShortcutSettings.tsx | 106 +++++++++++++----- 16 files changed, 234 insertions(+), 92 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index d4eb00cdd2d..79430323e54 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -182,6 +182,7 @@ export enum IpcChannel { 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', diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index f238e61f952..31c5019ef80 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -12,7 +12,7 @@ import { globalShortcut } from 'electron' const logger = loggerService.withContext('ShortcutService') type ShortcutHandler = (window?: BrowserWindow) => void -type RegisteredShortcut = { handler: ShortcutHandler; window: BrowserWindow } +type RegisteredShortcut = { key: ShortcutPreferenceKey; handler: ShortcutHandler; window: BrowserWindow } const toAccelerator = (keys: string[]): string => keys.join('+') @@ -28,7 +28,8 @@ const relevantDefinitions = SHORTCUT_DEFINITIONS.filter( export class ShortcutService extends BaseService { private mainWindow: BrowserWindow | null = null private handlers = new Map() - private windowOnHandlers = new Map void; onBlur: () => void; onClosed: () => void }>() + private registeredWindows = new Set() + private conflictedKeys = new Set() private isRegisterOnBoot = true private registeredAccelerators = new Map() @@ -48,6 +49,10 @@ export class ShortcutService extends BaseService { protected async onStop() { this.unregisterAll() this.mainWindow = null + this.registeredWindows.clear() + this.conflictedKeys.clear() + this.handlers.clear() + this.isRegisterOnBoot = true } private registerBuiltInHandlers(): void { @@ -144,28 +149,41 @@ export class ShortcutService extends BaseService { 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')) { this.registerShortcuts(window, true) } - }) + } + window.once('ready-to-show', onReadyToShow) + this.registerDisposable(() => window.off('ready-to-show', onReadyToShow)) this.isRegisterOnBoot = false } - if (!this.windowOnHandlers.has(window)) { - const onFocus = () => this.registerShortcuts(window, false) - const onBlur = () => this.registerShortcuts(window, true) + if (!this.registeredWindows.has(window)) { + this.registeredWindows.add(window) + + 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.windowOnHandlers.delete(window) + this.registeredWindows.delete(window) if (this.mainWindow === window) { this.mainWindow = null } } + window.on('focus', onFocus) window.on('blur', onBlur) window.once('closed', onClosed) - this.windowOnHandlers.set(window, { onFocus, onBlur, onClosed }) + this.registerDisposable(() => window.off('focus', onFocus)) + this.registerDisposable(() => window.off('blur', onBlur)) + this.registerDisposable(() => window.off('closed', onClosed)) } if (!window.isDestroyed() && window.isFocused()) { @@ -197,19 +215,26 @@ export class ShortcutService extends BaseService { const accelerator = toAccelerator(pref.binding) if (accelerator) { - desired.set(accelerator, { handler, window }) + desired.set(accelerator, { key: definition.key, handler, window }) } if (definition.variants) { for (const variant of definition.variants) { const variantAccelerator = toAccelerator(variant) if (variantAccelerator) { - desired.set(variantAccelerator, { handler, window }) + desired.set(variantAccelerator, { key: definition.key, 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) + } + } + // Unregister shortcuts that are no longer needed or have a different handler for (const [accelerator, previous] of this.registeredAccelerators) { const entry = desired.get(accelerator) @@ -224,7 +249,7 @@ export class ShortcutService extends BaseService { } // Register new or changed shortcuts - for (const [accelerator, { handler, window: win }] of desired) { + for (const [accelerator, { key, handler, window: win }] of desired) { if (!this.registeredAccelerators.has(accelerator)) { try { const success = globalShortcut.register(accelerator, () => { @@ -236,12 +261,15 @@ export class ShortcutService extends BaseService { } }) if (success) { - this.registeredAccelerators.set(accelerator, { handler, window: win }) + 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.warn(`Failed to register shortcut ${accelerator}`, error as Error) + logger.error(`Failed to register shortcut ${accelerator}`, error as Error) + this.markRegistrationConflict(key, accelerator) } } } @@ -258,23 +286,37 @@ export class ShortcutService extends BaseService { } private unregisterAll(): void { - try { - this.windowOnHandlers.forEach((handlers, window) => { - window.off('focus', handlers.onFocus) - window.off('blur', handlers.onBlur) - window.off('closed', handlers.onClosed) - }) - this.windowOnHandlers.clear() - for (const accelerator of this.registeredAccelerators.keys()) { - try { - globalShortcut.unregister(accelerator) - } catch (error) { - logger.debug(`Failed to unregister shortcut accelerator: ${accelerator}`, error as Error) - } + 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() - } catch (error) { - logger.warn('Failed to unregister all shortcuts', error as Error) } + this.registeredAccelerators.clear() + } + + private markRegistrationConflict(key: ShortcutPreferenceKey, accelerator: string): void { + this.conflictedKeys.add(key) + this.emitRegistrationConflict({ key, accelerator, hasConflict: true }) + } + + private clearRegistrationConflict(key: ShortcutPreferenceKey): void { + if (!this.conflictedKeys.delete(key)) { + return + } + this.emitRegistrationConflict({ key, hasConflict: false }) + } + + 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 index 819b1767e81..2e0dca457b2 100644 --- a/src/main/services/__tests__/ShortcutService.test.ts +++ b/src/main/services/__tests__/ShortcutService.test.ts @@ -70,6 +70,7 @@ vi.mock('electron', () => ({ })) import { handleZoomFactor } from '@main/utils/zoom' +import { IpcChannel } from '@shared/IpcChannel' import { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceService' import { ShortcutService } from '../ShortcutService' @@ -228,4 +229,28 @@ describe('ShortcutService', () => { 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 + }) + }) }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 0bb959db638..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, @@ -94,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 } @@ -819,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/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index cd34afc2581..1d0764342a1 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -5565,6 +5565,7 @@ "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", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 6dcac378431..a2cca487552 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -5565,6 +5565,7 @@ "label": "按键", "mini_window": "快捷助手", "new_topic": "新建话题", + "occupied_by_other_application": "该快捷键已被系统或其他应用占用", "press_shortcut": "按下快捷键", "rename_topic": "重命名话题", "reset_defaults": "重置默认快捷键", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index c41c47b0849..56d3cdd1bdd 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -5565,6 +5565,7 @@ "label": "按鍵", "mini_window": "快捷助手", "new_topic": "新增話題", + "occupied_by_other_application": "此快捷鍵已被系統或其他應用程式占用", "press_shortcut": "按下快捷鍵", "rename_topic": "重新命名話題", "reset_defaults": "重設預設快捷鍵", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 685bf3ba3c5..2130a979dc7 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -5554,7 +5554,7 @@ "shortcuts": { "action": "Aktionen", "actions": "Aktionen", - "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", + "bind_first_to_enable": "Weise zuerst eine Tastenkombination zu, um ihren aktivierten Zustand zu ändern.", "clear_shortcut": "Shortcut löschen", "clear_topic": "Nachricht leeren", "conflict_with": "Bereits von „{{name}}“ verwendet", @@ -5565,14 +5565,15 @@ "label": "Taste", "mini_window": "Schnellassistent", "new_topic": "Neues Thema", + "occupied_by_other_application": "Dieses Tastenkü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": "[to be translated]:Failed to reset shortcuts to defaults", + "reset_defaults_failed": "Fehler beim Zurücksetzen der Tastenkombinationen auf die Standardwerte", "reset_to_default": "Auf Standard zurücksetzen", - "save_failed": "[to be translated]:Failed to save shortcut", - "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", + "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 durchsuchen...", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 7d460e55202..1d8d4c73ec5 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -5554,7 +5554,7 @@ "shortcuts": { "action": "Ενέργεια", "actions": "Λειτουργία", - "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", + "bind_first_to_enable": "Δέστε πρώτα μια συντόμευση για να αλλάξετε την κατάσταση ενεργοποίησής της", "clear_shortcut": "Καθαρισμός συντομού πλήκτρου", "clear_topic": "Άδειασμα μηνυμάτων", "conflict_with": "Ήδη χρησιμοποιείται από τον \"{{name}}\"", @@ -5565,14 +5565,15 @@ "label": "Πλήκτρο", "mini_window": "Συντομεύστε επιχειρηματικά", "new_topic": "Νέο θέμα", + "occupied_by_other_application": "Αυτή η συντόμευση χρησιμοποιείται ήδη από το σύστημα ή μια άλλη εφαρμογή", "press_shortcut": "Πάτησε το συντομού πλήκτρου", "rename_topic": "Μετονομασία θέματος", "reset_defaults": "Επαναφορά στα προεπιλεγμένα συντομού πλήκτρα", "reset_defaults_confirm": "Θέλετε να επαναφέρετε όλα τα συντομού πλήκτρα στις προεπιλεγμένες τιμές;", - "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", + "reset_defaults_failed": "Απέτυχε η επαναφορά των συντομεύσεων στις προεπιλογές", "reset_to_default": "Επαναφορά στις προεπιλεγμένες", - "save_failed": "[to be translated]:Failed to save shortcut", - "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", + "save_failed": "Αποτυχία αποθήκευσης συντόμευσης", + "save_failed_with_name": "Αποτυχία αποθήκευσης συντόμευσης: {{name}}", "search_message": "Αναζήτηση μηνυμάτων", "search_message_in_chat": "Αναζήτηση μηνύματος στην τρέχουσα συνομιλία", "search_placeholder": "Συντομεύσεις αναζήτησης...", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 0c2755f6131..5163df242b9 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -5554,7 +5554,7 @@ "shortcuts": { "action": "Acción", "actions": "operación", - "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", + "bind_first_to_enable": "Primero asigna un atajo para cambiar su estado de habilitación.", "clear_shortcut": "Borrar atajo", "clear_topic": "Vaciar mensaje", "conflict_with": "Ya usado por \"{{name}}\"", @@ -5565,14 +5565,15 @@ "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": "[to be translated]:Failed to reset shortcuts to defaults", + "reset_defaults_failed": "No se pudieron restablecer los atajos a los valores predeterminados", "reset_to_default": "Restablecer a predeterminado", - "save_failed": "[to be translated]:Failed to save shortcut", - "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", + "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...", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 131abd5daa1..a91acf9f774 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -5554,7 +5554,7 @@ "shortcuts": { "action": "Action", "actions": "操作", - "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", + "bind_first_to_enable": "Associez 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}}\"", @@ -5565,14 +5565,15 @@ "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": "[to be translated]:Failed to reset shortcuts to defaults", + "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": "[to be translated]:Failed to save shortcut", - "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", + "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...", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index d6f9b398b4e..d9ae77c743f 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -5554,7 +5554,7 @@ "shortcuts": { "action": "操作", "actions": "操作", - "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", + "bind_first_to_enable": "ショートカットを最初にバインドして、その有効状態を変更してください", "clear_shortcut": "ショートカットをクリア", "clear_topic": "メッセージを消去", "conflict_with": "既に「{{name}}」によって使用されています", @@ -5565,14 +5565,15 @@ "label": "キー", "mini_window": "クイックアシスタント", "new_topic": "新しいトピック", + "occupied_by_other_application": "このショートカットは、システムまたは別のアプリケーションで既に使用されています", "press_shortcut": "ショートカットを押す", "rename_topic": "トピックの名前を変更", "reset_defaults": "デフォルトのショートカットをリセット", "reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?", - "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", + "reset_defaults_failed": "ショートカットをデフォルトにリセットできませんでした", "reset_to_default": "デフォルトにリセット", - "save_failed": "[to be translated]:Failed to save shortcut", - "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", + "save_failed": "ショートカットの保存に失敗しました", + "save_failed_with_name": "ショートカットの保存に失敗しました: {{name}}", "search_message": "メッセージを検索", "search_message_in_chat": "現在のチャットでメッセージを検索", "search_placeholder": "検索ショートカット...", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index dfb734cb1dc..f8e7f31da7a 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -5554,7 +5554,7 @@ "shortcuts": { "action": "Ação", "actions": "operação", - "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", + "bind_first_to_enable": "Primeiro associe um atalho para alterar seu estado de ativação", "clear_shortcut": "Limpar atalho", "clear_topic": "Limpar mensagem", "conflict_with": "Já utilizado por \"{{name}}\"", @@ -5565,14 +5565,15 @@ "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": "[to be translated]:Failed to reset shortcuts to defaults", + "reset_defaults_failed": "Falha ao redefinir atalhos para os padrões", "reset_to_default": "Redefinir para padrão", - "save_failed": "[to be translated]:Failed to save shortcut", - "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", + "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...", diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index e145fd339d9..b759531a410 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -5554,7 +5554,7 @@ "shortcuts": { "action": "Acțiune", "actions": "Comandă", - "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", + "bind_first_to_enable": "Legați 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}}”", @@ -5565,14 +5565,15 @@ "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": "[to be translated]:Failed to reset shortcuts to defaults", + "reset_defaults_failed": "Nu s-a reușit resetarea comenzilor rapide la valorile implicite", "reset_to_default": "Resetează la implicit", - "save_failed": "[to be translated]:Failed to save shortcut", - "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", + "save_failed": "Nu s-a reușit salvarea comenzii rapide", + "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...", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 6c8d08a7518..754a64391d5 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -5554,7 +5554,7 @@ "shortcuts": { "action": "Действие", "actions": "操作", - "bind_first_to_enable": "[to be translated]:Bind a shortcut first to change its enabled state", + "bind_first_to_enable": "Сначала назначьте сочетание клавиш, чтобы изменить его состояние включения", "clear_shortcut": "Очистить сочетание клавиш", "clear_topic": "Очистить все сообщения", "conflict_with": "Уже используется \"{{name}}\"", @@ -5565,14 +5565,15 @@ "label": "Клавиша", "mini_window": "Быстрый помощник", "new_topic": "Новый топик", + "occupied_by_other_application": "Это сочетание клавиш уже используется системой или другим приложением", "press_shortcut": "Нажмите сочетание клавиш", "rename_topic": "Переименовать топик", "reset_defaults": "Сбросить настройки по умолчанию", "reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?", - "reset_defaults_failed": "[to be translated]:Failed to reset shortcuts to defaults", + "reset_defaults_failed": "Не удалось сбросить сочетания клавиш к значениям по умолчанию", "reset_to_default": "Сбросить настройки по умолчанию", - "save_failed": "[to be translated]:Failed to save shortcut", - "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", + "save_failed": "Не удалось сохранить ярлык", + "save_failed_with_name": "Не удалось сохранить ярлык: {{name}}", "search_message": "Поиск сообщения", "search_message_in_chat": "Поиск сообщения в текущем диалоге", "search_placeholder": "Поиск ярлыков...", diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index fe3017e382e..24a3b46bc1f 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -15,7 +15,7 @@ import { isValidShortcut } from '@shared/shortcuts/utils' import type { FC, KeyboardEvent as ReactKeyboardEvent } from 'react' -import { useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' @@ -54,6 +54,7 @@ const ShortcutSettings: FC = () => { const [editingKey, setEditingKey] = useState(null) const [pendingKeys, setPendingKeys] = useState([]) const [conflictLabel, setConflictLabel] = useState(null) + const [systemConflictKey, setSystemConflictKey] = useState(null) const [searchQuery, setSearchQuery] = useState('') const { setTimeoutTimer, clearTimeoutTimer } = useTimer() @@ -90,6 +91,30 @@ const ShortcutSettings: FC = () => { setConflictLabel(null) } + 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) @@ -109,6 +134,7 @@ const ShortcutSettings: FC = () => { const handleResetShortcut = async (record: (typeof shortcuts)[number]) => { try { + clearSystemConflict(record.key) await updatePreference(record.key, { binding: record.defaultPreference.binding, enabled: record.defaultPreference.enabled @@ -164,6 +190,7 @@ const ShortcutSettings: FC = () => { setConflictLabel(null) try { + clearSystemConflict(record.key) await updatePreference(record.key, { binding: keys, enabled: true }) clearEditingState() } catch (error) { @@ -179,6 +206,7 @@ const ShortcutSettings: FC = () => { const updates: Record = getAllShortcutDefaultPreferences() try { + clearSystemConflict() await preferenceService.setMultiple(updates) } catch (error) { logger.error('Failed to reset all shortcuts to defaults', error as Error) @@ -194,10 +222,13 @@ const ShortcutSettings: FC = () => { 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 = conflictLabel !== null + const hasConflict = conflictMessage !== null return (
@@ -218,7 +249,7 @@ const ShortcutSettings: FC = () => { /> {hasConflict && ( - {t('settings.shortcuts.conflict_with', { name: conflictLabel })} + {conflictLabel ? t('settings.shortcuts.conflict_with', { name: conflictLabel }) : conflictMessage} )}
@@ -227,38 +258,52 @@ const ShortcutSettings: FC = () => { if (displayShortcut) { return ( - - {isBindingModified && ( - - { - void handleResetShortcut(record) - }} - /> - - )} - isEditable && handleAddShortcut(record.key)}> - {displayKeys.map((key) => ( - - {formatKeyDisplay(key, isMac)} - - ))} +
+ + {isBindingModified && ( + + { + void handleResetShortcut(record) + }} + /> + + )} + isEditable && handleAddShortcut(record.key)}> + {displayKeys.map((key) => ( + + {formatKeyDisplay(key, isMac)} + + ))} + - + {hasSystemConflict && ( + + {conflictMessage} + + )} +
) } return ( - isEditable && handleAddShortcut(record.key)}> - {t('settings.shortcuts.press_shortcut')} - +
+ isEditable && handleAddShortcut(record.key)}> + {t('settings.shortcuts.press_shortcut')} + + {hasSystemConflict && ( + + {conflictMessage} + + )} +
) } @@ -269,6 +314,7 @@ const ShortcutSettings: FC = () => { checked={record.preference.enabled} disabled={!record.preference.binding.length} onCheckedChange={() => { + clearSystemConflict(record.key) updatePreference(record.key, { enabled: !record.preference.enabled }).catch((error) => { handleUpdateFailure(record, error) }) From 1bd8bde3ab8338d2dc212df5c30276c6c4646447 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 14 Apr 2026 14:56:35 +0800 Subject: [PATCH 35/37] refactor(shortcuts): encapsulate runtime state reset logic and enhance conflict notification handling --- src/main/services/ShortcutService.ts | 17 ++++++++++++----- .../services/__tests__/ShortcutService.test.ts | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 31c5019ef80..69fdd8d46c0 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -48,11 +48,7 @@ export class ShortcutService extends BaseService { protected async onStop() { this.unregisterAll() - this.mainWindow = null - this.registeredWindows.clear() - this.conflictedKeys.clear() - this.handlers.clear() - this.isRegisterOnBoot = true + this.resetRuntimeState() } private registerBuiltInHandlers(): void { @@ -296,7 +292,18 @@ export class ShortcutService extends BaseService { this.registeredAccelerators.clear() } + private resetRuntimeState(): void { + this.mainWindow = null + this.registeredWindows.clear() + this.conflictedKeys.clear() + this.isRegisterOnBoot = true + } + private markRegistrationConflict(key: ShortcutPreferenceKey, accelerator: string): void { + if (this.conflictedKeys.has(key)) { + return + } + this.conflictedKeys.add(key) this.emitRegistrationConflict({ key, accelerator, hasConflict: true }) } diff --git a/src/main/services/__tests__/ShortcutService.test.ts b/src/main/services/__tests__/ShortcutService.test.ts index 2e0dca457b2..52cd80ca681 100644 --- a/src/main/services/__tests__/ShortcutService.test.ts +++ b/src/main/services/__tests__/ShortcutService.test.ts @@ -253,4 +253,21 @@ describe('ShortcutService', () => { 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 + }) + ) + }) }) From 862eba9490672a423a86f57a499f9909102826ab Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 16 Apr 2026 10:14:06 +0800 Subject: [PATCH 36/37] fix(i18n): update Russian translations for shortcuts and tab actions --- src/renderer/src/i18n/translate/ru-ru.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 024175f6a31..23936b22cbf 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -5573,10 +5573,10 @@ "reset_defaults_failed": "Не удалось сбросить сочетания клавиш к значениям по умолчанию", "reset_to_default": "Сбросить настройки по умолчанию", "save_failed": "Не удалось сохранить ярлык", - "save_failed_with_name": "[to be translated]:Failed to save shortcut: {{name}}", + "save_failed_with_name": "Не удалось сохранить ярлык: {{name}}", "search_message": "Поиск сообщения", "search_message_in_chat": "Поиск сообщения в текущем диалоге", - "search_placeholder": "[to be translated]:Search shortcuts...", + "search_placeholder": "Поисковые ярлыки...", "select_model": "Select Model", "selection_assistant_select_text": "Помощник выделения: выделить текст", "selection_assistant_toggle": "Переключить помощник выделения", @@ -5585,7 +5585,7 @@ "title": "Горячие клавиши", "toggle_new_context": "Очистить контекст", "toggle_show_topics": "Переключить отображение топиков", - "toggle_sidebar": "[to be translated]:Toggle Sidebar", + "toggle_sidebar": "Свернуть боковую панель", "zoom_in": "Увеличить", "zoom_out": "Уменьшить", "zoom_reset": "Сбросить масштаб" @@ -5854,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": "Приложения", From 43a9da5d21a69e5ef8d0d7cbdcf7cdce9b1d420e Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 16 Apr 2026 11:10:49 +0800 Subject: [PATCH 37/37] refactor(shortcuts): streamline shortcut definitions and update label mapping --- packages/shared/shortcuts/definitions.ts | 88 +++++++++++++----------- packages/shared/shortcuts/types.ts | 24 +------ src/renderer/src/i18n/label.ts | 27 ++------ 3 files changed, 51 insertions(+), 88 deletions(-) diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts index cafa09b06a8..0c9dcb735d7 100644 --- a/packages/shared/shortcuts/definitions.ts +++ b/packages/shared/shortcuts/definitions.ts @@ -1,129 +1,131 @@ import type { ShortcutDefinition, ShortcutPreferenceKey } from './types' -export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ +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', @@ -131,8 +133,8 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ global: true, enabledWhen: 'feature.selection.enabled', supportedPlatforms: ['darwin', 'win32'] - }, - { + }), + defineShortcut({ key: 'shortcut.feature.selection.get_text', scope: 'main', category: 'feature.selection', @@ -140,8 +142,10 @@ export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [ global: true, enabledWhen: 'feature.selection.enabled', supportedPlatforms: ['darwin', 'win32'] - } -] as const + }) +] as const satisfies readonly ShortcutDefinition[] + +export type ShortcutLabelKey = (typeof SHORTCUT_DEFINITIONS)[number]['labelKey'] const definitionMap = new Map( SHORTCUT_DEFINITIONS.map((definition) => [definition.key, definition]) diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts index 17ad6638365..47b6c1e8119 100644 --- a/packages/shared/shortcuts/types.ts +++ b/packages/shared/shortcuts/types.ts @@ -20,28 +20,6 @@ export type ShortcutDependencyPreferenceKey = Extract { return getLabel(sidebarIconKeyMap, key) } -const shortcutLabelKeyMap: Record = { - clear_topic: 'settings.shortcuts.clear_topic', - rename_topic: 'settings.shortcuts.rename_topic', - copy_last_message: 'settings.shortcuts.copy_last_message', - show_app: 'settings.shortcuts.show_app', - show_settings: 'settings.shortcuts.show_settings', - toggle_sidebar: 'settings.shortcuts.toggle_sidebar', - exit_fullscreen: 'settings.shortcuts.exit_fullscreen', - zoom_in: 'settings.shortcuts.zoom_in', - zoom_out: 'settings.shortcuts.zoom_out', - zoom_reset: 'settings.shortcuts.zoom_reset', - search_message: 'settings.shortcuts.search_message', - search_message_in_chat: 'settings.shortcuts.search_message_in_chat', - toggle_new_context: 'settings.shortcuts.toggle_new_context', - edit_last_user_message: 'settings.shortcuts.edit_last_user_message', - select_model: 'settings.shortcuts.select_model', - new_topic: 'settings.shortcuts.new_topic', - mini_window: 'settings.shortcuts.mini_window', - selection_assistant_toggle: 'settings.shortcuts.selection_assistant_toggle', - selection_assistant_select_text: 'settings.shortcuts.selection_assistant_select_text', - toggle_show_topics: 'settings.shortcuts.toggle_show_topics' -} as const +const shortcutLabelKeyMap = Object.fromEntries( + SHORTCUT_DEFINITIONS.map((definition) => [definition.labelKey, `settings.shortcuts.${definition.labelKey}`]) +) as Record export const getShortcutLabel = (key: ShortcutLabelKey): string => { return getLabel(shortcutLabelKeyMap, key)