From 6745108708a602e800fc7dba0a247afae1cf237b Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:49:41 +0800 Subject: [PATCH 01/55] feat(desktop): add search shortcut settings Make SearchView command shortcuts configurable via persisted keybindings. Add settings UI and tests for shortcut routing and validation. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/src/config/searchKeybindings.ts | 121 ++++++ apps/desktop/src/database/schema.ts | 1 + apps/desktop/src/i18n/messages.ts | 34 ++ .../src/services/EventService/types.ts | 4 +- apps/desktop/src/stores/settings.ts | 51 +++ apps/desktop/src/utils/shortcuts.ts | 306 +++++++++++++ .../interaction/useSearchKeyboardRouter.ts | 142 ++++-- .../composables/searchInteraction.ts | 406 ++---------------- apps/desktop/src/views/SearchView/index.vue | 3 +- .../SettingsView/components/General/index.vue | 320 ++++++++++++++ .../SearchView/searchInteraction.test.ts | 153 ------- .../useSearchKeyboardRouter.test.ts | 89 ++-- .../tests/stores/settings-keybindings.test.ts | 125 ++++++ .../settingsGeneralComponent.test.ts | 15 +- 14 files changed, 1175 insertions(+), 595 deletions(-) create mode 100644 apps/desktop/src/config/searchKeybindings.ts create mode 100644 apps/desktop/src/utils/shortcuts.ts create mode 100644 apps/desktop/tests/stores/settings-keybindings.test.ts diff --git a/apps/desktop/src/config/searchKeybindings.ts b/apps/desktop/src/config/searchKeybindings.ts new file mode 100644 index 00000000..b54295c6 --- /dev/null +++ b/apps/desktop/src/config/searchKeybindings.ts @@ -0,0 +1,121 @@ +import type { MessageKey } from '@/i18n'; +import { normalizeLocalShortcutString } from '@/utils/shortcuts'; + +export const SEARCH_KEYBINDING_ACTION_IDS = [ + 'search.history.open', + 'search.input.focus', + 'search.session.new', + 'search.model.toggle', + 'search.window.pin', + 'search.request.cancel', + 'search.draft.clearAll', +] as const; + +export type SearchKeybindingActionId = (typeof SEARCH_KEYBINDING_ACTION_IDS)[number]; + +export interface SearchKeybindingDefinition { + id: SearchKeybindingActionId; + labelKey: MessageKey; + defaultShortcut: string | null; + allowDisable: boolean; +} + +export type SearchKeybindings = Record; + +export const SEARCH_KEYBINDING_DEFINITIONS: SearchKeybindingDefinition[] = [ + { + id: 'search.history.open', + labelKey: 'settings.general.searchActions.history', + defaultShortcut: 'Mod+H', + allowDisable: true, + }, + { + id: 'search.input.focus', + labelKey: 'settings.general.searchActions.focusInput', + defaultShortcut: 'Mod+L', + allowDisable: true, + }, + { + id: 'search.session.new', + labelKey: 'settings.general.searchActions.newSession', + defaultShortcut: 'Mod+N', + allowDisable: true, + }, + { + id: 'search.model.toggle', + labelKey: 'settings.general.searchActions.modelToggle', + defaultShortcut: 'Mod+M', + allowDisable: true, + }, + { + id: 'search.window.pin', + labelKey: 'settings.general.searchActions.windowPin', + defaultShortcut: 'Mod+P', + allowDisable: true, + }, + { + id: 'search.request.cancel', + labelKey: 'settings.general.searchActions.cancelRequest', + defaultShortcut: 'Mod+.', + allowDisable: true, + }, + { + id: 'search.draft.clearAll', + labelKey: 'settings.general.searchActions.clearAll', + defaultShortcut: 'Mod+Backspace', + allowDisable: true, + }, +]; + +const SEARCH_KEYBINDING_DEFINITION_MAP = new Map( + SEARCH_KEYBINDING_DEFINITIONS.map((definition) => [definition.id, definition]) +); + +const SEARCH_KEYBINDING_ACTION_ID_SET = new Set(SEARCH_KEYBINDING_ACTION_IDS); + +export function isSearchKeybindingActionId(value: string): value is SearchKeybindingActionId { + return SEARCH_KEYBINDING_ACTION_ID_SET.has(value); +} + +export function getSearchKeybindingDefinition( + actionId: SearchKeybindingActionId +): SearchKeybindingDefinition { + const definition = SEARCH_KEYBINDING_DEFINITION_MAP.get(actionId); + if (!definition) { + throw new Error(`Unknown search keybinding action: ${actionId}`); + } + return definition; +} + +export function createDefaultSearchKeybindings(): SearchKeybindings { + return SEARCH_KEYBINDING_DEFINITIONS.reduce((accumulator, definition) => { + accumulator[definition.id] = definition.defaultShortcut; + return accumulator; + }, {} as SearchKeybindings); +} + +export function normalizeSearchKeybindings(value: unknown): SearchKeybindings { + const normalized = createDefaultSearchKeybindings(); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return normalized; + } + + for (const definition of SEARCH_KEYBINDING_DEFINITIONS) { + const candidate = (value as Record)[definition.id]; + if (candidate === null && definition.allowDisable) { + normalized[definition.id] = null; + continue; + } + + if (typeof candidate !== 'string') { + continue; + } + + const shortcut = normalizeLocalShortcutString(candidate); + if (shortcut) { + normalized[definition.id] = shortcut; + } + } + + return normalized; +} diff --git a/apps/desktop/src/database/schema.ts b/apps/desktop/src/database/schema.ts index 5f94c259..49ae142e 100644 --- a/apps/desktop/src/database/schema.ts +++ b/apps/desktop/src/database/schema.ts @@ -26,6 +26,7 @@ export enum SettingKey { AUTO_START = 'auto_start', OUTPUT_SCROLL_BEHAVIOR = 'output_scroll_behavior', SEARCH_WINDOW_SIZE_PRESET = 'search_window_size_preset', + SEARCH_KEYBINDINGS = 'search_keybindings', } export type ToolLogKind = 'mcp' | 'builtin'; diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index 7e3f242b..5dd69641 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -87,6 +87,22 @@ const zhCNMessages = { '点击输入框后按下您想要设置的快捷键组合。支持的修饰键:Ctrl、Alt、Shift', 'settings.general.winKeyUnsupported': '不支持 Win 键组合,请使用 Ctrl、Alt、Shift', 'settings.general.shortcutSaved': '快捷键保存成功', + 'settings.general.searchShortcuts': '搜索页快捷键', + 'settings.general.searchShortcutsDescription': + '自定义搜索窗口内的命令型快捷键,不会影响输入导航与全局唤起。', + 'settings.general.searchActions.history': '打开会话历史', + 'settings.general.searchActions.focusInput': '聚焦输入框', + 'settings.general.searchActions.newSession': '开始新会话', + 'settings.general.searchActions.modelToggle': '切换模型选择', + 'settings.general.searchActions.windowPin': '切换窗口置顶', + 'settings.general.searchActions.cancelRequest': '取消当前请求', + 'settings.general.searchActions.clearAll': '清空草稿与上下文', + 'settings.general.searchShortcuts.errors.modifierRequired': '快捷键至少需要一个修饰键', + 'settings.general.searchShortcuts.errors.reserved': + '该快捷键保留给输入/导航行为,请选择其他组合', + 'settings.general.searchShortcuts.errors.duplicate': '该快捷键已被“{action}”使用,请换一个组合', + 'settings.general.searchShortcuts.errors.globalConflict': + '该快捷键与全局唤起快捷键冲突,请换一个组合', 'settings.general.saveShortcutFailed': '保存快捷键到数据库失败', 'settings.general.loadSettingsFailed': '加载设置失败', 'settings.general.saveStartOnBootFailed': '保存开机自启动设置失败', @@ -816,6 +832,24 @@ const enUSMessages: Record = { 'settings.general.winKeyUnsupported': 'Win key combinations are not supported. Use Ctrl, Alt, or Shift.', 'settings.general.shortcutSaved': 'Shortcut saved', + 'settings.general.searchShortcuts': 'Search shortcuts', + 'settings.general.searchShortcutsDescription': + 'Customize command shortcuts inside the search window without changing typing, navigation, or the global activation shortcut.', + 'settings.general.searchActions.history': 'Open session history', + 'settings.general.searchActions.focusInput': 'Focus input', + 'settings.general.searchActions.newSession': 'Start new session', + 'settings.general.searchActions.modelToggle': 'Toggle model picker', + 'settings.general.searchActions.windowPin': 'Toggle window pin', + 'settings.general.searchActions.cancelRequest': 'Cancel current request', + 'settings.general.searchActions.clearAll': 'Clear draft and context', + 'settings.general.searchShortcuts.errors.modifierRequired': + 'A shortcut must include at least one modifier key', + 'settings.general.searchShortcuts.errors.reserved': + 'This shortcut is reserved for typing or navigation. Choose another combination.', + 'settings.general.searchShortcuts.errors.duplicate': + 'This shortcut is already used by "{action}". Choose another combination.', + 'settings.general.searchShortcuts.errors.globalConflict': + 'This shortcut conflicts with the global activation shortcut. Choose another combination.', 'settings.general.saveShortcutFailed': 'Failed to save shortcut to database', 'settings.general.loadSettingsFailed': 'Failed to load settings', 'settings.general.saveStartOnBootFailed': 'Failed to save start-on-boot setting', diff --git a/apps/desktop/src/services/EventService/types.ts b/apps/desktop/src/services/EventService/types.ts index 36558d81..33da3bb6 100644 --- a/apps/desktop/src/services/EventService/types.ts +++ b/apps/desktop/src/services/EventService/types.ts @@ -11,6 +11,7 @@ import type { PopupSessionSearchQueryChangePayload, } from '@services/PopupService/types'; +import type { SearchKeybindings } from '@/config/searchKeybindings'; import type { SessionStatusReminderKind } from '@/utils/session'; export type { SessionStatusReminderKind } from '@/utils/session'; @@ -72,6 +73,7 @@ export interface McpStatusChangeEvent { export type GeneralSettingKey = | 'global_shortcut' + | 'search_keybindings' | 'start_on_boot' | 'start_minimized' | 'output_scroll_behavior' @@ -85,7 +87,7 @@ export interface SettingsGeneralUpdatedEvent { sourceId: string; windowLabel: string; key: GeneralSettingKey; - value: string | number | boolean | null; + value: string | number | boolean | SearchKeybindings | null; } export interface AiModelsUpdatedEvent { diff --git a/apps/desktop/src/stores/settings.ts b/apps/desktop/src/stores/settings.ts index 260c7b8a..50861aaa 100644 --- a/apps/desktop/src/stores/settings.ts +++ b/apps/desktop/src/stores/settings.ts @@ -12,6 +12,11 @@ import { DEFAULT_APP_UPDATE_CHANNEL, normalizeAppUpdateChannel, } from '@/config/appUpdate'; +import { + createDefaultSearchKeybindings, + normalizeSearchKeybindings, + type SearchKeybindings, +} from '@/config/searchKeybindings'; import { DEFAULT_SEARCH_WINDOW_SIZE_PRESET, resolveSearchWindowDefaultSize, @@ -26,6 +31,7 @@ export type OutputScrollBehavior = 'follow_output' | 'stay_position' | 'jump_to_ export interface GeneralSettingsData { globalShortcut: string; + searchKeybindings: SearchKeybindings; startOnBoot: boolean; startMinimized: boolean; outputScrollBehavior: OutputScrollBehavior; @@ -39,6 +45,7 @@ export interface GeneralSettingsData { const DEFAULT_GENERAL_SETTINGS: GeneralSettingsData = { globalShortcut: 'Alt+Space', + searchKeybindings: createDefaultSearchKeybindings(), startOnBoot: false, startMinimized: true, outputScrollBehavior: 'follow_output', @@ -53,6 +60,9 @@ const DEFAULT_GENERAL_SETTINGS: GeneralSettingsData = { function createDefaultGeneralSettings(): GeneralSettingsData { return { ...DEFAULT_GENERAL_SETTINGS, + searchKeybindings: { + ...DEFAULT_GENERAL_SETTINGS.searchKeybindings, + }, searchWindowDefaultSize: { ...DEFAULT_GENERAL_SETTINGS.searchWindowDefaultSize, }, @@ -112,9 +122,28 @@ export const useSettingsStore = defineStore('settings', () => { }; } + function applySearchKeybindings(value: unknown): void { + settings.value.searchKeybindings = normalizeSearchKeybindings(value); + } + + function parsePersistedSearchKeybindings(value: string | null): SearchKeybindings { + if (!value) { + return createDefaultSearchKeybindings(); + } + + try { + return normalizeSearchKeybindings(JSON.parse(value)); + } catch { + return createDefaultSearchKeybindings(); + } + } + function cloneSettingsSnapshot(): GeneralSettingsData { return { ...settings.value, + searchKeybindings: { + ...settings.value.searchKeybindings, + }, searchWindowDefaultSize: { ...settings.value.searchWindowDefaultSize, }, @@ -128,6 +157,11 @@ export const useSettingsStore = defineStore('settings', () => { value || DEFAULT_GENERAL_SETTINGS.globalShortcut ); break; + case 'search_keybindings': + applySearchKeybindings( + typeof value === 'string' ? parsePersistedSearchKeybindings(value) : value + ); + break; case 'start_on_boot': settings.value.startOnBoot = typeof value === 'boolean' ? value : String(value) === 'true'; @@ -164,6 +198,8 @@ export const useSettingsStore = defineStore('settings', () => { switch (key) { case 'global_shortcut': return settings.value.globalShortcut; + case 'search_keybindings': + return JSON.stringify(settings.value.searchKeybindings); case 'start_on_boot': return String(settings.value.startOnBoot); case 'start_minimized': @@ -189,6 +225,10 @@ export const useSettingsStore = defineStore('settings', () => { switch (key) { case 'global_shortcut': return settings.value.globalShortcut; + case 'search_keybindings': + return { + ...settings.value.searchKeybindings, + }; case 'start_on_boot': return settings.value.startOnBoot; case 'start_minimized': @@ -222,6 +262,7 @@ export const useSettingsStore = defineStore('settings', () => { try { const [ globalShortcut, + searchKeybindings, startOnBoot, startMinimized, outputScroll, @@ -232,6 +273,7 @@ export const useSettingsStore = defineStore('settings', () => { appUpdateLastCheckedAt, ] = await Promise.all([ getSettingValue({ key: 'global_shortcut' }), + getSettingValue({ key: 'search_keybindings' }), getSettingValue({ key: 'start_on_boot' }), getSettingValue({ key: 'start_minimized' }), getSettingValue({ key: 'output_scroll_behavior' }), @@ -244,6 +286,7 @@ export const useSettingsStore = defineStore('settings', () => { settings.value.globalShortcut = globalShortcut || DEFAULT_GENERAL_SETTINGS.globalShortcut; + settings.value.searchKeybindings = parsePersistedSearchKeybindings(searchKeybindings); settings.value.startOnBoot = startOnBoot === null ? DEFAULT_GENERAL_SETTINGS.startOnBoot @@ -264,6 +307,7 @@ export const useSettingsStore = defineStore('settings', () => { await Promise.allSettled([ persistDefaultIfMissing('global_shortcut', globalShortcut), + persistDefaultIfMissing('search_keybindings', searchKeybindings), persistDefaultIfMissing('start_on_boot', startOnBoot), persistDefaultIfMissing('start_minimized', startMinimized), persistDefaultIfMissing('output_scroll_behavior', outputScroll), @@ -372,6 +416,10 @@ export const useSettingsStore = defineStore('settings', () => { await updateSetting('global_shortcut', shortcut); } + async function updateSearchKeybindings(searchKeybindings: SearchKeybindings) { + await updateSetting('search_keybindings', normalizeSearchKeybindings(searchKeybindings)); + } + async function updateStartOnBoot(enabled: boolean) { await updateSetting('start_on_boot', enabled); } @@ -406,6 +454,7 @@ export const useSettingsStore = defineStore('settings', () => { const outputScrollBehavior = computed(() => settings.value.outputScrollBehavior); const globalShortcut = computed(() => settings.value.globalShortcut); + const searchKeybindings = computed(() => settings.value.searchKeybindings); const searchWindowSizePreset = computed(() => settings.value.searchWindowSizePreset); const searchWindowDefaultSize = computed(() => settings.value.searchWindowDefaultSize); const language = computed(() => settings.value.language); @@ -419,6 +468,7 @@ export const useSettingsStore = defineStore('settings', () => { loading, outputScrollBehavior, globalShortcut, + searchKeybindings, searchWindowSizePreset, searchWindowDefaultSize, language, @@ -429,6 +479,7 @@ export const useSettingsStore = defineStore('settings', () => { dispose, refresh, updateGlobalShortcut, + updateSearchKeybindings, updateStartOnBoot, updateStartMinimized, updateOutputScrollBehavior, diff --git a/apps/desktop/src/utils/shortcuts.ts b/apps/desktop/src/utils/shortcuts.ts new file mode 100644 index 00000000..8dd73ae8 --- /dev/null +++ b/apps/desktop/src/utils/shortcuts.ts @@ -0,0 +1,306 @@ +export interface ShortcutMatchInput { + key: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} + +export interface CapturedShortcutResult { + shortcut: string; + displayShortcut: string; +} + +const MODIFIER_DISPLAY_ORDER = ['Mod', 'Ctrl', 'Alt', 'Shift'] as const; +const SUPPORTED_CAPTURE_MODIFIERS = new Set(['Ctrl', 'Alt', 'Shift', 'Mod']); +const RESERVED_LOCAL_SHORTCUT_KEYS = new Set([ + 'Enter', + 'Esc', + 'Tab', + 'Up', + 'Down', + 'Left', + 'Right', +]); +const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta', 'OS']); + +const KEY_DISPLAY_MAP: Record = { + ' ': 'Space', + Spacebar: 'Space', + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + Escape: 'Esc', + Esc: 'Esc', + Delete: 'Del', + Del: 'Del', + '.': '.', +}; + +const ALIAS_MAP: Record = { + cmd: 'Mod', + command: 'Mod', + meta: 'Mod', + win: 'Mod', + super: 'Mod', + ctrl: 'Ctrl', + control: 'Ctrl', + option: 'Alt', + alt: 'Alt', + shift: 'Shift', + esc: 'Esc', + escape: 'Esc', + delete: 'Del', + del: 'Del', + return: 'Enter', + enter: 'Enter', + pageup: 'PageUp', + pagedown: 'PageDown', + arrowup: 'Up', + up: 'Up', + arrowdown: 'Down', + down: 'Down', + arrowleft: 'Left', + left: 'Left', + arrowright: 'Right', + right: 'Right', + backspace: 'Backspace', + space: 'Space', +}; + +function isMacPlatform(): boolean { + if (typeof navigator === 'undefined') { + return false; + } + + return /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform); +} + +function getPrimaryModifierLabel(): 'Cmd' | 'Ctrl' { + return isMacPlatform() ? 'Cmd' : 'Ctrl'; +} + +function usesPrimaryModifier(input: ShortcutMatchInput): boolean { + return isMacPlatform() ? Boolean(input.metaKey) : Boolean(input.ctrlKey); +} + +function normalizeShortcutToken(token: string): string | null { + const trimmed = token.trim(); + if (!trimmed) { + return null; + } + + const alias = ALIAS_MAP[trimmed.toLowerCase()]; + if (alias) { + return alias; + } + + if (trimmed.length === 1) { + return trimmed.toUpperCase(); + } + + if (/^f\d{1,2}$/i.test(trimmed)) { + return trimmed.toUpperCase(); + } + + return trimmed; +} + +function normalizeEventKey(key: string): string | null { + if (!key) { + return null; + } + + const mappedKey = KEY_DISPLAY_MAP[key] ?? key; + return normalizeShortcutToken(mappedKey); +} + +function createShortcutParts(shortcut: string): { modifiers: string[]; key: string | null } { + const parts = shortcut + .split('+') + .map((part) => normalizeShortcutToken(part)) + .filter((part): part is string => Boolean(part)); + + const modifierSet = new Set(); + let key: string | null = null; + for (const part of parts) { + if (SUPPORTED_CAPTURE_MODIFIERS.has(part)) { + modifierSet.add(part); + continue; + } + + key = part; + } + + const modifiers = MODIFIER_DISPLAY_ORDER.filter((modifier) => modifierSet.has(modifier)); + return { modifiers, key }; +} + +export function normalizeLocalShortcutString(shortcut: string | null | undefined): string | null { + if (!shortcut) { + return null; + } + + const { modifiers, key } = createShortcutParts(shortcut); + if (!key) { + return null; + } + + return [...modifiers, key].join('+'); +} + +export function formatShortcutForDisplay(shortcut: string | null | undefined): string { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return '—'; + } + + const { modifiers, key } = createShortcutParts(normalized); + const displayModifiers = modifiers.map((modifier) => { + if (modifier === 'Mod') { + return getPrimaryModifierLabel(); + } + return modifier; + }); + + return [...displayModifiers, key].join('+'); +} + +export function toCurrentPlatformShortcut(shortcut: string | null | undefined): string | null { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return null; + } + + return formatShortcutForDisplay(normalized); +} + +export function matchShortcut( + shortcut: string | null | undefined, + input: ShortcutMatchInput +): boolean { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return false; + } + + const { modifiers, key } = createShortcutParts(normalized); + const eventKey = normalizeEventKey(input.key); + if (!eventKey || eventKey !== key) { + return false; + } + + const isMac = isMacPlatform(); + const expectsMod = modifiers.includes('Mod'); + const expectsCtrl = modifiers.includes('Ctrl'); + const expectsAlt = modifiers.includes('Alt'); + const expectsShift = modifiers.includes('Shift'); + + if (expectsMod !== usesPrimaryModifier(input)) { + return false; + } + + const effectiveCtrl = isMac + ? Boolean(input.ctrlKey) + : expectsMod + ? false + : Boolean(input.ctrlKey); + if (expectsCtrl !== effectiveCtrl) { + return false; + } + + if (expectsAlt !== Boolean(input.altKey)) { + return false; + } + + if (expectsShift !== Boolean(input.shiftKey)) { + return false; + } + + if (!isMac && input.metaKey) { + return false; + } + + return true; +} + +export function captureShortcutFromKeyboardEvent( + event: KeyboardEvent +): CapturedShortcutResult | null { + if (MODIFIER_KEYS.has(event.key)) { + return null; + } + + if (!isMacPlatform() && event.metaKey) { + return null; + } + + const key = normalizeEventKey(event.key); + if (!key) { + return null; + } + + const modifiers: string[] = []; + if (usesPrimaryModifier(event)) { + modifiers.push('Mod'); + } + if (event.ctrlKey && isMacPlatform()) { + modifiers.push('Ctrl'); + } + if (event.altKey) { + modifiers.push('Alt'); + } + if (event.shiftKey) { + modifiers.push('Shift'); + } + + const shortcut = [...modifiers, key].join('+'); + return { + shortcut, + displayShortcut: formatShortcutForDisplay(shortcut), + }; +} + +export function isReservedLocalShortcut(shortcut: string | null | undefined): boolean { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return false; + } + + const { key } = createShortcutParts(normalized); + return key ? RESERVED_LOCAL_SHORTCUT_KEYS.has(key) : false; +} + +export function hasRequiredModifier(shortcut: string | null | undefined): boolean { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return false; + } + + const { modifiers } = createShortcutParts(normalized); + return modifiers.length > 0; +} + +export function findShortcutConflict( + shortcut: string | null | undefined, + entries: Array<{ id: T; shortcut: string | null | undefined }>, + excludeId?: T +): T | null { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return null; + } + + for (const entry of entries) { + if (excludeId && entry.id === excludeId) { + continue; + } + + if (normalizeLocalShortcutString(entry.shortcut) === normalized) { + return entry.id; + } + } + + return null; +} diff --git a/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts b/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts index 64193b17..6b8fef4e 100644 --- a/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts +++ b/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts @@ -1,15 +1,18 @@ -import type { SearchPopupSurfaceType } from '../searchInteraction'; +import type { SearchKeybindingActionId, SearchKeybindings } from '@/config/searchKeybindings'; +import { matchShortcut } from '@/utils/shortcuts'; +export type SearchPopupSurfaceType = 'model-dropdown-surface' | 'session-history-surface'; type SearchKeyboardSurface = 'search-surface' | SearchPopupSurfaceType; type SearchQuickSearchDirection = 'up' | 'down' | 'left' | 'right'; -type SearchPrimaryShortcutKey = 'h' | 'l' | 'n' | 'm' | 'p' | '.' | 'backspace'; +export type SessionInputHistoryDirection = 'older' | 'newer'; +export type SessionInputHistoryNavigationResult = 'navigated' | 'blocked' | 'ignored'; interface PendingApprovalState { callId?: string; keyboardApproveAt: number; } -interface SearchKeyboardRouteInput { +export interface SearchKeyboardRouteInput { key: string; shiftKey?: boolean; ctrlKey?: boolean; @@ -18,6 +21,7 @@ interface SearchKeyboardRouteInput { } interface CreateSearchKeyboardRouterOptions { + getSearchKeybindings: () => SearchKeybindings; getPendingApproval: () => PendingApprovalState | null; getActiveSurface: () => SearchKeyboardSurface; hasActivePopupWindowFocus: () => boolean; @@ -27,6 +31,8 @@ interface CreateSearchKeyboardRouterOptions { hasQuickSearchHighlight: () => boolean; shouldTriggerQuickSearch: (query: string) => boolean; isMultiLineCursor: () => boolean; + isCursorAtTextStart: () => boolean; + isCursorAtEnd: () => boolean; hasModelOverride: () => boolean; getSessionHistoryCount: () => number; isLoading: () => boolean; @@ -39,6 +45,14 @@ interface CreateSearchKeyboardRouterOptions { onMoveQuickSearchSelection: (direction: SearchQuickSearchDirection) => void; onOpenHighlightedQuickSearchItem: () => void | Promise; onCloseQuickSearch: () => void; + onQuickSearchPageUp: () => void; + onQuickSearchPageDown: () => void; + onQuickSearchContextMenu: () => void; + onQuickSearchToggleView: () => void; + onQuickSearchCollapse: () => void; + onNavigateInputHistory: ( + direction: SessionInputHistoryDirection + ) => SessionInputHistoryNavigationResult; onHideAllPopups: () => void | Promise; onCancelRequest: () => void; onClearModelOverride: () => void; @@ -46,7 +60,7 @@ interface CreateSearchKeyboardRouterOptions { onClearSession: () => void; onClearDraft: () => void; onClearAll: () => void; - onPrimaryShortcut: (key: SearchPrimaryShortcutKey) => void | Promise; + onSearchKeybindingAction: (actionId: SearchKeybindingActionId) => void | Promise; } /** @@ -69,30 +83,30 @@ function isTypingAttemptDuringApproval(input: SearchKeyboardRouteInput) { return input.key.length === 1 || input.key === 'Backspace' || input.key === 'Delete'; } -/** - * 判断是否命中 Ctrl/Cmd 主修饰键快捷键。 - */ -function resolvePrimaryShortcutKey( +function resolveSearchKeybindingAction( input: SearchKeyboardRouteInput, - queryText: string, - isLoading: boolean -): SearchPrimaryShortcutKey | null { - const hasPrimaryModifier = input.ctrlKey || input.metaKey; - if (!hasPrimaryModifier || input.altKey || input.shiftKey) { - return null; + keybindings: SearchKeybindings, + context: { + isLoading: boolean; + hasClearableState: boolean; } +): SearchKeybindingActionId | null { + for (const [actionId, shortcut] of Object.entries(keybindings) as Array< + [SearchKeybindingActionId, string | null] + >) { + if (!matchShortcut(shortcut, input)) { + continue; + } - const normalizedKey = input.key.toLowerCase(); - if (normalizedKey === '.') { - return isLoading ? '.' : null; - } + if (actionId === 'search.request.cancel' && !context.isLoading) { + continue; + } - if (normalizedKey === 'backspace') { - return queryText.trim() ? 'backspace' : null; - } + if (actionId === 'search.draft.clearAll' && !context.hasClearableState) { + continue; + } - if (['h', 'l', 'n', 'm', 'p'].includes(normalizedKey)) { - return normalizedKey as SearchPrimaryShortcutKey; + return actionId; } return null; @@ -103,6 +117,7 @@ function resolvePrimaryShortcutKey( */ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOptions) { const { + getSearchKeybindings, getPendingApproval, getActiveSurface, hasActivePopupWindowFocus, @@ -112,6 +127,8 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp hasQuickSearchHighlight, shouldTriggerQuickSearch, isMultiLineCursor, + isCursorAtTextStart, + isCursorAtEnd, hasModelOverride, getSessionHistoryCount, isLoading, @@ -124,18 +141,21 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp onMoveQuickSearchSelection, onOpenHighlightedQuickSearchItem, onCloseQuickSearch, + onQuickSearchPageUp, + onQuickSearchPageDown, + onQuickSearchContextMenu, + onQuickSearchToggleView, + onQuickSearchCollapse, + onNavigateInputHistory, onHideAllPopups, onCancelRequest, onClearModelOverride, onHideWindow, onClearSession, onClearDraft, - onPrimaryShortcut, + onSearchKeybindingAction, } = options; - /** - * 根据当前 surface 和审批状态解释一次键盘输入。 - */ function route(input: SearchKeyboardRouteInput) { const queryText = getQueryText(); const pendingApproval = getPendingApproval(); @@ -160,9 +180,20 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp } } - const primaryShortcut = resolvePrimaryShortcutKey(input, queryText, isLoading()); - if (primaryShortcut) { - runKeyboardEffect(() => onPrimaryShortcut(primaryShortcut)); + const searchKeybindingAction = resolveSearchKeybindingAction( + input, + getSearchKeybindings(), + { + isLoading: isLoading(), + hasClearableState: + Boolean(queryText.trim()) || + hasAttachments() || + hasModelOverride() || + getSessionHistoryCount() > 0, + } + ); + if (searchKeybindingAction) { + runKeyboardEffect(() => onSearchKeybindingAction(searchKeybindingAction)); return true; } @@ -171,6 +202,11 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp } if (input.key === 'Escape' || input.key === 'Esc') { + if (isQuickSearchOpen() && hasQuickSearchHighlight()) { + onQuickSearchCollapse(); + return true; + } + if (getActiveSurface() !== 'search-surface') { runKeyboardEffect(onHideAllPopups); return true; @@ -181,25 +217,21 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp return true; } - // Step 1: Clear input text first without exiting the current conversation if (queryText.trim()) { onClearDraft(); return true; } - // Step 2: Clear model selection if (hasModelOverride()) { onClearModelOverride(); return true; } - // Step 3: Exit conversation if session exists if (getSessionHistoryCount() > 0) { onClearSession(); return true; } - // Step 4: Hide window if no session runKeyboardEffect(onHideWindow); return true; } @@ -213,6 +245,26 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp } if (isQuickSearchOpen()) { + if (input.key === 'PageUp') { + onQuickSearchPageUp(); + return true; + } + + if (input.key === 'PageDown') { + onQuickSearchPageDown(); + return true; + } + + if (input.key === 'ContextMenu' || (input.key === 'F10' && input.shiftKey)) { + onQuickSearchContextMenu(); + return true; + } + + if (input.key.toLowerCase() === 'g' && (input.ctrlKey || input.metaKey)) { + onQuickSearchToggleView(); + return true; + } + if (hasQuickSearchHighlight()) { const directionMap: Partial> = { ArrowUp: 'up', @@ -247,23 +299,33 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp } if (getActiveSurface() === 'search-surface' && !isQuickSearchOpen()) { - if (input.key === 'ArrowDown') { - if (!shouldTriggerQuickSearch(queryText)) { + if (input.key === 'ArrowUp') { + if (isMultiLineCursor() && !isCursorAtTextStart()) { return false; } - onOpenQuickSearch(); - return true; + return onNavigateInputHistory('older') !== 'ignored'; } - if (input.key === 'ArrowUp') { - if (isMultiLineCursor()) { + if (input.key === 'ArrowDown') { + if (isMultiLineCursor() && !isCursorAtEnd()) { + return false; + } + + if (onNavigateInputHistory('newer') === 'navigated') { + return true; + } + + if (!shouldTriggerQuickSearch(queryText)) { return false; } if (queryText.trim() || hasAttachments()) { runKeyboardEffect(onSubmit); + return true; } + + onOpenQuickSearch(); return true; } } diff --git a/apps/desktop/src/views/SearchView/composables/searchInteraction.ts b/apps/desktop/src/views/SearchView/composables/searchInteraction.ts index eaea8da4..eb79ce15 100644 --- a/apps/desktop/src/views/SearchView/composables/searchInteraction.ts +++ b/apps/desktop/src/views/SearchView/composables/searchInteraction.ts @@ -7,6 +7,7 @@ import { AppEvent, eventService } from '@services/EventService'; import type { PopupKeydownPayload } from '@services/PopupService'; import { computed, type ComputedRef, reactive, type Ref, ref, watch } from 'vue'; +import type { SearchKeybindings } from '@/config/searchKeybindings'; import { useAskUserStore } from '@/stores/askUser'; import { cloneInputHistorySnapshot, @@ -25,6 +26,7 @@ import type { SearchOverlayState, SearchPageController, } from '../types'; +import { createSearchKeyboardRouter as createConfigurableSearchKeyboardRouter } from './interaction/useSearchKeyboardRouter'; export type SearchActivationSource = 'shortcut' | 'manual' | 'unknown'; @@ -97,70 +99,12 @@ interface SyncOverlayStateOptions { force?: boolean; } -type SearchKeyboardSurface = 'search-surface' | SearchPopupSurfaceType; -type SearchQuickSearchDirection = 'up' | 'down' | 'left' | 'right'; -type SearchPrimaryShortcutKey = 'h' | 'l' | 'n' | 'm' | 'p' | '.' | 'backspace'; export type SessionInputHistoryDirection = 'older' | 'newer'; export type SessionInputHistoryNavigationResult = 'navigated' | 'blocked' | 'ignored'; -interface PendingApprovalState { - callId?: string; - keyboardApproveAt: number; -} - -interface SearchKeyboardRouteInput { - key: string; - shiftKey?: boolean; - ctrlKey?: boolean; - metaKey?: boolean; - altKey?: boolean; -} - -interface CreateSearchKeyboardRouterOptions { - getPendingApproval: () => PendingApprovalState | null; - getActiveSurface: () => SearchKeyboardSurface; - hasActivePopupWindowFocus: () => boolean; - getQueryText: () => string; - hasAttachments: () => boolean; - isQuickSearchOpen: () => boolean; - hasQuickSearchHighlight: () => boolean; - shouldTriggerQuickSearch: (query: string) => boolean; - isMultiLineCursor: () => boolean; - isCursorAtStart: () => boolean; - isCursorAtTextStart: () => boolean; - isCursorAtEnd: () => boolean; - hasModelOverride: () => boolean; - getSessionHistoryCount: () => number; - isLoading: () => boolean; - onPromptApprovalAttention: () => void; - onRejectApproval: (callId?: string) => void; - onApproveApproval: (callId?: string) => void; - onForwardToPopup: (key: string) => void; - onSubmit: () => void | Promise; - onOpenQuickSearch: () => void; - onMoveQuickSearchSelection: (direction: SearchQuickSearchDirection) => void; - onOpenHighlightedQuickSearchItem: () => void | Promise; - onCloseQuickSearch: () => void; - onQuickSearchPageUp: () => void; - onQuickSearchPageDown: () => void; - onQuickSearchContextMenu: () => void; - onQuickSearchToggleView: () => void; - onQuickSearchCollapse: () => void; - onNavigateInputHistory: ( - direction: SessionInputHistoryDirection - ) => SessionInputHistoryNavigationResult; - onHideAllPopups: () => void | Promise; - onCancelRequest: () => void; - onClearModelOverride: () => void; - onHideWindow: () => void | Promise; - onClearSession: () => void; - onClearDraft: () => void; - onClearAll: () => void; - onPrimaryShortcut: (key: SearchPrimaryShortcutKey) => void | Promise; -} - export interface UseSearchKeyboardOptions { viewReady: Ref; + searchKeybindings: Readonly>; queryText: Ref; attachments: Ref; cursorContext: Ref; @@ -697,289 +641,13 @@ export function useSearchOverlayMachine(options: UseSearchOverlayMachineOptions) }; } -/** - * 在同步键盘路由中启动可能异步的副作用。 - */ -function runKeyboardEffect(effect: () => void | Promise) { - void Promise.resolve(effect()).catch((error) => { - console.error('[SearchKeyboardRouter] Failed to handle keyboard effect:', error); - }); -} - -/** - * 判断审批态下是否属于“误输入”。 - */ -function isTypingAttemptDuringApproval(input: SearchKeyboardRouteInput) { - if (input.ctrlKey || input.metaKey || input.altKey) { - return false; - } - - return input.key.length === 1 || input.key === 'Backspace' || input.key === 'Delete'; -} - -/** - * 判断是否命中 Ctrl/Cmd 主修饰键快捷键。 - */ -function resolvePrimaryShortcutKey( - input: SearchKeyboardRouteInput, - queryText: string, - isLoading: boolean -): SearchPrimaryShortcutKey | null { - const hasPrimaryModifier = input.ctrlKey || input.metaKey; - if (!hasPrimaryModifier || input.altKey || input.shiftKey) { - return null; - } - - const normalizedKey = input.key.toLowerCase(); - if (normalizedKey === '.') { - return isLoading ? '.' : null; - } - - if (normalizedKey === 'backspace') { - return queryText.trim() ? 'backspace' : null; - } - - if (['h', 'l', 'n', 'm', 'p'].includes(normalizedKey)) { - return normalizedKey as SearchPrimaryShortcutKey; - } - - return null; -} - -/** - * 纯键盘语义路由器。 - */ -export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOptions) { - const { - getPendingApproval, - getActiveSurface, - hasActivePopupWindowFocus, - getQueryText, - hasAttachments, - isQuickSearchOpen, - hasQuickSearchHighlight, - shouldTriggerQuickSearch, - isMultiLineCursor, - isCursorAtTextStart, - isCursorAtEnd, - hasModelOverride, - getSessionHistoryCount, - isLoading, - onPromptApprovalAttention, - onRejectApproval, - onApproveApproval, - onForwardToPopup, - onSubmit, - onOpenQuickSearch, - onMoveQuickSearchSelection, - onOpenHighlightedQuickSearchItem, - onCloseQuickSearch, - onQuickSearchPageUp, - onQuickSearchPageDown, - onQuickSearchContextMenu, - onQuickSearchToggleView, - onQuickSearchCollapse, - onNavigateInputHistory, - onHideAllPopups, - onCancelRequest, - onClearModelOverride, - onHideWindow, - onClearSession, - onClearDraft, - onPrimaryShortcut, - } = options; - - function route(input: SearchKeyboardRouteInput) { - const queryText = getQueryText(); - const pendingApproval = getPendingApproval(); - if (pendingApproval) { - if (input.key === 'Escape' || input.key === 'Esc') { - onRejectApproval(pendingApproval.callId); - return true; - } - - if (input.key === 'Enter') { - if (!input.shiftKey && Date.now() >= pendingApproval.keyboardApproveAt) { - onApproveApproval(pendingApproval.callId); - } else { - onPromptApprovalAttention(); - } - return true; - } - - if (isTypingAttemptDuringApproval(input)) { - onPromptApprovalAttention(); - return true; - } - } - - const primaryShortcut = resolvePrimaryShortcutKey(input, queryText, isLoading()); - if (primaryShortcut) { - runKeyboardEffect(() => onPrimaryShortcut(primaryShortcut)); - return true; - } - - if (hasActivePopupWindowFocus()) { - return true; - } - - if (input.key === 'Escape' || input.key === 'Esc') { - // 快速搜索有高亮时,Escape 只收缩面板、清除高亮,不走清空输入链路。 - if (isQuickSearchOpen() && hasQuickSearchHighlight()) { - onQuickSearchCollapse(); - return true; - } - - if (getActiveSurface() !== 'search-surface') { - runKeyboardEffect(onHideAllPopups); - return true; - } - - if (isLoading()) { - onCancelRequest(); - return true; - } - - // Step 1: Clear input text first without exiting the current conversation - if (queryText.trim()) { - onClearDraft(); - return true; - } - - // Step 2: Clear model selection - if (hasModelOverride()) { - onClearModelOverride(); - return true; - } - - // Step 3: Exit conversation if session exists - if (getSessionHistoryCount() > 0) { - onClearSession(); - return true; - } - - // Step 4: Hide window if no session - runKeyboardEffect(onHideWindow); - return true; - } - - if ( - getActiveSurface() === 'model-dropdown-surface' && - ['ArrowUp', 'ArrowDown', 'Enter'].includes(input.key) - ) { - onForwardToPopup(input.key); - return true; - } - - if (isQuickSearchOpen()) { - // PageUp/PageDown 翻页 - if (input.key === 'PageUp') { - onQuickSearchPageUp(); - return true; - } - if (input.key === 'PageDown') { - onQuickSearchPageDown(); - return true; - } - - // Menu 键或 Shift+F10 打开右键菜单 - if (input.key === 'ContextMenu' || (input.key === 'F10' && input.shiftKey)) { - onQuickSearchContextMenu(); - return true; - } - - // Ctrl+G 切换网格/列表视图 - if (input.key === 'g' && (input.ctrlKey || input.metaKey)) { - onQuickSearchToggleView(); - return true; - } - - if (hasQuickSearchHighlight()) { - const directionMap: Partial> = { - ArrowUp: 'up', - ArrowDown: 'down', - ArrowLeft: 'left', - ArrowRight: 'right', - }; - const direction = directionMap[input.key]; - if (direction) { - onMoveQuickSearchSelection(direction); - return true; - } - - if (input.key === 'Enter') { - runKeyboardEffect(onOpenHighlightedQuickSearchItem); - return true; - } - } else { - if (input.key === 'ArrowDown') { - onMoveQuickSearchSelection('down'); - return true; - } - - if (input.key === 'Enter' && !input.shiftKey) { - onCloseQuickSearch(); - if (queryText.trim()) { - runKeyboardEffect(onSubmit); - } - return true; - } - } - } - - if (getActiveSurface() === 'search-surface' && !isQuickSearchOpen()) { - if (input.key === 'ArrowUp') { - if (isMultiLineCursor() && !isCursorAtTextStart()) { - return false; - } - - return onNavigateInputHistory('older') !== 'ignored'; - } - - if (input.key === 'ArrowDown') { - if (isMultiLineCursor() && !isCursorAtEnd()) { - return false; - } - - if (onNavigateInputHistory('newer') === 'navigated') { - return true; - } - - if (!shouldTriggerQuickSearch(queryText)) { - return false; - } - - if (queryText.trim() || hasAttachments()) { - runKeyboardEffect(onSubmit); - return true; - } - - onOpenQuickSearch(); - return true; - } - } - - if (getActiveSurface() === 'search-surface' && input.key === 'Enter' && !input.shiftKey) { - if (queryText.trim() || hasAttachments()) { - runKeyboardEffect(onSubmit); - } - return true; - } - - return false; - } - - return { - route, - }; -} - /** * 创建 SearchView 页面级键盘处理器。 */ export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { const { viewReady, + searchKeybindings, queryText, attachments, cursorContext, @@ -1015,7 +683,8 @@ export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { } = options; let lastBackspaceTime = 0; - const keyboardRouter = createSearchKeyboardRouter({ + const keyboardRouter = createConfigurableSearchKeyboardRouter({ + getSearchKeybindings: () => searchKeybindings.value, getPendingApproval: () => pendingToolApproval.value ? { @@ -1043,7 +712,6 @@ export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { hasQuickSearchHighlight: () => controller.isQuickSearchItemHighlighted(), shouldTriggerQuickSearch, isMultiLineCursor: () => cursorContext.value.isMultiLine, - isCursorAtStart: () => cursorContext.value.cursorAtStart, isCursorAtTextStart: () => cursorContext.value.cursorAtTextStart, isCursorAtEnd: () => cursorContext.value.cursorAtEnd, hasModelOverride: () => Boolean(modelOverride.value.modelId), @@ -1113,41 +781,35 @@ export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { onClearAll: () => { clearAll(); }, - onPrimaryShortcut: async (key) => { - if (key === 'h') { - await openHistoryDialog(); - return; - } - - if (key === 'l') { - await hideAllPopups(); - await controller.focusSearchInput(); - return; - } - - if (key === 'n') { - if (sessionHistory.value.length > 0) { - await startNewSession(); - } - return; - } - - if (key === 'm') { - await toggleModelDropdown(); - return; - } - - if (key === 'p') { - await toggleWindowPin(); - return; - } - - if (key === '.') { - cancelRequest(); - return; + onSearchKeybindingAction: async (actionId) => { + switch (actionId) { + case 'search.history.open': + await openHistoryDialog(); + return; + case 'search.input.focus': + await hideAllPopups(); + await controller.focusSearchInput(); + return; + case 'search.session.new': + if (sessionHistory.value.length > 0) { + await startNewSession(); + } + return; + case 'search.model.toggle': + await toggleModelDropdown(); + return; + case 'search.window.pin': + await toggleWindowPin(); + return; + case 'search.request.cancel': + cancelRequest(); + return; + case 'search.draft.clearAll': + clearAll(); + return; + default: + return; } - - clearAll(); }, }); diff --git a/apps/desktop/src/views/SearchView/index.vue b/apps/desktop/src/views/SearchView/index.vue index 1cf2a4d6..d210456a 100644 --- a/apps/desktop/src/views/SearchView/index.vue +++ b/apps/desktop/src/views/SearchView/index.vue @@ -115,7 +115,7 @@ const inputHistoryRestoreVersion = ref(0); const mcpStore = useMcpStore(); const settingsStore = useSettingsStore(); - const { searchWindowDefaultSize } = storeToRefs(settingsStore); + const { searchWindowDefaultSize, searchKeybindings } = storeToRefs(settingsStore); const { sessionStatuses, refreshAllStatuses: refreshSessionStatuses } = useSessionStatus(); const { isPinned, syncWindowPinState, setWindowPinned, toggleWindowPin } = useSearchWindowPin(); const widgetBridgeWindow = window as Window & { @@ -508,6 +508,7 @@ useSearchKeyboard({ viewReady, + searchKeybindings, queryText, attachments, cursorContext, diff --git a/apps/desktop/src/views/SettingsView/components/General/index.vue b/apps/desktop/src/views/SettingsView/components/General/index.vue index fe8aaed8..5ac0ec3e 100644 --- a/apps/desktop/src/views/SettingsView/components/General/index.vue +++ b/apps/desktop/src/views/SettingsView/components/General/index.vue @@ -7,6 +7,11 @@ import { storeToRefs } from 'pinia'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; + import { + getSearchKeybindingDefinition, + SEARCH_KEYBINDING_DEFINITIONS, + type SearchKeybindingActionId, + } from '@/config/searchKeybindings'; import { resolveSearchWindowDefaultSize, type SearchWindowSizePreset, @@ -20,6 +25,15 @@ t, } from '@/i18n'; import { type OutputScrollBehavior, useSettingsStore } from '@/stores/settings'; + import { + captureShortcutFromKeyboardEvent, + findShortcutConflict, + formatShortcutForDisplay, + hasRequiredModifier, + isReservedLocalShortcut, + normalizeLocalShortcutString, + toCurrentPlatformShortcut, + } from '@/utils/shortcuts'; import { resolveShortcutCaptureCompletion } from './shortcutCapture'; import UpdateSettingsSection from './UpdateSettingsSection.vue'; @@ -74,6 +88,17 @@ label: LOCALE_LABELS[value], })); + const searchShortcutRows = computed(() => + SEARCH_KEYBINDING_DEFINITIONS.map((definition) => ({ + ...definition, + label: t(definition.labelKey), + displayValue: searchShortcutDisplayMap.value[definition.id], + isCapturing: activeSearchShortcutActionId.value === definition.id, + hasError: searchShortcutErrorActionId.value === definition.id, + defaultDisplay: formatShortcutForDisplay(definition.defaultShortcut), + })) + ); + const shortcutInput = ref(null); const isSaving = ref(false); const isCapturing = ref(false); @@ -83,6 +108,50 @@ const pendingLanguage = ref(settings.value.language); const alertMessage = ref | null>(null); const shortcutRegistrationFailed = ref(false); + const activeSearchShortcutActionId = ref(null); + const searchShortcutCapturedValue = ref(null); + const hasCapturedSearchShortcut = ref(false); + const searchShortcutErrorActionId = ref(null); + const searchShortcutDisplayMap = ref>( + SEARCH_KEYBINDING_DEFINITIONS.reduce( + (accumulator, definition) => { + accumulator[definition.id] = formatShortcutForDisplay( + settings.value.searchKeybindings[definition.id] + ); + return accumulator; + }, + {} as Record + ) + ); + + function updateSearchShortcutDisplay(actionId: SearchKeybindingActionId, value: string) { + searchShortcutDisplayMap.value = { + ...searchShortcutDisplayMap.value, + [actionId]: value, + }; + } + + function syncSearchShortcutDisplays() { + const next = { ...searchShortcutDisplayMap.value }; + for (const definition of SEARCH_KEYBINDING_DEFINITIONS) { + if (activeSearchShortcutActionId.value === definition.id) { + continue; + } + next[definition.id] = formatShortcutForDisplay( + settings.value.searchKeybindings[definition.id] + ); + } + searchShortcutDisplayMap.value = next; + } + + function reportSearchShortcutError( + actionId: SearchKeybindingActionId, + messageKey: MessageKey, + params?: MessageParams + ) { + searchShortcutErrorActionId.value = actionId; + alertMessage.value?.error(t(messageKey, params), 3000); + } // 键名映射表 const keyNameMap: Record = { @@ -212,6 +281,172 @@ await saveNewShortcut(shortcut); }; + const captureSearchShortcut = (event: KeyboardEvent) => { + const actionId = activeSearchShortcutActionId.value; + if (!actionId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const captured = captureShortcutFromKeyboardEvent(event); + if (!captured) { + if (event.metaKey) { + alertMessage.value?.warning(t('settings.general.winKeyUnsupported'), 3000); + } + return; + } + + searchShortcutCapturedValue.value = captured.shortcut; + hasCapturedSearchShortcut.value = true; + searchShortcutErrorActionId.value = null; + updateSearchShortcutDisplay(actionId, captured.displayShortcut); + }; + + const saveSearchShortcut = async ( + actionId: SearchKeybindingActionId, + shortcut: string | null + ) => { + const normalizedShortcut = + shortcut === null ? null : normalizeLocalShortcutString(shortcut); + if (normalizedShortcut) { + if (!hasRequiredModifier(normalizedShortcut)) { + reportSearchShortcutError( + actionId, + 'settings.general.searchShortcuts.errors.modifierRequired' + ); + updateSearchShortcutDisplay( + actionId, + formatShortcutForDisplay(settings.value.searchKeybindings[actionId]) + ); + return; + } + + if (isReservedLocalShortcut(normalizedShortcut)) { + reportSearchShortcutError( + actionId, + 'settings.general.searchShortcuts.errors.reserved' + ); + updateSearchShortcutDisplay( + actionId, + formatShortcutForDisplay(settings.value.searchKeybindings[actionId]) + ); + return; + } + + const conflictActionId = findShortcutConflict( + normalizedShortcut, + SEARCH_KEYBINDING_DEFINITIONS.map((definition) => ({ + id: definition.id, + shortcut: settings.value.searchKeybindings[definition.id], + })), + actionId + ); + if (conflictActionId) { + reportSearchShortcutError( + actionId, + 'settings.general.searchShortcuts.errors.duplicate', + { + action: t(getSearchKeybindingDefinition(conflictActionId).labelKey), + } + ); + updateSearchShortcutDisplay( + actionId, + formatShortcutForDisplay(settings.value.searchKeybindings[actionId]) + ); + return; + } + + const comparableGlobalShortcut = normalizeLocalShortcutString( + settings.value.globalShortcut + ); + const comparableLocalShortcut = normalizeLocalShortcutString( + toCurrentPlatformShortcut(normalizedShortcut) + ); + if ( + comparableGlobalShortcut && + comparableLocalShortcut && + comparableGlobalShortcut === comparableLocalShortcut + ) { + reportSearchShortcutError( + actionId, + 'settings.general.searchShortcuts.errors.globalConflict' + ); + updateSearchShortcutDisplay( + actionId, + formatShortcutForDisplay(settings.value.searchKeybindings[actionId]) + ); + return; + } + } + + isSaving.value = true; + searchShortcutErrorActionId.value = null; + try { + await settingsStore.updateSearchKeybindings({ + ...settings.value.searchKeybindings, + [actionId]: normalizedShortcut, + }); + updateSearchShortcutDisplay(actionId, formatShortcutForDisplay(normalizedShortcut)); + alertMessage.value?.success(t('common.saved'), 2000); + } catch (error) { + console.error('Failed to save search shortcut:', error); + reportSearchShortcutError(actionId, 'settings.general.saveSettingsFailed'); + updateSearchShortcutDisplay( + actionId, + formatShortcutForDisplay(settings.value.searchKeybindings[actionId]) + ); + } finally { + isSaving.value = false; + } + }; + + const startSearchShortcutCapture = (actionId: SearchKeybindingActionId) => { + activeSearchShortcutActionId.value = actionId; + hasCapturedSearchShortcut.value = false; + searchShortcutCapturedValue.value = null; + searchShortcutErrorActionId.value = null; + updateSearchShortcutDisplay(actionId, shortcutCapturePrompt.value); + }; + + const stopSearchShortcutCaptureAndSave = async (actionId: SearchKeybindingActionId) => { + if (activeSearchShortcutActionId.value !== actionId) { + return; + } + + activeSearchShortcutActionId.value = null; + + if (!hasCapturedSearchShortcut.value || !searchShortcutCapturedValue.value) { + updateSearchShortcutDisplay( + actionId, + formatShortcutForDisplay(settings.value.searchKeybindings[actionId]) + ); + return; + } + + if ( + normalizeLocalShortcutString(searchShortcutCapturedValue.value) === + normalizeLocalShortcutString(settings.value.searchKeybindings[actionId]) + ) { + updateSearchShortcutDisplay( + actionId, + formatShortcutForDisplay(settings.value.searchKeybindings[actionId]) + ); + return; + } + + await saveSearchShortcut(actionId, searchShortcutCapturedValue.value); + }; + + const resetSearchShortcut = async (actionId: SearchKeybindingActionId) => { + await saveSearchShortcut(actionId, getSearchKeybindingDefinition(actionId).defaultShortcut); + }; + + const disableSearchShortcut = async (actionId: SearchKeybindingActionId) => { + await saveSearchShortcut(actionId, null); + }; + // 监听 isCapturing 状态,添加/移除全局键盘监听 watch(isCapturing, (newValue) => { if (newValue) { @@ -221,6 +456,13 @@ } }); + watch(activeSearchShortcutActionId, (actionId) => { + window.removeEventListener('keydown', captureSearchShortcut); + if (actionId) { + window.addEventListener('keydown', captureSearchShortcut); + } + }); + watch( () => settings.value.globalShortcut, (shortcut) => { @@ -230,6 +472,14 @@ } ); + watch( + () => settings.value.searchKeybindings, + () => { + syncSearchShortcutDisplays(); + }, + { deep: true, immediate: true } + ); + watch( () => settings.value.language, (language) => { @@ -415,6 +665,7 @@ // 组件卸载时清理事件监听 onUnmounted(() => { window.removeEventListener('keydown', captureShortcut); + window.removeEventListener('keydown', captureSearchShortcut); }); @@ -524,6 +775,75 @@ + +
+

+ {{ t('settings.general.searchShortcuts') }} +

+

+ {{ t('settings.general.searchShortcutsDescription') }} +

+
+ +
+
+
+
{{ row.label }}
+
+ {{ t('common.default') }} · {{ row.defaultDisplay }} +
+
+
+
+
+ +
+ + +
+
+
+
diff --git a/apps/desktop/tests/composables/SearchView/searchInteraction.test.ts b/apps/desktop/tests/composables/SearchView/searchInteraction.test.ts index 5f357024..461287c1 100644 --- a/apps/desktop/tests/composables/SearchView/searchInteraction.test.ts +++ b/apps/desktop/tests/composables/SearchView/searchInteraction.test.ts @@ -7,7 +7,6 @@ import { createPopupSurfaceCoordinator, createSearchEntryPolicy, createSearchInteractionContext, - createSearchKeyboardRouter, createSessionInputHistoryBrowseState, extractSessionInputHistoryEntries, navigateSessionInputHistory, @@ -66,11 +65,6 @@ function createControllerStub() { } satisfies SearchPageController; } -async function flushMicrotasks() { - await Promise.resolve(); - await Promise.resolve(); -} - describe('extractSessionInputHistoryEntries', () => { it('returns only user prompts that still have visible input history content', () => { const entries = extractSessionInputHistoryEntries([ @@ -329,150 +323,3 @@ describe('useSearchOverlayMachine', () => { mounted.unmount(); }); }); - -describe('createSearchKeyboardRouter', () => { - function createKeyboardRouter( - overrides: Partial[0]> = {} - ) { - const callbacks = { - onPromptApprovalAttention: vi.fn(), - onRejectApproval: vi.fn(), - onApproveApproval: vi.fn(), - onForwardToPopup: vi.fn(), - onSubmit: vi.fn(), - onOpenQuickSearch: vi.fn(), - onMoveQuickSearchSelection: vi.fn(), - onOpenHighlightedQuickSearchItem: vi.fn(), - onCloseQuickSearch: vi.fn(), - onQuickSearchPageUp: vi.fn(), - onQuickSearchPageDown: vi.fn(), - onQuickSearchContextMenu: vi.fn(), - onQuickSearchToggleView: vi.fn(), - onQuickSearchCollapse: vi.fn(), - onNavigateInputHistory: vi.fn(() => 'ignored' as const), - onHideAllPopups: vi.fn(), - onCancelRequest: vi.fn(), - onClearModelOverride: vi.fn(), - onHideWindow: vi.fn(), - onClearSession: vi.fn(), - onClearDraft: vi.fn(), - onClearAll: vi.fn(), - onPrimaryShortcut: vi.fn(), - }; - - return { - callbacks, - router: createSearchKeyboardRouter({ - getPendingApproval: () => null, - getActiveSurface: () => 'search-surface', - hasActivePopupWindowFocus: () => false, - getQueryText: () => '', - hasAttachments: () => false, - isQuickSearchOpen: () => false, - hasQuickSearchHighlight: () => false, - shouldTriggerQuickSearch: () => false, - isMultiLineCursor: () => false, - isCursorAtStart: () => true, - isCursorAtTextStart: () => true, - isCursorAtEnd: () => true, - hasModelOverride: () => false, - getSessionHistoryCount: () => 0, - isLoading: () => false, - ...callbacks, - ...overrides, - }), - }; - } - - it('rejects pending approval with escape before normal surface handling', () => { - const { router, callbacks } = createKeyboardRouter({ - getPendingApproval: () => ({ - callId: 'approval-1', - keyboardApproveAt: Date.now() + 1_000, - }), - }); - - const handled = router.route({ key: 'Escape' }); - - expect(handled).toBe(true); - expect(callbacks.onRejectApproval).toHaveBeenCalledWith('approval-1'); - }); - - it('submits when ArrowDown cannot navigate newer history and query text is present', () => { - const { router, callbacks } = createKeyboardRouter({ - getQueryText: () => 'touch', - shouldTriggerQuickSearch: () => true, - onNavigateInputHistory: vi.fn(() => 'ignored' as const), - }); - - const handled = router.route({ key: 'ArrowDown' }); - - expect(handled).toBe(true); - expect(callbacks.onSubmit).toHaveBeenCalledTimes(1); - expect(callbacks.onOpenQuickSearch).not.toHaveBeenCalled(); - }); - - it('opens quick search when ArrowDown cannot navigate newer history and query is empty with eligible trigger', () => { - const { router, callbacks } = createKeyboardRouter({ - getQueryText: () => '', - shouldTriggerQuickSearch: () => true, - hasAttachments: () => false, - onNavigateInputHistory: vi.fn(() => 'ignored' as const), - }); - - const handled = router.route({ key: 'ArrowDown' }); - - expect(handled).toBe(true); - expect(callbacks.onOpenQuickSearch).toHaveBeenCalledTimes(1); - }); - - it('does not navigate input history when multiline cursor is not at the text start', () => { - const { router, callbacks } = createKeyboardRouter({ - isMultiLineCursor: () => true, - isCursorAtTextStart: () => false, - }); - - const handled = router.route({ key: 'ArrowUp' }); - - expect(handled).toBe(false); - expect(callbacks.onNavigateInputHistory).not.toHaveBeenCalled(); - }); - - it('forwards model-dropdown arrow keys to the popup surface contract', () => { - const { router, callbacks } = createKeyboardRouter({ - getActiveSurface: () => 'model-dropdown-surface', - }); - - const handled = router.route({ key: 'ArrowDown' }); - - expect(handled).toBe(true); - expect(callbacks.onForwardToPopup).toHaveBeenCalledWith('ArrowDown'); - }); - - it('clears the draft before model/session/window dismissal on escape', () => { - const { router, callbacks } = createKeyboardRouter({ - getQueryText: () => 'hello', - }); - - const handled = router.route({ key: 'Escape' }); - - expect(handled).toBe(true); - expect(callbacks.onClearDraft).toHaveBeenCalledTimes(1); - expect(callbacks.onClearModelOverride).not.toHaveBeenCalled(); - expect(callbacks.onClearSession).not.toHaveBeenCalled(); - expect(callbacks.onHideWindow).not.toHaveBeenCalled(); - }); - - it('fires the stop-request primary shortcut only while the request is still loading', async () => { - const { router, callbacks } = createKeyboardRouter({ - getQueryText: () => '', - isLoading: () => true, - }); - - const handled = router.route({ key: '.', ctrlKey: true }); - await flushMicrotasks(); - - expect(handled).toBe(true); - expect(callbacks.onPrimaryShortcut).toHaveBeenCalledWith('.'); - }); -}); diff --git a/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts b/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts index e186d6ae..f140538d 100644 --- a/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts +++ b/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createDefaultSearchKeybindings } from '@/config/searchKeybindings'; import { createSearchKeyboardRouter } from '@/views/SearchView/composables/interaction/useSearchKeyboardRouter'; async function flushAsyncWork() { @@ -10,7 +11,7 @@ async function flushAsyncWork() { function createKeyboardRouter( overrides: Partial[0]> = {} ) { - const callbacks = { + const defaultCallbacks = { onPromptApprovalAttention: vi.fn(), onRejectApproval: vi.fn(), onApproveApproval: vi.fn(), @@ -20,6 +21,12 @@ function createKeyboardRouter( onMoveQuickSearchSelection: vi.fn(), onOpenHighlightedQuickSearchItem: vi.fn(), onCloseQuickSearch: vi.fn(), + onQuickSearchPageUp: vi.fn(), + onQuickSearchPageDown: vi.fn(), + onQuickSearchContextMenu: vi.fn(), + onQuickSearchToggleView: vi.fn(), + onQuickSearchCollapse: vi.fn(), + onNavigateInputHistory: vi.fn(() => 'ignored' as const), onHideAllPopups: vi.fn(), onCancelRequest: vi.fn(), onClearModelOverride: vi.fn(), @@ -27,27 +34,31 @@ function createKeyboardRouter( onClearSession: vi.fn(), onClearDraft: vi.fn(), onClearAll: vi.fn(), - onPrimaryShortcut: vi.fn(), + onSearchKeybindingAction: vi.fn(), + }; + const routerOptions = { + getSearchKeybindings: () => createDefaultSearchKeybindings(), + getPendingApproval: () => null, + getActiveSurface: () => 'search-surface' as const, + hasActivePopupWindowFocus: () => false, + getQueryText: () => '', + hasAttachments: () => false, + isQuickSearchOpen: () => false, + hasQuickSearchHighlight: () => false, + shouldTriggerQuickSearch: () => false, + isMultiLineCursor: () => false, + isCursorAtTextStart: () => true, + isCursorAtEnd: () => true, + hasModelOverride: () => false, + getSessionHistoryCount: () => 0, + isLoading: () => false, + ...defaultCallbacks, + ...overrides, }; return { - callbacks, - router: createSearchKeyboardRouter({ - getPendingApproval: () => null, - getActiveSurface: () => 'search-surface', - hasActivePopupWindowFocus: () => false, - getQueryText: () => '', - hasAttachments: () => false, - isQuickSearchOpen: () => false, - hasQuickSearchHighlight: () => false, - shouldTriggerQuickSearch: () => false, - isMultiLineCursor: () => false, - hasModelOverride: () => false, - getSessionHistoryCount: () => 0, - isLoading: () => false, - ...callbacks, - ...overrides, - }), + callbacks: routerOptions, + router: createSearchKeyboardRouter(routerOptions), }; } @@ -89,8 +100,23 @@ describe('createSearchKeyboardRouter', () => { expect(callbacks.onApproveApproval).toHaveBeenCalledWith('approval-2'); }); - it('runs primary shortcuts only when their guard conditions are satisfied', async () => { + it('routes configurable command shortcuts through the action callback', async () => { + const { router, callbacks } = createKeyboardRouter({ + getSearchKeybindings: () => ({ + ...createDefaultSearchKeybindings(), + 'search.history.open': 'Mod+Y', + }), + }); + + expect(router.route({ key: 'y', ctrlKey: true })).toBe(true); + await flushAsyncWork(); + + expect(callbacks.onSearchKeybindingAction).toHaveBeenCalledWith('search.history.open'); + }); + + it('only routes cancel and clear actions when their guard conditions are satisfied', async () => { const { router, callbacks } = createKeyboardRouter({ + getSearchKeybindings: () => createDefaultSearchKeybindings(), getQueryText: () => 'touchai', isLoading: () => true, }); @@ -100,8 +126,14 @@ describe('createSearchKeyboardRouter', () => { expect(router.route({ key: '.', ctrlKey: true, shiftKey: true })).toBe(false); await flushAsyncWork(); - expect(callbacks.onPrimaryShortcut).toHaveBeenNthCalledWith(1, 'backspace'); - expect(callbacks.onPrimaryShortcut).toHaveBeenNthCalledWith(2, '.'); + expect(callbacks.onSearchKeybindingAction).toHaveBeenNthCalledWith( + 1, + 'search.draft.clearAll' + ); + expect(callbacks.onSearchKeybindingAction).toHaveBeenNthCalledWith( + 2, + 'search.request.cancel' + ); }); it('applies the escape fallback order on the search surface', async () => { @@ -181,27 +213,30 @@ describe('createSearchKeyboardRouter', () => { expect(quickSearchRouter.callbacks.onSubmit).toHaveBeenCalledTimes(1); const openRouter = createKeyboardRouter({ - getQueryText: () => 'touch', + getQueryText: () => '', shouldTriggerQuickSearch: () => true, }); expect(openRouter.router.route({ key: 'ArrowDown' })).toBe(true); expect(openRouter.callbacks.onOpenQuickSearch).toHaveBeenCalledTimes(1); }); - it('submits ArrowUp on a single-line cursor and leaves multiline editing alone', async () => { + it('uses input-history navigation for ArrowUp and leaves multiline editing alone', async () => { const singleLineRouter = createKeyboardRouter({ getQueryText: () => 'submit me', isMultiLineCursor: () => false, + isCursorAtTextStart: () => true, + onNavigateInputHistory: vi.fn(() => 'navigated' as const), }); expect(singleLineRouter.router.route({ key: 'ArrowUp' })).toBe(true); - await flushAsyncWork(); - expect(singleLineRouter.callbacks.onSubmit).toHaveBeenCalledTimes(1); + expect(singleLineRouter.callbacks.onNavigateInputHistory).toHaveBeenCalledWith('older'); const multiLineRouter = createKeyboardRouter({ getQueryText: () => 'keep editing', isMultiLineCursor: () => true, + isCursorAtTextStart: () => false, }); expect(multiLineRouter.router.route({ key: 'ArrowUp' })).toBe(false); - expect(multiLineRouter.callbacks.onSubmit).not.toHaveBeenCalled(); + expect(multiLineRouter.callbacks.onNavigateInputHistory).not.toHaveBeenCalled(); + await flushAsyncWork(); }); }); diff --git a/apps/desktop/tests/stores/settings-keybindings.test.ts b/apps/desktop/tests/stores/settings-keybindings.test.ts new file mode 100644 index 00000000..a7ac425a --- /dev/null +++ b/apps/desktop/tests/stores/settings-keybindings.test.ts @@ -0,0 +1,125 @@ +import { AppEvent } from '@services/EventService'; +import { createPinia, setActivePinia } from 'pinia'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createDefaultSearchKeybindings } from '@/config/searchKeybindings'; + +const { eventHandlers, eventServiceMock, getSettingValueMock, setSettingMock, windowMock } = + vi.hoisted(() => ({ + eventHandlers: new Map void>(), + eventServiceMock: { + emit: vi.fn(), + on: vi.fn(async (event: string, handler: (payload: unknown) => void) => { + eventHandlers.set(event, handler); + return () => { + eventHandlers.delete(event); + }; + }), + }, + getSettingValueMock: vi.fn(), + setSettingMock: vi.fn(), + windowMock: { + label: 'settings', + }, + })); + +vi.mock('@database/queries', () => ({ + getSettingValue: getSettingValueMock, + setSetting: setSettingMock, +})); + +vi.mock('@services/EventService', async () => { + const actual = + await vi.importActual('@services/EventService'); + return { + ...actual, + eventService: eventServiceMock, + }; +}); + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => windowMock, +})); + +function mockSettings(values: Record) { + getSettingValueMock.mockImplementation(async ({ key }: { key: string }) => values[key] ?? null); + setSettingMock.mockImplementation(async ({ key, value }: { key: string; value: string }) => ({ + id: 1, + key, + value, + created_at: '2026-06-03 00:00:00', + updated_at: '2026-06-03 00:00:00', + })); +} + +describe('settings search keybindings state', () => { + beforeEach(() => { + vi.clearAllMocks(); + eventHandlers.clear(); + setActivePinia(createPinia()); + windowMock.label = 'settings'; + }); + + it('persists default search keybindings when the row is missing', async () => { + mockSettings({}); + + const { useSettingsStore } = await import('@/stores/settings'); + const store = useSettingsStore(); + + await store.initialize(); + + expect(store.settings.searchKeybindings).toEqual(createDefaultSearchKeybindings()); + expect(setSettingMock).toHaveBeenCalledWith({ + key: 'search_keybindings', + value: JSON.stringify(createDefaultSearchKeybindings()), + }); + }); + + it('loads persisted search keybindings and merges missing defaults', async () => { + mockSettings({ + search_keybindings: JSON.stringify({ + 'search.history.open': 'Mod+Y', + 'search.request.cancel': null, + }), + }); + + const { useSettingsStore } = await import('@/stores/settings'); + const store = useSettingsStore(); + + await store.initialize(); + + expect(store.settings.searchKeybindings['search.history.open']).toBe('Mod+Y'); + expect(store.settings.searchKeybindings['search.request.cancel']).toBeNull(); + expect(store.settings.searchKeybindings['search.input.focus']).toBe( + createDefaultSearchKeybindings()['search.input.focus'] + ); + }); + + it('updates search keybindings, persists them, and broadcasts the change', async () => { + mockSettings({}); + + const { useSettingsStore } = await import('@/stores/settings'); + const store = useSettingsStore(); + await store.initialize(); + + const nextKeybindings = { + ...createDefaultSearchKeybindings(), + 'search.input.focus': 'Mod+K', + 'search.request.cancel': null, + }; + + await store.updateSearchKeybindings(nextKeybindings); + + expect(store.settings.searchKeybindings).toEqual(nextKeybindings); + expect(setSettingMock).toHaveBeenLastCalledWith({ + key: 'search_keybindings', + value: JSON.stringify(nextKeybindings), + }); + expect(eventServiceMock.emit).toHaveBeenLastCalledWith(AppEvent.SETTINGS_GENERAL_UPDATED, { + sourceId: expect.any(String), + windowLabel: 'settings', + key: 'search_keybindings', + value: nextKeybindings, + }); + }); +}); diff --git a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts index 05b0bd40..b0ec16b0 100644 --- a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts @@ -8,6 +8,15 @@ const settingsStoreMock = vi.hoisted(() => ({ settings: { value: { globalShortcut: 'Alt+Space', + searchKeybindings: { + 'search.history.open': 'Mod+H', + 'search.input.focus': 'Mod+L', + 'search.session.new': 'Mod+N', + 'search.model.toggle': 'Mod+M', + 'search.window.pin': 'Mod+P', + 'search.request.cancel': 'Mod+.', + 'search.draft.clearAll': 'Mod+Backspace', + }, startOnBoot: false, startMinimized: true, language: 'zh-CN', @@ -21,6 +30,7 @@ const settingsStoreMock = vi.hoisted(() => ({ }, initialize: vi.fn().mockResolvedValue(undefined), updateGlobalShortcut: vi.fn().mockResolvedValue(undefined), + updateSearchKeybindings: vi.fn().mockResolvedValue(undefined), updateStartOnBoot: vi.fn().mockResolvedValue(undefined), updateStartMinimized: vi.fn().mockResolvedValue(undefined), updateOutputScrollBehavior: vi.fn().mockResolvedValue(undefined), @@ -162,6 +172,9 @@ describe('SettingsGeneralSection', () => { expect(wrapper.text()).toContain('唤起快捷键'); expect(wrapper.text()).toContain('Alt+Space'); expect(wrapper.text()).toContain('Ctrl+Space'); + expect(wrapper.text()).toContain('搜索页快捷键'); + expect(wrapper.text()).toContain('打开会话历史'); + expect(wrapper.text()).toContain('开始新会话'); expect(wrapper.text()).toContain('启动与窗口'); expect(wrapper.text()).toContain('开机自启动'); expect(wrapper.text()).toContain('启动时最小化'); @@ -187,7 +200,7 @@ describe('SettingsGeneralSection', () => { expect(controls.length).toBeGreaterThanOrEqual(3); const rowLabels = wrapper.findAll('[data-testid="settings-general-row-label"]'); - expect(rowLabels).toHaveLength(8); + expect(rowLabels.length).toBeGreaterThanOrEqual(13); }); it('shows the current version in the latest update details', async () => { From 70d89aaec1ee3f5ee3023dc60add245fff6eab54 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:59:30 +0800 Subject: [PATCH 02/55] feat: customize shortcut settings UI --- apps/desktop/src/components/appIconMap.ts | 2 + apps/desktop/src/config/searchKeybindings.ts | 9 + apps/desktop/src/i18n/messages.ts | 14 +- apps/desktop/src/utils/shortcuts.ts | 15 + .../composables/searchInteraction.ts | 10 +- .../SettingsView/components/General/index.vue | 386 +++++++++++++----- .../SettingsView/general-language.test.ts | 2 + .../SearchView/searchInteraction.test.ts | 68 ++- .../useSearchKeyboardRouter.test.ts | 27 ++ .../tests/stores/settings-keybindings.test.ts | 4 + .../settingsGeneralComponent.test.ts | 287 +++++++++++-- 11 files changed, 662 insertions(+), 162 deletions(-) diff --git a/apps/desktop/src/components/appIconMap.ts b/apps/desktop/src/components/appIconMap.ts index b91e1752..8421d3d4 100644 --- a/apps/desktop/src/components/appIconMap.ts +++ b/apps/desktop/src/components/appIconMap.ts @@ -39,6 +39,7 @@ import IconShow from '~icons/bx/show'; import IconStop from '~icons/bx/stop'; import IconTrash from '~icons/bx/trash'; import IconTrashAlt from '~icons/bx/trash-alt'; +import IconUndo from '~icons/bx/undo'; import IconWrench from '~icons/bx/wrench'; import IconX from '~icons/bx/x'; import IconXCircle from '~icons/bx/x-circle'; @@ -82,6 +83,7 @@ export const appIconMap = { stop: IconStop, tool: IconBriefcase, trash: IconTrash, + undo: IconUndo, bug: IconBug, wrench: IconWrench, x: IconX, diff --git a/apps/desktop/src/config/searchKeybindings.ts b/apps/desktop/src/config/searchKeybindings.ts index b54295c6..13fff367 100644 --- a/apps/desktop/src/config/searchKeybindings.ts +++ b/apps/desktop/src/config/searchKeybindings.ts @@ -7,6 +7,7 @@ export const SEARCH_KEYBINDING_ACTION_IDS = [ 'search.session.new', 'search.model.toggle', 'search.window.pin', + 'search.window.maximize', 'search.request.cancel', 'search.draft.clearAll', ] as const; @@ -18,6 +19,7 @@ export interface SearchKeybindingDefinition { labelKey: MessageKey; defaultShortcut: string | null; allowDisable: boolean; + allowModifierlessFunctionKey?: boolean; } export type SearchKeybindings = Record; @@ -53,6 +55,13 @@ export const SEARCH_KEYBINDING_DEFINITIONS: SearchKeybindingDefinition[] = [ defaultShortcut: 'Mod+P', allowDisable: true, }, + { + id: 'search.window.maximize', + labelKey: 'settings.general.searchActions.windowMaximize', + defaultShortcut: 'F11', + allowDisable: true, + allowModifierlessFunctionKey: true, + }, { id: 'search.request.cancel', labelKey: 'settings.general.searchActions.cancelRequest', diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index 5dd69641..b6e16954 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -75,7 +75,6 @@ const zhCNMessages = { 'settings.general.title': '常规设置', 'settings.general.description': '配置应用的基本行为和外观', 'settings.general.shortcuts': '快捷键', - 'settings.general.shortcutsDescription': '设置桌面唤起 TouchAI 的全局入口', 'settings.general.globalShortcut': '全局快捷键', 'settings.general.activationShortcut': '唤起快捷键', 'settings.general.shortcutPlaceholder': '点击输入框设置快捷键', @@ -87,14 +86,18 @@ const zhCNMessages = { '点击输入框后按下您想要设置的快捷键组合。支持的修饰键:Ctrl、Alt、Shift', 'settings.general.winKeyUnsupported': '不支持 Win 键组合,请使用 Ctrl、Alt、Shift', 'settings.general.shortcutSaved': '快捷键保存成功', - 'settings.general.searchShortcuts': '搜索页快捷键', + 'settings.general.globalShortcutGroup': '全局唤起', 'settings.general.searchShortcutsDescription': '自定义搜索窗口内的命令型快捷键,不会影响输入导航与全局唤起。', + 'settings.general.searchShortcutGroups.session': '会话', + 'settings.general.searchShortcutGroups.inputAndRequest': '输入与请求', + 'settings.general.searchShortcutGroups.window': '窗口', 'settings.general.searchActions.history': '打开会话历史', 'settings.general.searchActions.focusInput': '聚焦输入框', 'settings.general.searchActions.newSession': '开始新会话', 'settings.general.searchActions.modelToggle': '切换模型选择', 'settings.general.searchActions.windowPin': '切换窗口置顶', + 'settings.general.searchActions.windowMaximize': '切换窗口最大化', 'settings.general.searchActions.cancelRequest': '取消当前请求', 'settings.general.searchActions.clearAll': '清空草稿与上下文', 'settings.general.searchShortcuts.errors.modifierRequired': '快捷键至少需要一个修饰键', @@ -818,7 +821,6 @@ const enUSMessages: Record = { 'settings.general.title': 'General settings', 'settings.general.description': 'Configure basic app behavior and appearance', 'settings.general.shortcuts': 'Shortcuts', - 'settings.general.shortcutsDescription': 'Set the global entry point for opening TouchAI', 'settings.general.globalShortcut': 'Global shortcut', 'settings.general.activationShortcut': 'Activation shortcut', 'settings.general.shortcutPlaceholder': 'Click the field to set a shortcut', @@ -832,14 +834,18 @@ const enUSMessages: Record = { 'settings.general.winKeyUnsupported': 'Win key combinations are not supported. Use Ctrl, Alt, or Shift.', 'settings.general.shortcutSaved': 'Shortcut saved', - 'settings.general.searchShortcuts': 'Search shortcuts', + 'settings.general.globalShortcutGroup': 'Global activation', 'settings.general.searchShortcutsDescription': 'Customize command shortcuts inside the search window without changing typing, navigation, or the global activation shortcut.', + 'settings.general.searchShortcutGroups.session': 'Session', + 'settings.general.searchShortcutGroups.inputAndRequest': 'Input and request', + 'settings.general.searchShortcutGroups.window': 'Window', 'settings.general.searchActions.history': 'Open session history', 'settings.general.searchActions.focusInput': 'Focus input', 'settings.general.searchActions.newSession': 'Start new session', 'settings.general.searchActions.modelToggle': 'Toggle model picker', 'settings.general.searchActions.windowPin': 'Toggle window pin', + 'settings.general.searchActions.windowMaximize': 'Toggle window maximize', 'settings.general.searchActions.cancelRequest': 'Cancel current request', 'settings.general.searchActions.clearAll': 'Clear draft and context', 'settings.general.searchShortcuts.errors.modifierRequired': diff --git a/apps/desktop/src/utils/shortcuts.ts b/apps/desktop/src/utils/shortcuts.ts index 8dd73ae8..a01f86cc 100644 --- a/apps/desktop/src/utils/shortcuts.ts +++ b/apps/desktop/src/utils/shortcuts.ts @@ -282,6 +282,21 @@ export function hasRequiredModifier(shortcut: string | null | undefined): boolea return modifiers.length > 0; } +export function isModifierlessFunctionShortcut(shortcut: string | null | undefined): boolean { + const normalized = normalizeLocalShortcutString(shortcut); + if (!normalized) { + return false; + } + + const match = /^F(\d{1,2})$/.exec(normalized); + if (!match) { + return false; + } + + const functionKeyNumber = Number(match[1]); + return functionKeyNumber >= 1 && functionKeyNumber <= 12; +} + export function findShortcutConflict( shortcut: string | null | undefined, entries: Array<{ id: T; shortcut: string | null | undefined }>, diff --git a/apps/desktop/src/views/SearchView/composables/searchInteraction.ts b/apps/desktop/src/views/SearchView/composables/searchInteraction.ts index eb79ce15..97c19b31 100644 --- a/apps/desktop/src/views/SearchView/composables/searchInteraction.ts +++ b/apps/desktop/src/views/SearchView/composables/searchInteraction.ts @@ -801,6 +801,9 @@ export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { case 'search.window.pin': await toggleWindowPin(); return; + case 'search.window.maximize': + await toggleWindowMaximize(); + return; case 'search.request.cancel': cancelRequest(); return; @@ -840,13 +843,6 @@ export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { return; } - if (event.key === 'F11') { - event.preventDefault(); - event.stopPropagation(); - await toggleWindowMaximize(); - return; - } - const handledByRouter = keyboardRouter.route({ key: event.key, shiftKey: event.shiftKey, diff --git a/apps/desktop/src/views/SettingsView/components/General/index.vue b/apps/desktop/src/views/SettingsView/components/General/index.vue index 5ac0ec3e..ef0aa8c0 100644 --- a/apps/desktop/src/views/SettingsView/components/General/index.vue +++ b/apps/desktop/src/views/SettingsView/components/General/index.vue @@ -1,4 +1,4 @@ - + + From c25541cc438ba908d8bb57738e064cc8fc655c90 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:18:55 +0800 Subject: [PATCH 30/55] fix(i18n): update Chinese translations for shortcut settings --- apps/desktop/src/i18n/messages.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index ae7ccbe0..c2ff8a0c 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -77,7 +77,7 @@ const zhCNMessages = { 'settings.general.shortcuts': '快捷键', 'settings.general.globalShortcut': '全局快捷键', 'settings.general.activationShortcut': '唤起快捷键', - 'settings.general.activationShortcutDescription': '设置呼出 TouchAI 的全局快捷键。', + 'settings.general.activationShortcutDescription': '设置呼出 TouchAI 的全局快捷键', 'settings.general.shortcutPlaceholder': '点击输入框设置快捷键', 'settings.general.shortcutCapturePrompt': '请按下快捷键...', 'settings.general.shortcutRegistrationFailed': '快捷键注册失败,可能已被其他应用占用', @@ -109,19 +109,21 @@ const zhCNMessages = { 'settings.general.fixedSearchActions.previousInputHistory': '上一条会话历史', 'settings.general.fixedSearchActions.nextInputHistory': '下一条会话历史', 'settings.general.searchActions.clearAll': '清空草稿与上下文', - 'settings.general.searchActionDescriptions.history': '快速打开会话历史列表。', - 'settings.general.searchActionDescriptions.focusInput': '将焦点移动到输入框。', - 'settings.general.searchActionDescriptions.newSession': '立即开始一段新会话。', - 'settings.general.searchActionDescriptions.reopenLastClosedSession': '重新打开最近关闭的会话。', - 'settings.general.searchActionDescriptions.modelToggle': '展开或收起模型选择。', - 'settings.general.searchActionDescriptions.windowPin': '切换搜索窗口置顶状态。', - 'settings.general.searchActionDescriptions.windowMaximize': '切换搜索窗口最大化。', - 'settings.general.searchActionDescriptions.openSettings': '快速打开设置窗口。', - 'settings.general.searchActionDescriptions.cancelRequest': '停止当前生成或工具执行。', - 'settings.general.searchActionDescriptions.submitRequest': '发送当前输入内容。', - 'settings.general.searchActionDescriptions.newLine': '在输入框中插入换行。', - 'settings.general.searchActionDescriptions.previousInputHistory': '查看上一条输入记录。', - 'settings.general.searchActionDescriptions.nextInputHistory': '查看下一条输入记录。', + 'settings.general.searchActionDescriptions.history': '快速打开或收起会话历史列表', + 'settings.general.searchActionDescriptions.focusInput': '将焦点移动到输入框', + 'settings.general.searchActionDescriptions.newSession': '立即开始一段新会话', + 'settings.general.searchActionDescriptions.reopenLastClosedSession': + '重新打开最近关闭的一条会话', + 'settings.general.searchActionDescriptions.modelToggle': '快速打开或收起模型列表', + 'settings.general.searchActionDescriptions.windowPin': '切换搜索窗口的置顶状态', + 'settings.general.searchActionDescriptions.windowMaximize': '切换搜索窗口最大化', + 'settings.general.searchActionDescriptions.openSettings': '快速打开设置窗口', + 'settings.general.searchActionDescriptions.cancelRequest': + '停止会话 / 清除输入内容 / 恢复默认模型', + 'settings.general.searchActionDescriptions.submitRequest': '发送当前输入内容', + 'settings.general.searchActionDescriptions.newLine': '在输入框中输入文本时换行', + 'settings.general.searchActionDescriptions.previousInputHistory': '查看输入框输入的上一条记录', + 'settings.general.searchActionDescriptions.nextInputHistory': '查看输入框输入的下一条记录', 'settings.general.searchShortcuts.errors.modifierRequired': '快捷键需要包含 Ctrl、Alt 或 Cmd,或使用允许的功能键', 'settings.general.searchShortcuts.errors.reserved': From 96dceb8e023926449e6b6968161fda8f0860a868 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:47:57 +0800 Subject: [PATCH 31/55] fix(desktop): prevent search shortcut input selection --- .../General/SearchShortcutSettings.vue | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue index def6c8b1..f6418462 100644 --- a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue +++ b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue @@ -374,6 +374,21 @@ updateSearchShortcutDisplay(actionId, shortcutCapturePrompt.value); } + function focusSearchShortcutInput(event: MouseEvent, row: SettingsSearchShortcutGroupRow) { + event.preventDefault(); + if (row.kind !== 'configurable') { + return; + } + + (event.currentTarget as HTMLInputElement).focus(); + } + + function clearSearchShortcutSelection(event: Event) { + const input = event.currentTarget as HTMLInputElement; + const cursorPosition = input.value.length; + input.setSelectionRange(cursorPosition, cursorPosition); + } + async function confirmCapturedSearchShortcut( actionId: SearchKeybindingActionId, shortcut: string @@ -538,7 +553,7 @@ type="text" readonly :class="[ - 'w-full rounded-[10px] border px-9 py-2 text-center text-[12px] shadow-none [box-shadow:none] transition-colors select-none focus:shadow-none focus:[box-shadow:none] focus:outline-none', + 'shortcut-capture-input w-full rounded-[10px] border px-9 py-2 text-center text-[12px] shadow-none [box-shadow:none] transition-colors select-none focus:shadow-none focus:[box-shadow:none] focus:outline-none', row.hasError ? 'border-red-300 bg-red-50 text-red-600' : row.isCapturing @@ -558,7 +573,9 @@ : undefined " :tabindex="row.kind === 'fixed' ? -1 : 0" - @mousedown="row.kind === 'fixed' && $event.preventDefault()" + @mousedown="focusSearchShortcutInput($event, row)" + @select="clearSearchShortcutSelection" + @dragstart.prevent @focus="startSearchShortcutCapture(row)" @blur="stopSearchShortcutCaptureAndSave(row)" /> @@ -586,3 +603,21 @@ + + From 5a5e856430fb3119012b876aa7917233d8cfbd17 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:49:06 +0800 Subject: [PATCH 32/55] fix(desktop): clarify global shortcut conflicts --- .../SettingsView/components/General/index.vue | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/views/SettingsView/components/General/index.vue b/apps/desktop/src/views/SettingsView/components/General/index.vue index 0405255a..4deddf2d 100644 --- a/apps/desktop/src/views/SettingsView/components/General/index.vue +++ b/apps/desktop/src/views/SettingsView/components/General/index.vue @@ -13,6 +13,10 @@ import { storeToRefs } from 'pinia'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; + import { + getSearchKeybindingDefinition, + type SearchKeybindingActionId, + } from '@/config/searchKeybindings'; import { resolveSearchWindowDefaultSize, type SearchWindowSizePreset, @@ -107,16 +111,22 @@ })) ); - function findGlobalShortcutSearchConflict(shortcut: string): boolean { + function findGlobalShortcutSearchConflict(shortcut: string): SearchKeybindingActionId | null { const normalizedShortcut = normalizeLocalShortcutString(shortcut); if (!normalizedShortcut) { - return false; + return null; } - return Object.values(settings.value.searchKeybindings).some((searchShortcut) => { + for (const [actionId, searchShortcut] of Object.entries( + settings.value.searchKeybindings + ) as Array<[SearchKeybindingActionId, string | null]>) { const normalizedSearchShortcut = normalizeLocalShortcutString(searchShortcut); - return normalizedSearchShortcut === normalizedShortcut; - }); + if (normalizedSearchShortcut === normalizedShortcut) { + return actionId; + } + } + + return null; } const captureShortcut = (event: KeyboardEvent) => { @@ -264,11 +274,14 @@ }; const saveNewShortcut = async (newShortcut: string) => { - if (findGlobalShortcutSearchConflict(newShortcut)) { + const conflictActionId = findGlobalShortcutSearchConflict(newShortcut); + if (conflictActionId) { shortcutRegistrationFailed.value = false; displayShortcut.value = settings.value.globalShortcut; alertMessage.value?.error( - t('settings.general.searchShortcuts.errors.globalConflict'), + t('settings.general.searchShortcuts.errors.duplicate', { + action: t(getSearchKeybindingDefinition(conflictActionId).labelKey), + }), 3000 ); return; From 86963bdac03084066a703ba07e6c315cfd4029bc Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:25:06 +0800 Subject: [PATCH 33/55] test(desktop): align general shortcut description --- .../tests/views/SettingsView/settingsGeneralComponent.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts index 99577991..861a0d99 100644 --- a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts @@ -201,7 +201,7 @@ describe('SettingsGeneralSection', () => { .element as HTMLInputElement ).value ).toBe('Alt+Space'); - expect(wrapper.text()).toContain('设置呼出 TouchAI 的全局快捷键。'); + expect(wrapper.text()).toContain('设置呼出 TouchAI 的全局快捷键'); expect(wrapper.text()).not.toContain('Ctrl+Space'); expect(wrapper.find('[data-testid="settings-shortcut-suggestions"]').exists()).toBe(false); expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( From ad4c365db80a18cbae945b9cf140cf4af13fc09e Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:34:50 +0800 Subject: [PATCH 34/55] test(desktop): align search shortcut descriptions --- .../views/SettingsView/settingsGeneralComponent.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts index 861a0d99..4b1b87fd 100644 --- a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts @@ -212,12 +212,12 @@ describe('SettingsGeneralSection', () => { expect(wrapper.text()).toContain('输入与请求'); expect(wrapper.text()).toContain('窗口'); expect(wrapper.text()).toContain('打开会话历史'); - expect(wrapper.text()).toContain('快速打开会话历史列表。'); + expect(wrapper.text()).toContain('快速打开或收起会话历史列表'); expect(wrapper.text()).toContain('开始新会话'); expect(wrapper.text()).toContain('切换窗口最大化'); - expect(wrapper.text()).toContain('切换搜索窗口最大化。'); + expect(wrapper.text()).toContain('切换搜索窗口最大化'); expect(wrapper.text()).toContain('打开设置'); - expect(wrapper.text()).toContain('快速打开设置窗口。'); + expect(wrapper.text()).toContain('快速打开设置窗口'); expect( ( wrapper.get('[data-testid="settings-search-shortcut-input-search.window.maximize"]') From a4a2ff5d9cdad5a42fcaeac1616bdf67b816a782 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:45:57 +0800 Subject: [PATCH 35/55] fix(desktop): handle macOS global shortcut conflicts --- .../src-tauri/src/core/system/shortcut.rs | 7 ++ apps/desktop/src/i18n/messages.ts | 4 + apps/desktop/src/utils/shortcuts.ts | 16 ++++ .../SettingsView/components/General/index.vue | 53 ++++++++---- apps/desktop/tests/setup/vitest.ts | 17 ++-- apps/desktop/tests/utils/shortcuts.test.ts | 19 ++++- .../settingsGeneralComponent.test.ts | 82 +++++++++++++++++++ 7 files changed, 168 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src-tauri/src/core/system/shortcut.rs b/apps/desktop/src-tauri/src/core/system/shortcut.rs index 67a719ec..0bf793fe 100644 --- a/apps/desktop/src-tauri/src/core/system/shortcut.rs +++ b/apps/desktop/src-tauri/src/core/system/shortcut.rs @@ -221,6 +221,13 @@ mod tests { assert_eq!(shortcut.key, Code::Space); } + #[test] + fn parse_shortcut_accepts_option_alias_for_alt() { + let shortcut = parse_shortcut("Option+Shift+Space").expect("option+shift+space parses"); + assert_eq!(shortcut.mods, Modifiers::ALT | Modifiers::SHIFT); + assert_eq!(shortcut.key, Code::Space); + } + #[test] fn parse_shortcut_accepts_super_aliases_for_cmd_or_win() { // global-hotkey 把 Cmd(macOS)和 Win/Super(Linux)都映射到 SUPER; diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index c2ff8a0c..61118f9a 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -86,6 +86,8 @@ const zhCNMessages = { 'settings.general.shortcutHelp': '点击输入框后按下您想要设置的快捷键组合。支持的修饰键:Ctrl、Alt、Shift', 'settings.general.winKeyUnsupported': '不支持 Win/Super 键组合,请使用 Ctrl、Alt、Shift', + 'settings.general.globalShortcutReservedOnMac': + '该快捷键容易与 macOS 系统功能或输入法冲突,请改用 Option+Space 或 Option+Shift+Space', 'settings.general.shortcutSaved': '快捷键保存成功', 'settings.general.noShortcut': '无', 'settings.general.globalShortcutGroup': '全局唤起', @@ -1048,6 +1050,8 @@ const enUSMessages: Record = { 'Click the field and press the shortcut you want. Supported modifiers: Ctrl, Alt, Shift', 'settings.general.winKeyUnsupported': 'Win/Super key combinations are not supported. Use Ctrl, Alt, or Shift.', + 'settings.general.globalShortcutReservedOnMac': + 'This shortcut can conflict with macOS system features or input methods. Use Option+Space or Option+Shift+Space.', 'settings.general.shortcutSaved': 'Shortcut saved', 'settings.general.noShortcut': 'None', 'settings.general.globalShortcutGroup': 'Global activation', diff --git a/apps/desktop/src/utils/shortcuts.ts b/apps/desktop/src/utils/shortcuts.ts index b1332423..17820a00 100644 --- a/apps/desktop/src/utils/shortcuts.ts +++ b/apps/desktop/src/utils/shortcuts.ts @@ -89,6 +89,10 @@ function getPrimaryModifierLabel(): 'Cmd' | 'Ctrl' { return isMacPlatform() ? 'Cmd' : 'Ctrl'; } +function getAltModifierLabel(): 'Option' | 'Alt' { + return isMacPlatform() ? 'Option' : 'Alt'; +} + function usesPrimaryModifier(input: ShortcutMatchInput): boolean { return isMacPlatform() ? Boolean(input.metaKey) : Boolean(input.ctrlKey); } @@ -203,6 +207,9 @@ export function formatShortcutForDisplay(shortcut: string | null | undefined): s if (modifier === 'Mod') { return getPrimaryModifierLabel(); } + if (modifier === 'Alt') { + return getAltModifierLabel(); + } return modifier; }); @@ -322,6 +329,15 @@ export function isReservedLocalShortcutKey( return normalizedKey ? RESERVED_LOCAL_SHORTCUT_KEYS.has(normalizedKey) : false; } +export function isReservedGlobalShortcut(shortcut: string | null | undefined): boolean { + if (!isMacPlatform()) { + return false; + } + + const normalized = normalizeLocalShortcutString(shortcut); + return normalized === 'Mod+Space' || normalized === 'Ctrl+Space'; +} + export function hasRequiredModifier(shortcut: string | null | undefined): boolean { const normalized = normalizeLocalShortcutString(shortcut); if (!normalized) { diff --git a/apps/desktop/src/views/SettingsView/components/General/index.vue b/apps/desktop/src/views/SettingsView/components/General/index.vue index 4deddf2d..9855d878 100644 --- a/apps/desktop/src/views/SettingsView/components/General/index.vue +++ b/apps/desktop/src/views/SettingsView/components/General/index.vue @@ -32,8 +32,10 @@ import { type OutputScrollBehavior, useSettingsStore } from '@/stores/settings'; import { captureShortcutFromKeyboardEvent, + formatShortcutForDisplay, hasCommandModifier, isMacPlatform, + isReservedGlobalShortcut, isReservedLocalShortcutKey, normalizeLocalShortcutString, } from '@/utils/shortcuts'; @@ -101,15 +103,20 @@ const alertMessage = ref | null>(null); const shortcutRegistrationFailed = ref(false); const showGlobalShortcutPresetMenu = ref(false); - // Mac 上 Cmd+Space 被 Spotlight 占用,因此预设使用 Option+Space(Alt+Space)和 Ctrl+Space; - // Windows/Linux 上 Cmd 由 Win/Super 键代表,前端统一拒绝其作为快捷键,预设保持 Alt/Ctrl 组合。 - const globalShortcutPresetShortcuts = ['Alt+Space', 'Ctrl+Space'] as const; - const globalShortcutPresetOptions = computed(() => - globalShortcutPresetShortcuts.map((shortcut) => ({ + const globalShortcutPresetOptions = computed(() => { + const shortcuts = isMacPlatform() + ? ['Option+Space', 'Option+Shift+Space'] + : ['Alt+Space', 'Ctrl+Space']; + + return shortcuts.map((shortcut) => ({ label: shortcut, value: shortcut, - })) - ); + })); + }); + + function formatGlobalShortcutForSettings(shortcut: string | null | undefined): string { + return formatShortcutForDisplay(shortcut); + } function findGlobalShortcutSearchConflict(shortcut: string): SearchKeybindingActionId | null { const normalizedShortcut = normalizeLocalShortcutString(shortcut); @@ -196,10 +203,10 @@ return; } - displayShortcut.value = shortcut; + displayShortcut.value = formatGlobalShortcutForSettings(shortcut); hasCapturedShortcut.value = true; showGlobalShortcutPresetMenu.value = false; - void confirmCapturedShortcut(shortcut); + void confirmCapturedShortcut(displayShortcut.value); }; let unlistenSystemKey: UnlistenFn | null = null; @@ -230,8 +237,11 @@ isCapturing.value = false; showGlobalShortcutPresetMenu.value = false; - if (shortcut === settings.value.globalShortcut) { - displayShortcut.value = settings.value.globalShortcut; + if ( + normalizeLocalShortcutString(shortcut) === + normalizeLocalShortcutString(settings.value.globalShortcut) + ) { + displayShortcut.value = formatGlobalShortcutForSettings(settings.value.globalShortcut); return; } @@ -247,7 +257,7 @@ showGlobalShortcutPresetMenu.value = false; const completion = resolveShortcutCaptureCompletion({ - currentShortcut: settings.value.globalShortcut, + currentShortcut: formatGlobalShortcutForSettings(settings.value.globalShortcut), displayShortcut: displayShortcut.value, hasCapturedShortcut: hasCapturedShortcut.value, }); @@ -274,10 +284,17 @@ }; const saveNewShortcut = async (newShortcut: string) => { + if (isReservedGlobalShortcut(newShortcut)) { + shortcutRegistrationFailed.value = false; + displayShortcut.value = formatGlobalShortcutForSettings(settings.value.globalShortcut); + alertMessage.value?.warning(t('settings.general.globalShortcutReservedOnMac'), 4000); + return; + } + const conflictActionId = findGlobalShortcutSearchConflict(newShortcut); if (conflictActionId) { shortcutRegistrationFailed.value = false; - displayShortcut.value = settings.value.globalShortcut; + displayShortcut.value = formatGlobalShortcutForSettings(settings.value.globalShortcut); alertMessage.value?.error( t('settings.general.searchShortcuts.errors.duplicate', { action: t(getSearchKeybindingDefinition(conflictActionId).labelKey), @@ -294,18 +311,18 @@ const registered = await registerShortcut(newShortcut); if (!registered) { shortcutRegistrationFailed.value = true; - displayShortcut.value = newShortcut; + displayShortcut.value = formatGlobalShortcutForSettings(newShortcut); return; } await saveShortcutToDatabase(newShortcut); settings.value.globalShortcut = newShortcut; - displayShortcut.value = newShortcut; + displayShortcut.value = formatGlobalShortcutForSettings(newShortcut); alertMessage.value?.success(t('settings.general.shortcutSaved'), 3000); } catch (error) { console.error('Failed to save shortcut:', error); alertMessage.value?.error(t('settings.general.saveShortcutFailed'), 3000); - displayShortcut.value = settings.value.globalShortcut; + displayShortcut.value = formatGlobalShortcutForSettings(settings.value.globalShortcut); shortcutRegistrationFailed.value = false; } finally { isSaving.value = false; @@ -337,7 +354,7 @@ () => settings.value.globalShortcut, (shortcut) => { if (!isCapturing.value && !shortcutRegistrationFailed.value) { - displayShortcut.value = shortcut; + displayShortcut.value = formatGlobalShortcutForSettings(shortcut); } } ); @@ -353,7 +370,7 @@ const loadSettings = async () => { try { await settingsStore.initialize(); - displayShortcut.value = settings.value.globalShortcut; + displayShortcut.value = formatGlobalShortcutForSettings(settings.value.globalShortcut); const [failed, error] = await native.shortcut.getShortcutStatus(); if (failed) { diff --git a/apps/desktop/tests/setup/vitest.ts b/apps/desktop/tests/setup/vitest.ts index f3fd7f55..f0cf7f78 100644 --- a/apps/desktop/tests/setup/vitest.ts +++ b/apps/desktop/tests/setup/vitest.ts @@ -1,21 +1,18 @@ -import { - ReadableStream as NodeReadableStream, - TransformStream as NodeTransformStream, - WritableStream as NodeWritableStream, -} from 'node:stream/web'; - import { afterEach, beforeEach, vi } from 'vitest'; import { installTauriMocks, resetTauriMocks } from '../utils/tauri'; const webStreamGlobals = { - ReadableStream: NodeReadableStream, - TransformStream: NodeTransformStream, - WritableStream: NodeWritableStream, + ReadableStream: globalThis.ReadableStream, + TransformStream: globalThis.TransformStream, + WritableStream: globalThis.WritableStream, }; for (const [name, value] of Object.entries(webStreamGlobals)) { - if (typeof (globalThis as Record)[name] === 'undefined') { + if ( + typeof value !== 'undefined' && + typeof (globalThis as Record)[name] === 'undefined' + ) { Object.defineProperty(globalThis, name, { configurable: true, writable: true, diff --git a/apps/desktop/tests/utils/shortcuts.test.ts b/apps/desktop/tests/utils/shortcuts.test.ts index 58210d5b..c17a6d3c 100644 --- a/apps/desktop/tests/utils/shortcuts.test.ts +++ b/apps/desktop/tests/utils/shortcuts.test.ts @@ -7,6 +7,7 @@ import { hasCommandModifier, hasRequiredModifier, isModifierlessFunctionShortcut, + isReservedGlobalShortcut, isReservedLocalShortcut, isReservedLocalShortcutKey, matchShortcut, @@ -56,7 +57,8 @@ describe('shortcut utilities', () => { setPlatform('MacIntel'); - expect(formatShortcutForDisplay('Mod+Ctrl+Alt+Shift+H')).toBe('Cmd+Ctrl+Alt+Shift+H'); + expect(formatShortcutForDisplay('Mod+Ctrl+Alt+Shift+H')).toBe('Cmd+Ctrl+Option+Shift+H'); + expect(formatShortcutForDisplay('Alt+Space')).toBe('Option+Space'); expect(toCurrentPlatformShortcut('Mod+H')).toBe('Cmd+H'); }); @@ -127,7 +129,7 @@ describe('shortcut utilities', () => { ) ).toEqual({ shortcut: 'Mod+Ctrl+Alt+Shift+K', - displayShortcut: 'Cmd+Ctrl+Alt+Shift+K', + displayShortcut: 'Cmd+Ctrl+Option+Shift+K', }); }); @@ -171,4 +173,17 @@ describe('shortcut utilities', () => { expect(findShortcutConflict('Mod+P', entries)).toBeNull(); expect(findShortcutConflict('', entries)).toBeNull(); }); + + it('classifies macOS system global shortcut conflicts', () => { + expect(isReservedGlobalShortcut('Mod+Space')).toBe(false); + expect(isReservedGlobalShortcut('Ctrl+Space')).toBe(false); + + setPlatform('MacIntel'); + + expect(isReservedGlobalShortcut('Cmd+Space')).toBe(true); + expect(isReservedGlobalShortcut('Mod+Space')).toBe(true); + expect(isReservedGlobalShortcut('Ctrl+Space')).toBe(true); + expect(isReservedGlobalShortcut('Option+Space')).toBe(false); + expect(isReservedGlobalShortcut('Option+Shift+Space')).toBe(false); + }); }); diff --git a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts index 4b1b87fd..13cb82e1 100644 --- a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts @@ -6,6 +6,15 @@ import { createDefaultSearchKeybindings } from '@/config/searchKeybindings'; import { setLocale } from '@/i18n'; import GeneralSection from '@/views/SettingsView/components/General/index.vue'; +const originalPlatform = navigator.platform; + +function setPlatform(platform: string) { + Object.defineProperty(window.navigator, 'platform', { + configurable: true, + value: platform, + }); +} + const settingsStoreMock = vi.hoisted(() => { const createGeneralSettingsMock = (searchKeybindings: Record) => ({ globalShortcut: 'Alt+Space', @@ -178,6 +187,7 @@ vi.mock('@services/AppUpdateService', () => ({ describe('SettingsGeneralSection', () => { beforeEach(() => { vi.clearAllMocks(); + setPlatform('Win32'); setLocale('zh-CN'); appUpdateServiceMock.state = appUpdateServiceMock.createState(); nativeMock.shortcut.getShortcutStatus.mockResolvedValue([false, null]); @@ -187,6 +197,10 @@ describe('SettingsGeneralSection', () => { ); }); + afterEach(() => { + setPlatform(originalPlatform); + }); + it('renders the general settings groups and row controls', async () => { const wrapper = mount(GeneralSection); @@ -302,6 +316,74 @@ describe('SettingsGeneralSection', () => { ); }); + it('uses macOS shortcut labels and omits input-method-conflicting presets', async () => { + setPlatform('MacIntel'); + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); + expect((input.element as HTMLInputElement).value).toBe('Option+Space'); + + await input.trigger('focus'); + await flushPromises(); + + expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( + true + ); + expect( + wrapper.get('[data-testid="settings-global-shortcut-preset-Option+Space"]').text() + ).toBe('Option+Space'); + expect( + wrapper.get('[data-testid="settings-global-shortcut-preset-Option+Shift+Space"]').text() + ).toBe('Option+Shift+Space'); + expect( + wrapper.find('[data-testid="settings-global-shortcut-preset-Ctrl+Space"]').exists() + ).toBe(false); + + await wrapper + .get('[data-testid="settings-global-shortcut-preset-Option+Shift+Space"]') + .trigger('click'); + await flushPromises(); + + expect(nativeMock.shortcut.registerGlobalShortcut).toHaveBeenCalledWith( + 'Option+Shift+Space' + ); + expect(settingsStoreMock.updateGlobalShortcut).toHaveBeenCalledWith('Option+Shift+Space'); + expect((input.element as HTMLInputElement).value).toBe('Option+Shift+Space'); + }); + + it('blocks macOS system-reserved global shortcuts before registration', async () => { + setPlatform('MacIntel'); + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); + await input.trigger('focus'); + await flushPromises(); + + window.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', metaKey: true }) + ); + await flushPromises(); + + expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); + expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); + expect((input.element as HTMLInputElement).value).toBe('Option+Space'); + + await input.trigger('focus'); + await flushPromises(); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', ctrlKey: true }) + ); + await flushPromises(); + + expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); + expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); + expect((input.element as HTMLInputElement).value).toBe('Option+Space'); + }); + it('saves the global shortcut immediately after a shortcut is pressed', async () => { settingsStoreMock.settings.value.searchKeybindings['search.session.new'] = 'Mod+Shift+N'; const wrapper = mount(GeneralSection); From a028ebfd27882a8698aff6b0e0644651759e9c31 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:38:06 +0800 Subject: [PATCH 36/55] fix(desktop): respect configurable search shortcuts --- .../src/core/window/webview_defaults.rs | 81 ------------------- apps/desktop/src/config/searchKeybindings.ts | 9 +-- apps/desktop/src/i18n/messages.ts | 5 +- apps/desktop/src/i18n/textMap.ts | 3 +- .../src/services/EventService/types.ts | 7 -- .../SearchView/composables/useSearchPage.ts | 17 ---- apps/desktop/src/views/SearchView/index.vue | 5 -- .../useSearchKeyboardRouter.test.ts | 32 ++++++++ 8 files changed, 41 insertions(+), 118 deletions(-) diff --git a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs index 67625110..9eaacf0b 100644 --- a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs +++ b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs @@ -251,84 +251,6 @@ fn register_system_menu_accelerator_handler( Ok(()) } -#[cfg(target_os = "windows")] -/// 判断是否命中了需要从宿主层兜底转发的搜索 surface 快捷键。 -fn is_search_surface_accelerator_command( - key_event_kind: i32, - virtual_key: u32, - is_control_down: bool, -) -> bool { - let is_key_down = key_event_kind == COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN.0 - || key_event_kind == COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN.0; - is_key_down && is_control_down && virtual_key == u32::from(b'M') -} - -#[cfg(target_os = "windows")] -/// 在主搜索窗口注册 WebView2 accelerator 兜底,避免 DOM 未接管焦点时首个 Ctrl+M 丢失。 -fn register_search_surface_accelerator_bridge( - window: &WebviewWindow, - controller: &ICoreWebView2Controller, -) -> Result<(), String> { - if window.label() != "main" { - return Ok(()); - } - - let app_handle = window.app_handle().clone(); - let mut token = 0i64; - let handler = AcceleratorKeyPressedEventHandler::create(Box::new( - move |_controller: Option, - args: Option| { - let Some(args) = args else { - return Ok(()); - }; - - unsafe { - let mut key_event_kind = COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN; - args.KeyEventKind(&mut key_event_kind)?; - - let mut virtual_key = 0u32; - args.VirtualKey(&mut virtual_key)?; - - let mut physical_key_status = Default::default(); - args.PhysicalKeyStatus(&mut physical_key_status)?; - - let is_control_down = (GetKeyState(i32::from(VK_CONTROL.0)) as u16 & 0x8000) != 0; - if !is_search_surface_accelerator_command( - key_event_kind.0, - virtual_key, - is_control_down, - ) { - return Ok(()); - } - - if let Ok(args2) = - Interface::cast::(&args) - { - let _ = args2.SetIsBrowserAcceleratorKeyEnabled(false); - } - let _ = args.SetHandled(true); - let _ = app_handle.emit( - "search-surface-command", - serde_json::json!({ - "command": "toggle-model-dropdown", - "source": "webview2-accelerator" - }), - ); - } - - Ok(()) - }, - )); - - unsafe { - controller - .add_AcceleratorKeyPressed(&handler, &mut token) - .map_err(|error| format!("Failed to add WebView2 accelerator handler: {}", error))?; - } - - Ok(()) -} - #[cfg(target_os = "windows")] /// 判断是否命中了需要打开 DevTools 的快捷键。 fn is_devtools_accelerator_command( @@ -436,9 +358,6 @@ pub(crate) fn apply_webview_runtime_defaults( let controller = webview.controller(); let result = disable_browser_accelerator_keys_with_controller(&controller) .and_then(|_| register_system_menu_accelerator_handler(&window_clone, &controller)) - .and_then(|_| { - register_search_surface_accelerator_bridge(&window_clone, &controller) - }) .and_then(|_| register_devtools_accelerator_handler(&controller)); if let Ok(hwnd) = top_level_hwnd(&window_clone) { install_system_menu_interceptor_on_children(hwnd); diff --git a/apps/desktop/src/config/searchKeybindings.ts b/apps/desktop/src/config/searchKeybindings.ts index 80662a61..517eb0d7 100644 --- a/apps/desktop/src/config/searchKeybindings.ts +++ b/apps/desktop/src/config/searchKeybindings.ts @@ -132,8 +132,8 @@ export function normalizeSearchKeybindings(value: unknown): SearchKeybindings { const candidates = value as Record; - // First pass: resolve each action's desired shortcut (validated custom value, - // explicit disable, or its default) without considering conflicts yet. + // 第一轮:先解析每个动作期望使用的快捷键(合法自定义值、显式禁用或默认值), + // 暂不处理快捷键冲突。 const resolved = new Map(); for (const definition of SEARCH_KEYBINDING_DEFINITIONS) { const candidate = candidates[definition.id]; @@ -161,9 +161,8 @@ export function normalizeSearchKeybindings(value: unknown): SearchKeybindings { resolved.set(definition.id, defaults[definition.id]); } - // Second pass: resolve conflicts deterministically (first action in - // definition order keeps the shortcut). This lets clean swaps survive - // instead of both sides colliding with the other's default. + // 第二轮:按定义顺序稳定处理冲突,先出现的动作保留快捷键。 + // 这样干净的快捷键互换不会因为撞到对方默认值而被同时丢弃。 const result = createDefaultSearchKeybindings(); const usedShortcuts = new Set(); for (const definition of SEARCH_KEYBINDING_DEFINITIONS) { diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index 61118f9a..3aaa0f6b 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -828,7 +828,7 @@ const zhCNMessages = { 'conversation.toolbar.pinWindow': '窗口置顶', 'conversation.timeline.jumpToMessage': '跳转到消息: {preview}', 'assistant.loadingTip.newLine': 'Shift+Enter 换行,适合分段表述', - 'assistant.loadingTip.switchModel': 'Ctrl+M 或点击模型图标切换模型', + 'assistant.loadingTip.switchModel': '使用配置的快捷键或点击模型图标切换模型', 'assistant.loadingTip.history': 'Ctrl+H 快速打开历史会话', 'assistant.loadingTip.alwaysOnTop': 'Ctrl+P 切换窗口置顶', 'assistant.loadingTip.newSession': 'Ctrl+N 开启新会话', @@ -1865,7 +1865,8 @@ const enUSMessages: Record = { 'conversation.toolbar.pinWindow': 'Keep window on top', 'conversation.timeline.jumpToMessage': 'Jump to message: {preview}', 'assistant.loadingTip.newLine': 'Shift+Enter inserts a new line for structured prompts', - 'assistant.loadingTip.switchModel': 'Ctrl+M or the model icon switches models', + 'assistant.loadingTip.switchModel': + 'Use the configured shortcut or the model icon to switch models', 'assistant.loadingTip.history': 'Ctrl+H opens conversation history', 'assistant.loadingTip.alwaysOnTop': 'Ctrl+P toggles always on top', 'assistant.loadingTip.newSession': 'Ctrl+N starts a new conversation', diff --git a/apps/desktop/src/i18n/textMap.ts b/apps/desktop/src/i18n/textMap.ts index 748b938b..6def9656 100644 --- a/apps/desktop/src/i18n/textMap.ts +++ b/apps/desktop/src/i18n/textMap.ts @@ -511,7 +511,8 @@ export const zhToEnTextMap = { 未命名图片: 'Image', 未命名文件: 'File', 'Shift+Enter 换行,适合分段表述': 'Shift+Enter inserts a new line for structured prompts', - 'Ctrl+M 或点击模型图标切换模型': 'Ctrl+M or the model icon switches models', + 使用配置的快捷键或点击模型图标切换模型: + 'Use the configured shortcut or the model icon to switch models', 'Ctrl+H 快速打开历史会话': 'Ctrl+H opens conversation history', 'Ctrl+P 切换窗口置顶': 'Ctrl+P toggles always on top', 'Ctrl+N 开启新会话': 'Ctrl+N starts a new conversation', diff --git a/apps/desktop/src/services/EventService/types.ts b/apps/desktop/src/services/EventService/types.ts index 326c17cc..b3616512 100644 --- a/apps/desktop/src/services/EventService/types.ts +++ b/apps/desktop/src/services/EventService/types.ts @@ -56,7 +56,6 @@ export enum AppEvent { POPUP_SESSION_SEARCH_QUERY_CHANGE = 'popup-session-history-search-query-change', SEARCH_SURFACE_SHOWN = 'search-surface-shown', SEARCH_SURFACE_HIDDEN = 'search-surface-hidden', - SEARCH_SURFACE_COMMAND = 'search-surface-command', SESSION_TASK_STATUS_CHANGED = 'session:task:status-changed', SESSION_STATUS_REMINDER_ACTION = 'session-status-reminder:action', @@ -119,11 +118,6 @@ export interface SearchSurfaceHiddenEvent { sequence?: number; } -export interface SearchSurfaceCommandEvent { - command: 'toggle-model-dropdown'; - source: 'webview2-accelerator'; -} - /** * 系统级快捷键(如 Alt+Space)由 WebView2 当作 system accelerator 截获, * 不会派发 DOM keydown,因此宿主在 accelerator 阶段直接 emit 此事件, @@ -207,7 +201,6 @@ export interface AppEventMap { [AppEvent.POPUP_SESSION_SEARCH_QUERY_CHANGE]: PopupSessionSearchQueryChangePayload; [AppEvent.SEARCH_SURFACE_SHOWN]: SearchSurfaceShownEvent; [AppEvent.SEARCH_SURFACE_HIDDEN]: SearchSurfaceHiddenEvent; - [AppEvent.SEARCH_SURFACE_COMMAND]: SearchSurfaceCommandEvent; [AppEvent.SESSION_TASK_STATUS_CHANGED]: SessionTaskStatusChangedEvent; [AppEvent.SESSION_STATUS_REMINDER_ACTION]: SessionStatusReminderActionEvent; diff --git a/apps/desktop/src/views/SearchView/composables/useSearchPage.ts b/apps/desktop/src/views/SearchView/composables/useSearchPage.ts index 98a12f76..09ce2c48 100644 --- a/apps/desktop/src/views/SearchView/composables/useSearchPage.ts +++ b/apps/desktop/src/views/SearchView/composables/useSearchPage.ts @@ -384,10 +384,6 @@ interface UseSearchPageLifecycleOptions { reconcilePopupSurfaces?: () => Promise; remeasureSearchWindowHeight?: () => void | Promise; onSurfaceHidden?: () => void | Promise; - handleSearchSurfaceCommand?: (payload: { - command: 'toggle-model-dropdown'; - source: 'webview2-accelerator'; - }) => void | Promise; handleSessionStatusReminderAction?: ( payload: SessionStatusReminderActionEvent ) => void | Promise; @@ -409,7 +405,6 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { reconcilePopupSurfaces, remeasureSearchWindowHeight, onSurfaceHidden, - handleSearchSurfaceCommand, handleSessionStatusReminderAction, handleAiModelsUpdated, handleShortcutAutoPaste, @@ -420,7 +415,6 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { let unlistenAiModelsUpdated: (() => void) | null = null; let unlistenSearchSurfaceShown: (() => void) | null = null; let unlistenSearchSurfaceHidden: (() => void) | null = null; - let unlistenSearchSurfaceCommand: (() => void) | null = null; let unlistenSessionTaskStatusChanged: (() => void) | null = null; let unlistenSessionStatusReminderAction: (() => void) | null = null; let stopReadyWatch: (() => void) | null = null; @@ -681,15 +675,6 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { AppEvent.SESSION_STATUS_REMINDER_ACTION, sessionStatusReminderCoordinator.handleReminderAction ); - - unlistenSearchSurfaceCommand = await eventService.on( - AppEvent.SEARCH_SURFACE_COMMAND, - async (payload) => { - await Promise.resolve(handleSearchSurfaceCommand?.(payload)).catch((error) => { - console.error('[SearchView] Failed to handle search surface command:', error); - }); - } - ); } const handleWindowFocus = () => { @@ -774,8 +759,6 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { unlistenSessionTaskStatusChanged = null; unlistenSessionStatusReminderAction?.(); unlistenSessionStatusReminderAction = null; - unlistenSearchSurfaceCommand?.(); - unlistenSearchSurfaceCommand = null; }); return { diff --git a/apps/desktop/src/views/SearchView/index.vue b/apps/desktop/src/views/SearchView/index.vue index 3fdc5ca9..6b35ec92 100644 --- a/apps/desktop/src/views/SearchView/index.vue +++ b/apps/desktop/src/views/SearchView/index.vue @@ -412,11 +412,6 @@ reconcilePopupSurfaces: hideAllPopups, remeasureSearchWindowHeight: remeasureTargetHeight, onSurfaceHidden: clearSurfaceUiAfterHidden, - handleSearchSurfaceCommand: async (payload) => { - if (payload.command === 'toggle-model-dropdown') { - await handleToggleModelDropdownRequest(); - } - }, handleSessionStatusReminderAction, handleAiModelsUpdated, handleShortcutAutoPaste: tryShortcutAutoPaste, diff --git a/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts b/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts index d22edb35..899b6251 100644 --- a/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts +++ b/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts @@ -148,6 +148,38 @@ describe('createSearchKeyboardRouter', () => { expect(callbacks.onSearchKeybindingAction).toHaveBeenCalledWith('search.history.open'); }); + it('routes Ctrl+M only through the current keybinding configuration', async () => { + const defaultRouter = createKeyboardRouter(); + expect(defaultRouter.router.route({ key: 'm', ctrlKey: true })).toBe(true); + await flushAsyncWork(); + expect(defaultRouter.callbacks.onSearchKeybindingAction).toHaveBeenCalledWith( + 'search.model.toggle' + ); + + const disabledRouter = createKeyboardRouter({ + getSearchKeybindings: () => ({ + ...createDefaultSearchKeybindings(), + 'search.model.toggle': null, + }), + }); + expect(disabledRouter.router.route({ key: 'm', ctrlKey: true })).toBe(false); + await flushAsyncWork(); + expect(disabledRouter.callbacks.onSearchKeybindingAction).not.toHaveBeenCalled(); + + const remappedRouter = createKeyboardRouter({ + getSearchKeybindings: () => ({ + ...createDefaultSearchKeybindings(), + 'search.history.open': 'Mod+M', + 'search.model.toggle': null, + }), + }); + expect(remappedRouter.router.route({ key: 'm', ctrlKey: true })).toBe(true); + await flushAsyncWork(); + expect(remappedRouter.callbacks.onSearchKeybindingAction).toHaveBeenCalledWith( + 'search.history.open' + ); + }); + it('routes function-row search shortcuts by keyboard code when the key value is remapped', async () => { const { router, callbacks } = createKeyboardRouter({ getSearchKeybindings: () => ({ From 4c23b89daa3a337ef3dfe9a81d22144034321534 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sun, 14 Jun 2026 03:47:53 +0800 Subject: [PATCH 37/55] fix(desktop): validate search shortcut keys --- apps/desktop/src/utils/shortcuts.ts | 50 ++++++++++++++----- .../tests/config/searchKeybindings.test.ts | 2 + apps/desktop/tests/utils/shortcuts.test.ts | 10 ++++ 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/utils/shortcuts.ts b/apps/desktop/src/utils/shortcuts.ts index 17820a00..16a3075d 100644 --- a/apps/desktop/src/utils/shortcuts.ts +++ b/apps/desktop/src/utils/shortcuts.ts @@ -14,6 +14,23 @@ export interface CapturedShortcutResult { const MODIFIER_DISPLAY_ORDER = ['Mod', 'Ctrl', 'Alt', 'Shift'] as const; const SUPPORTED_CAPTURE_MODIFIERS = new Set(['Ctrl', 'Alt', 'Shift', 'Mod']); +const SUPPORTED_NON_CHARACTER_KEYS = new Set([ + 'Backspace', + 'Del', + 'Enter', + 'Esc', + 'Home', + 'End', + 'PageUp', + 'PageDown', + 'Tab', + 'Up', + 'Down', + 'Left', + 'Right', + 'Insert', + 'Space', +]); const RESERVED_LOCAL_SHORTCUT_KEYS = new Set([ 'Backspace', 'Del', @@ -47,6 +64,7 @@ const KEY_DISPLAY_MAP: Record = { }; const ALIAS_MAP: Record = { + mod: 'Mod', cmd: 'Mod', command: 'Mod', meta: 'Mod', @@ -63,6 +81,7 @@ const ALIAS_MAP: Record = { del: 'Del', return: 'Enter', enter: 'Enter', + tab: 'Tab', pageup: 'PageUp', pagedown: 'PageDown', arrowup: 'Up', @@ -74,6 +93,8 @@ const ALIAS_MAP: Record = { arrowright: 'Right', right: 'Right', backspace: 'Backspace', + insert: 'Insert', + ins: 'Insert', space: 'Space', }; @@ -112,11 +133,17 @@ function normalizeShortcutToken(token: string): string | null { return trimmed.toUpperCase(); } - if (/^f\d{1,2}$/i.test(trimmed)) { - return trimmed.toUpperCase(); + const functionKeyMatch = /^f(\d{1,2})$/i.exec(trimmed); + if (functionKeyMatch) { + const functionKeyNumber = Number(functionKeyMatch[1]); + return functionKeyNumber >= 1 && functionKeyNumber <= 24 ? trimmed.toUpperCase() : null; + } + + if (SUPPORTED_NON_CHARACTER_KEYS.has(trimmed)) { + return trimmed; } - return trimmed; + return null; } function normalizeEventKey(key: string): string | null { @@ -133,12 +160,8 @@ function normalizeFunctionKeyCode(code: string | null | undefined): string | nul return null; } - const trimmedCode = code.trim(); - if (!/^F\d{1,2}$/i.test(trimmedCode)) { - return null; - } - - return trimmedCode.toUpperCase(); + const normalizedCode = normalizeShortcutToken(code); + return normalizedCode && /^F\d{1,2}$/.test(normalizedCode) ? normalizedCode : null; } export function resolveKeyboardEventShortcutKey( @@ -155,11 +178,12 @@ export function resolveKeyboardEventShortcutKey( } function createShortcutParts(shortcut: string): { modifiers: string[]; key: string | null } { - const parts = shortcut - .split('+') - .map((part) => normalizeShortcutToken(part)) - .filter((part): part is string => Boolean(part)); + const normalizedParts = shortcut.split('+').map((part) => normalizeShortcutToken(part)); + if (normalizedParts.some((part) => !part)) { + return { modifiers: [], key: null }; + } + const parts = normalizedParts as string[]; const modifierSet = new Set(); let key: string | null = null; for (const part of parts) { diff --git a/apps/desktop/tests/config/searchKeybindings.test.ts b/apps/desktop/tests/config/searchKeybindings.test.ts index 451dbc6f..3b536679 100644 --- a/apps/desktop/tests/config/searchKeybindings.test.ts +++ b/apps/desktop/tests/config/searchKeybindings.test.ts @@ -110,6 +110,8 @@ describe('search keybinding configuration', () => { normalizeSearchKeybindings({ 'search.history.open': 42, 'search.input.focus': false, + 'search.model.toggle': 'Ctrl+DefinitelyNotAKey', + 'search.window.pin': 'Ctrl+DefinitelyNotAKey+P', }) ).toEqual(defaults); }); diff --git a/apps/desktop/tests/utils/shortcuts.test.ts b/apps/desktop/tests/utils/shortcuts.test.ts index c17a6d3c..3cfc6728 100644 --- a/apps/desktop/tests/utils/shortcuts.test.ts +++ b/apps/desktop/tests/utils/shortcuts.test.ts @@ -35,13 +35,20 @@ describe('shortcut utilities', () => { }); it('normalizes shortcut strings with aliases and stable modifier ordering', () => { + expect(normalizeLocalShortcutString('mod+h')).toBe('Mod+H'); expect(normalizeLocalShortcutString(' shift + cmd + option + k ')).toBe('Mod+Alt+Shift+K'); expect(normalizeLocalShortcutString('control+delete')).toBe('Mod+Del'); expect(normalizeLocalShortcutString('return')).toBe('Enter'); + expect(normalizeLocalShortcutString('tab')).toBe('Tab'); + expect(normalizeLocalShortcutString('insert')).toBe('Insert'); expect(normalizeLocalShortcutString('f12')).toBe('F12'); expect(normalizeLocalShortcutString(null)).toBeNull(); expect(normalizeLocalShortcutString('Ctrl+Alt')).toBeNull(); expect(normalizeLocalShortcutString('Ctrl+A+B')).toBeNull(); + expect(normalizeLocalShortcutString('Ctrl+DefinitelyNotAKey')).toBeNull(); + expect(normalizeLocalShortcutString('Ctrl+DefinitelyNotAKey+A')).toBeNull(); + expect(normalizeLocalShortcutString('Ctrl++A')).toBeNull(); + expect(normalizeLocalShortcutString('F25')).toBeNull(); expect(normalizeLocalShortcutString(' ')).toBeNull(); setPlatform('MacIntel'); @@ -67,6 +74,9 @@ describe('shortcut utilities', () => { expect(resolveKeyboardEventShortcutKey(' ', null)).toBe('Space'); expect(resolveKeyboardEventShortcutKey('BrightnessUp', 'F2')).toBe('F2'); expect(resolveKeyboardEventShortcutKey('F2', 'F3')).toBe('F2'); + expect(resolveKeyboardEventShortcutKey('BrightnessUp', 'F25')).toBeNull(); + expect(resolveKeyboardEventShortcutKey('x', 'Space')).toBe('X'); + expect(resolveKeyboardEventShortcutKey('BrightnessUp', 'Space')).toBeNull(); expect(resolveKeyboardEventShortcutKey('', 'KeyA')).toBeNull(); expect(resolveKeyboardEventShortcutKey(undefined, undefined)).toBeNull(); }); From 563ef2589f66e7bb324301550637140dac6ea902 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sun, 14 Jun 2026 03:48:57 +0800 Subject: [PATCH 38/55] fix(desktop): remove hardcoded search shortcut tips --- apps/desktop/src/i18n/messages.ts | 12 ++++++------ apps/desktop/src/i18n/textMap.ts | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index 3aaa0f6b..495c339b 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -829,9 +829,9 @@ const zhCNMessages = { 'conversation.timeline.jumpToMessage': '跳转到消息: {preview}', 'assistant.loadingTip.newLine': 'Shift+Enter 换行,适合分段表述', 'assistant.loadingTip.switchModel': '使用配置的快捷键或点击模型图标切换模型', - 'assistant.loadingTip.history': 'Ctrl+H 快速打开历史会话', - 'assistant.loadingTip.alwaysOnTop': 'Ctrl+P 切换窗口置顶', - 'assistant.loadingTip.newSession': 'Ctrl+N 开启新会话', + 'assistant.loadingTip.history': '使用配置的快捷键快速打开历史会话', + 'assistant.loadingTip.alwaysOnTop': '使用配置的快捷键切换窗口置顶', + 'assistant.loadingTip.newSession': '使用配置的快捷键开启新会话', 'assistant.loadingTip.historyButton': '右上角历史按钮可查看和切换历史会话', 'assistant.loadingTip.copy': '消息下方按钮可复制内容', 'assistant.loadingTip.regenerate': '消息下方按钮可重新生成回复', @@ -1867,9 +1867,9 @@ const enUSMessages: Record = { 'assistant.loadingTip.newLine': 'Shift+Enter inserts a new line for structured prompts', 'assistant.loadingTip.switchModel': 'Use the configured shortcut or the model icon to switch models', - 'assistant.loadingTip.history': 'Ctrl+H opens conversation history', - 'assistant.loadingTip.alwaysOnTop': 'Ctrl+P toggles always on top', - 'assistant.loadingTip.newSession': 'Ctrl+N starts a new conversation', + 'assistant.loadingTip.history': 'Use the configured shortcut to open conversation history', + 'assistant.loadingTip.alwaysOnTop': 'Use the configured shortcut to toggle always on top', + 'assistant.loadingTip.newSession': 'Use the configured shortcut to start a new conversation', 'assistant.loadingTip.historyButton': 'Use the history button in the top right to view and switch conversations', 'assistant.loadingTip.copy': 'Buttons below a message can copy content', diff --git a/apps/desktop/src/i18n/textMap.ts b/apps/desktop/src/i18n/textMap.ts index 6def9656..59f02b34 100644 --- a/apps/desktop/src/i18n/textMap.ts +++ b/apps/desktop/src/i18n/textMap.ts @@ -513,9 +513,9 @@ export const zhToEnTextMap = { 'Shift+Enter 换行,适合分段表述': 'Shift+Enter inserts a new line for structured prompts', 使用配置的快捷键或点击模型图标切换模型: 'Use the configured shortcut or the model icon to switch models', - 'Ctrl+H 快速打开历史会话': 'Ctrl+H opens conversation history', - 'Ctrl+P 切换窗口置顶': 'Ctrl+P toggles always on top', - 'Ctrl+N 开启新会话': 'Ctrl+N starts a new conversation', + 使用配置的快捷键快速打开历史会话: 'Use the configured shortcut to open conversation history', + 使用配置的快捷键切换窗口置顶: 'Use the configured shortcut to toggle always on top', + 使用配置的快捷键开启新会话: 'Use the configured shortcut to start a new conversation', 右上角历史按钮可查看和切换历史会话: 'Use the history button in the top right to view and switch conversations', 消息下方按钮可复制内容: 'Buttons below a message can copy content', From 3d995c37ecc4febbccb40eab2e5406f43bb326ca Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sun, 14 Jun 2026 04:42:38 +0800 Subject: [PATCH 39/55] fix(desktop): harden search shortcut handling --- apps/desktop/src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/commands/shortcut.rs | 7 + .../src-tauri/src/core/system/shortcut.rs | 259 +++++++++++++++++- .../src/core/window/webview_defaults.rs | 39 ++- apps/desktop/src/i18n/messages.ts | 4 + .../src/services/EventService/index.ts | 1 + .../src/services/EventService/types.ts | 9 + .../src/services/NativeService/index.ts | 1 + .../src/services/NativeService/shortcut.ts | 8 + .../composables/searchInteraction.ts | 9 +- .../SearchView/composables/useSearchPage.ts | 62 ++++- apps/desktop/src/views/SearchView/index.vue | 43 +++ .../General/SearchShortcutSettings.vue | 12 + .../SearchView/useSearchPage.test.ts | 71 +++++ .../tests/services/native-service.test.ts | 19 ++ .../settingsGeneralComponent.test.ts | 33 +++ 16 files changed, 568 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index bb64a38b..28e35069 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -40,6 +40,7 @@ pub fn invoke_handler( window::get_search_window_state, shortcut::register_global_shortcut, shortcut::get_shortcut_status, + shortcut::set_search_surface_shortcuts, clipboard::read_clipboard_payload, clipboard::consume_shortcut_auto_paste_payload, clipboard::write_clipboard_text, diff --git a/apps/desktop/src-tauri/src/commands/shortcut.rs b/apps/desktop/src-tauri/src/commands/shortcut.rs index defcc0b0..7784e139 100644 --- a/apps/desktop/src-tauri/src/commands/shortcut.rs +++ b/apps/desktop/src-tauri/src/commands/shortcut.rs @@ -15,3 +15,10 @@ pub fn register_global_shortcut( pub fn get_shortcut_status() -> (bool, Option) { crate::core::system::shortcut::get_shortcut_status() } + +#[tauri::command] +pub fn set_search_surface_shortcuts( + entries: Vec, +) -> Result<(), String> { + crate::core::system::shortcut::set_search_surface_shortcuts(entries) +} diff --git a/apps/desktop/src-tauri/src/core/system/shortcut.rs b/apps/desktop/src-tauri/src/core/system/shortcut.rs index 0bf793fe..13f2c544 100644 --- a/apps/desktop/src-tauri/src/core/system/shortcut.rs +++ b/apps/desktop/src-tauri/src/core/system/shortcut.rs @@ -13,6 +13,29 @@ use tauri_plugin_global_shortcut::{ static CURRENT_SHORTCUT: Mutex> = Mutex::new(None); static REGISTRATION_STATUS: Mutex<(bool, Option)> = Mutex::new((false, None)); +static SEARCH_SURFACE_SHORTCUTS: Mutex> = Mutex::new(Vec::new()); + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchSurfaceShortcutEntry { + pub action_id: String, + pub shortcut: String, +} + +#[derive(Debug, Clone)] +struct SearchSurfaceShortcut { + action_id: String, + shortcut: String, + parsed: Shortcut, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchSurfaceCommand { + pub action_id: String, + pub shortcut: String, + pub source: &'static str, +} /// 先异步跳出 WM_HOTKEY 回调栈,再把搜索窗口切换投递回 Tauri 主事件循环。 fn schedule_search_window_toggle(app_handle: AppHandle) { @@ -65,12 +88,6 @@ pub fn register_global_shortcut( .register(new_shortcut) .map_err(|e| format!("Failed to register shortcut: {}", e)); - if result.is_err() { - if let Some(old_shortcut) = old_shortcut { - let _ = app.global_shortcut().register(old_shortcut); - } - } - match result { Ok(_) => { if let Ok(mut current) = CURRENT_SHORTCUT.lock() { @@ -81,7 +98,27 @@ pub fn register_global_shortcut( } } Err(ref e) => { - if let Ok(mut status) = REGISTRATION_STATUS.lock() { + if let Some(old_shortcut) = old_shortcut { + match app.global_shortcut().register(old_shortcut) { + Ok(_) => { + if let Ok(mut status) = REGISTRATION_STATUS.lock() { + *status = (false, None); + } + } + Err(restore_error) => { + let message = format!( + "{}; failed to restore previous shortcut: {}", + e, restore_error + ); + if let Ok(mut current) = CURRENT_SHORTCUT.lock() { + *current = None; + } + if let Ok(mut status) = REGISTRATION_STATUS.lock() { + *status = (true, Some(message)); + } + } + } + } else if let Ok(mut status) = REGISTRATION_STATUS.lock() { *status = (true, Some(e.clone())); } } @@ -97,6 +134,156 @@ pub fn get_shortcut_status() -> (bool, Option) { .unwrap_or((false, None)) } +pub fn set_search_surface_shortcuts( + entries: Vec, +) -> Result<(), String> { + let mut parsed_entries = Vec::with_capacity(entries.len()); + for entry in entries { + parsed_entries.push(SearchSurfaceShortcut { + parsed: parse_shortcut(&entry.shortcut)?, + action_id: entry.action_id, + shortcut: entry.shortcut, + }); + } + + let mut shortcuts = SEARCH_SURFACE_SHORTCUTS + .lock() + .map_err(|_| "Failed to lock search surface shortcuts".to_string())?; + *shortcuts = parsed_entries; + Ok(()) +} + +pub fn find_search_surface_command_for_windows_accelerator( + virtual_key: u32, + control: bool, + alt: bool, + shift: bool, + super_key: bool, +) -> Option { + let candidate = windows_accelerator_to_shortcut(virtual_key, control, alt, shift, super_key)?; + let shortcuts = SEARCH_SURFACE_SHORTCUTS.lock().ok()?; + shortcuts + .iter() + .find(|entry| entry.parsed.mods == candidate.mods && entry.parsed.key == candidate.key) + .map(|entry| SearchSurfaceCommand { + action_id: entry.action_id.clone(), + shortcut: entry.shortcut.clone(), + source: "webview2-accelerator", + }) +} + +fn windows_accelerator_to_shortcut( + virtual_key: u32, + control: bool, + alt: bool, + shift: bool, + super_key: bool, +) -> Option { + let key = windows_virtual_key_to_code(virtual_key)?; + let mut modifiers = Modifiers::empty(); + if control { + modifiers |= Modifiers::CONTROL; + } + if alt { + modifiers |= Modifiers::ALT; + } + if shift { + modifiers |= Modifiers::SHIFT; + } + if super_key { + modifiers |= Modifiers::SUPER; + } + + Some(Shortcut::new( + if modifiers.is_empty() { + None + } else { + Some(modifiers) + }, + key, + )) +} + +fn windows_virtual_key_to_code(virtual_key: u32) -> Option { + match virtual_key { + 0x08 => Some(Code::Backspace), + 0x09 => Some(Code::Tab), + 0x0D => Some(Code::Enter), + 0x1B => Some(Code::Escape), + 0x20 => Some(Code::Space), + 0x21 => Some(Code::PageUp), + 0x22 => Some(Code::PageDown), + 0x23 => Some(Code::End), + 0x24 => Some(Code::Home), + 0x25 => Some(Code::ArrowLeft), + 0x26 => Some(Code::ArrowUp), + 0x27 => Some(Code::ArrowRight), + 0x28 => Some(Code::ArrowDown), + 0x2D => Some(Code::Insert), + 0x2E => Some(Code::Delete), + 0x30 => Some(Code::Digit0), + 0x31 => Some(Code::Digit1), + 0x32 => Some(Code::Digit2), + 0x33 => Some(Code::Digit3), + 0x34 => Some(Code::Digit4), + 0x35 => Some(Code::Digit5), + 0x36 => Some(Code::Digit6), + 0x37 => Some(Code::Digit7), + 0x38 => Some(Code::Digit8), + 0x39 => Some(Code::Digit9), + 0x41 => Some(Code::KeyA), + 0x42 => Some(Code::KeyB), + 0x43 => Some(Code::KeyC), + 0x44 => Some(Code::KeyD), + 0x45 => Some(Code::KeyE), + 0x46 => Some(Code::KeyF), + 0x47 => Some(Code::KeyG), + 0x48 => Some(Code::KeyH), + 0x49 => Some(Code::KeyI), + 0x4A => Some(Code::KeyJ), + 0x4B => Some(Code::KeyK), + 0x4C => Some(Code::KeyL), + 0x4D => Some(Code::KeyM), + 0x4E => Some(Code::KeyN), + 0x4F => Some(Code::KeyO), + 0x50 => Some(Code::KeyP), + 0x51 => Some(Code::KeyQ), + 0x52 => Some(Code::KeyR), + 0x53 => Some(Code::KeyS), + 0x54 => Some(Code::KeyT), + 0x55 => Some(Code::KeyU), + 0x56 => Some(Code::KeyV), + 0x57 => Some(Code::KeyW), + 0x58 => Some(Code::KeyX), + 0x59 => Some(Code::KeyY), + 0x5A => Some(Code::KeyZ), + 0x70 => Some(Code::F1), + 0x71 => Some(Code::F2), + 0x72 => Some(Code::F3), + 0x73 => Some(Code::F4), + 0x74 => Some(Code::F5), + 0x75 => Some(Code::F6), + 0x76 => Some(Code::F7), + 0x77 => Some(Code::F8), + 0x78 => Some(Code::F9), + 0x79 => Some(Code::F10), + 0x7A => Some(Code::F11), + 0x7B => Some(Code::F12), + 0xBA => Some(Code::Semicolon), + 0xBB => Some(Code::Equal), + 0xBC => Some(Code::Comma), + 0xBD => Some(Code::Minus), + 0xBE => Some(Code::Period), + 0xBF => Some(Code::Slash), + 0xC0 => Some(Code::Backquote), + 0xDB => Some(Code::BracketLeft), + 0xDC => Some(Code::Backslash), + 0xDD => Some(Code::BracketRight), + 0xDE => Some(Code::Quote), + _ => None, + } +} + pub fn parse_shortcut(shortcut_str: &str) -> Result { let parts: Vec<&str> = shortcut_str.split('+').map(|s| s.trim()).collect(); @@ -143,6 +330,17 @@ pub fn parse_shortcut(shortcut_str: &str) -> Result { "arrowdown" | "down" => Code::ArrowDown, "arrowleft" | "left" => Code::ArrowLeft, "arrowright" | "right" => Code::ArrowRight, + "," | "comma" => Code::Comma, + "." | "period" => Code::Period, + "=" | "equal" | "equals" => Code::Equal, + "-" | "minus" => Code::Minus, + ";" | "semicolon" => Code::Semicolon, + "/" | "slash" => Code::Slash, + "'" | "quote" => Code::Quote, + "`" | "backquote" => Code::Backquote, + "[" | "bracketleft" => Code::BracketLeft, + "]" | "bracketright" => Code::BracketRight, + "\\" | "backslash" => Code::Backslash, "a" => Code::KeyA, "b" => Code::KeyB, "c" => Code::KeyC, @@ -271,4 +469,51 @@ mod tests { assert_eq!(shortcut.mods, Modifiers::SUPER); assert_eq!(shortcut.key, Code::Space); } + + #[test] + fn parse_shortcut_accepts_punctuation_keys() { + let shortcut = parse_shortcut("Mod+,").expect("mod+comma parses"); + let expected_mod = if cfg!(target_os = "macos") { + Modifiers::SUPER + } else { + Modifiers::CONTROL + }; + assert_eq!(shortcut.mods, expected_mod); + assert_eq!(shortcut.key, Code::Comma); + + let shortcut = parse_shortcut("Ctrl+=").expect("ctrl+equal parses"); + assert_eq!(shortcut.mods, Modifiers::CONTROL); + assert_eq!(shortcut.key, Code::Equal); + } + + #[test] + fn search_surface_command_matches_windows_accelerator() { + set_search_surface_shortcuts(vec![ + SearchSurfaceShortcutEntry { + action_id: "search.model.toggle".to_string(), + shortcut: "Mod+M".to_string(), + }, + SearchSurfaceShortcutEntry { + action_id: "search.settings.open".to_string(), + shortcut: "Mod+,".to_string(), + }, + ]) + .expect("shortcuts sync"); + + let command = find_search_surface_command_for_windows_accelerator( + 0xBC, + !cfg!(target_os = "macos"), + false, + false, + cfg!(target_os = "macos"), + ) + .expect("comma shortcut matches"); + assert_eq!(command.action_id, "search.settings.open"); + assert_eq!(command.shortcut, "Mod+,"); + + assert!(find_search_surface_command_for_windows_accelerator( + 0x4D, false, false, false, false + ) + .is_none()); + } } diff --git a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs index 9eaacf0b..f7bb8a94 100644 --- a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs +++ b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs @@ -20,7 +20,9 @@ use webview2_com::Microsoft::Web::WebView2::Win32::{ #[cfg(target_os = "windows")] use windows::Win32::Foundation::{BOOL, HWND, LPARAM, LRESULT, TRUE, WPARAM}; #[cfg(target_os = "windows")] -use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyState, VK_CONTROL, VK_SHIFT, VK_SPACE}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + GetKeyState, VK_CONTROL, VK_LWIN, VK_MENU, VK_RWIN, VK_SHIFT, VK_SPACE, +}; #[cfg(target_os = "windows")] use windows::Win32::UI::Shell::{DefSubclassProc, SetWindowSubclass}; #[cfg(target_os = "windows")] @@ -189,6 +191,12 @@ fn is_system_menu_accelerator_command(key_event_kind: i32, virtual_key: u32) -> is_system_key_down && virtual_key == u32::from(VK_SPACE.0) } +#[cfg(target_os = "windows")] +fn is_accelerator_key_down_event(key_event_kind: i32) -> bool { + key_event_kind == COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN.0 + || key_event_kind == COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN.0 +} + #[cfg(target_os = "windows")] /// 注册 WebView2 accelerator 处理器,将 Alt+Space 转成 Tauri 事件供前端捕获。 /// @@ -217,6 +225,35 @@ fn register_system_menu_accelerator_handler( args.VirtualKey(&mut virtual_key)?; if !is_system_menu_accelerator_command(key_event_kind.0, virtual_key) { + if !is_accelerator_key_down_event(key_event_kind.0) { + return Ok(()); + } + + let is_ctrl_down = (GetKeyState(i32::from(VK_CONTROL.0)) as u16 & 0x8000) != 0; + let is_alt_down = (GetKeyState(i32::from(VK_MENU.0)) as u16 & 0x8000) != 0; + let is_shift_down = (GetKeyState(i32::from(VK_SHIFT.0)) as u16 & 0x8000) != 0; + let is_super_down = (GetKeyState(i32::from(VK_LWIN.0)) as u16 & 0x8000) != 0 + || (GetKeyState(i32::from(VK_RWIN.0)) as u16 & 0x8000) != 0; + + let Some(command) = + crate::core::system::shortcut::find_search_surface_command_for_windows_accelerator( + virtual_key, + is_ctrl_down, + is_alt_down, + is_shift_down, + is_super_down, + ) + else { + return Ok(()); + }; + + if let Ok(args2) = + Interface::cast::(&args) + { + let _ = args2.SetIsBrowserAcceleratorKeyEnabled(false); + } + let _ = args.SetHandled(true); + let _ = app_handle.emit("search-surface-command", command); return Ok(()); } diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index 495c339b..710b18e9 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -130,6 +130,8 @@ const zhCNMessages = { '快捷键需要包含 Ctrl、Alt 或 Cmd,或使用允许的功能键', 'settings.general.searchShortcuts.errors.reserved': '该快捷键保留给输入/导航行为,请选择其他组合', + 'settings.general.searchShortcuts.errors.unsupported': + '暂不支持该按键作为搜索页快捷键,请换一个组合', 'settings.general.searchShortcuts.errors.duplicate': '该快捷键已被“{action}”使用,请换一个组合', 'settings.general.searchShortcuts.errors.globalConflict': '该快捷键与全局唤起快捷键冲突,请换一个组合', @@ -1098,6 +1100,8 @@ const enUSMessages: Record = { 'A shortcut must include Ctrl, Alt, or Cmd, or use an allowed function key', 'settings.general.searchShortcuts.errors.reserved': 'This shortcut is reserved for typing or navigation. Choose another combination.', + 'settings.general.searchShortcuts.errors.unsupported': + 'This key is not supported for search shortcuts yet. Choose another combination.', 'settings.general.searchShortcuts.errors.duplicate': 'This shortcut is already used by "{action}". Choose another combination.', 'settings.general.searchShortcuts.errors.globalConflict': diff --git a/apps/desktop/src/services/EventService/index.ts b/apps/desktop/src/services/EventService/index.ts index f345d135..88bbc139 100644 --- a/apps/desktop/src/services/EventService/index.ts +++ b/apps/desktop/src/services/EventService/index.ts @@ -77,6 +77,7 @@ export type { GeneralSettingKey, McpServerStatus, McpStatusChangeEvent, + SearchSurfaceCommandEvent, SettingsGeneralUpdatedEvent, ShortcutCaptureSystemKeyEvent, WindowFocusEvent, diff --git a/apps/desktop/src/services/EventService/types.ts b/apps/desktop/src/services/EventService/types.ts index b3616512..d52b36eb 100644 --- a/apps/desktop/src/services/EventService/types.ts +++ b/apps/desktop/src/services/EventService/types.ts @@ -11,6 +11,7 @@ import type { PopupSessionSearchQueryChangePayload, } from '@services/PopupService/types'; +import type { SearchKeybindingActionId } from '@/config/searchKeybindings'; import type { GeneralSettingKey, GeneralSettingValue } from '@/stores/setting'; import type { SessionStatusReminderKind } from '@/utils/session'; @@ -56,6 +57,7 @@ export enum AppEvent { POPUP_SESSION_SEARCH_QUERY_CHANGE = 'popup-session-history-search-query-change', SEARCH_SURFACE_SHOWN = 'search-surface-shown', SEARCH_SURFACE_HIDDEN = 'search-surface-hidden', + SEARCH_SURFACE_COMMAND = 'search-surface-command', SESSION_TASK_STATUS_CHANGED = 'session:task:status-changed', SESSION_STATUS_REMINDER_ACTION = 'session-status-reminder:action', @@ -118,6 +120,12 @@ export interface SearchSurfaceHiddenEvent { sequence?: number; } +export interface SearchSurfaceCommandEvent { + actionId: SearchKeybindingActionId; + shortcut: string; + source: 'webview2-accelerator'; +} + /** * 系统级快捷键(如 Alt+Space)由 WebView2 当作 system accelerator 截获, * 不会派发 DOM keydown,因此宿主在 accelerator 阶段直接 emit 此事件, @@ -201,6 +209,7 @@ export interface AppEventMap { [AppEvent.POPUP_SESSION_SEARCH_QUERY_CHANGE]: PopupSessionSearchQueryChangePayload; [AppEvent.SEARCH_SURFACE_SHOWN]: SearchSurfaceShownEvent; [AppEvent.SEARCH_SURFACE_HIDDEN]: SearchSurfaceHiddenEvent; + [AppEvent.SEARCH_SURFACE_COMMAND]: SearchSurfaceCommandEvent; [AppEvent.SESSION_TASK_STATUS_CHANGED]: SessionTaskStatusChangedEvent; [AppEvent.SESSION_STATUS_REMINDER_ACTION]: SessionStatusReminderActionEvent; diff --git a/apps/desktop/src/services/NativeService/index.ts b/apps/desktop/src/services/NativeService/index.ts index 4cc78738..9b518b3a 100644 --- a/apps/desktop/src/services/NativeService/index.ts +++ b/apps/desktop/src/services/NativeService/index.ts @@ -21,6 +21,7 @@ export type { McpToolDefinition, McpTransportType, } from './mcp'; +export type { SearchSurfaceShortcutEntry } from './shortcut'; export type { AppUpdateChannel, AppUpdateChannelLatest, diff --git a/apps/desktop/src/services/NativeService/shortcut.ts b/apps/desktop/src/services/NativeService/shortcut.ts index 78afa41c..f9fe5b7f 100644 --- a/apps/desktop/src/services/NativeService/shortcut.ts +++ b/apps/desktop/src/services/NativeService/shortcut.ts @@ -1,5 +1,10 @@ import { invoke } from '@tauri-apps/api/core'; +export interface SearchSurfaceShortcutEntry { + actionId: string; + shortcut: string; +} + export const shortcut = { registerGlobalShortcut(shortcut: string): Promise { return invoke('register_global_shortcut', { shortcut }); @@ -7,4 +12,7 @@ export const shortcut = { getShortcutStatus(): Promise<[boolean, string | null]> { return invoke('get_shortcut_status'); }, + setSearchSurfaceShortcuts(entries: SearchSurfaceShortcutEntry[]): Promise { + return invoke('set_search_surface_shortcuts', { entries }); + }, } as const; diff --git a/apps/desktop/src/views/SearchView/composables/searchInteraction.ts b/apps/desktop/src/views/SearchView/composables/searchInteraction.ts index 25152aa8..74f7fd70 100644 --- a/apps/desktop/src/views/SearchView/composables/searchInteraction.ts +++ b/apps/desktop/src/views/SearchView/composables/searchInteraction.ts @@ -7,7 +7,7 @@ import { AppEvent, eventService } from '@services/EventService'; import type { PopupKeydownPayload } from '@services/PopupService'; import { computed, type ComputedRef, reactive, type Ref, ref, watch } from 'vue'; -import type { SearchKeybindings } from '@/config/searchKeybindings'; +import type { SearchKeybindingActionId, SearchKeybindings } from '@/config/searchKeybindings'; import { useAskUserStore } from '@/stores/askUser'; import { cloneInputHistorySnapshot, @@ -137,6 +137,7 @@ export interface UseSearchKeyboardOptions { toggleWindowPin: () => Promise; toggleWindowMaximize: () => Promise; openSettingsWindow: () => Promise; + handleSearchKeybindingAction?: (actionId: SearchKeybindingActionId) => void | Promise; handleSubmit: (query: string) => Promise; cancelRequest: () => void; clearSession: () => void; @@ -675,6 +676,7 @@ export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { toggleWindowPin, toggleWindowMaximize, openSettingsWindow, + handleSearchKeybindingAction, handleSubmit, cancelRequest, clearSession, @@ -776,6 +778,11 @@ export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { queryText.value = ''; }, onSearchKeybindingAction: async (actionId) => { + if (handleSearchKeybindingAction) { + await handleSearchKeybindingAction(actionId); + return; + } + switch (actionId) { case 'search.history.open': await openHistoryDialog(); diff --git a/apps/desktop/src/views/SearchView/composables/useSearchPage.ts b/apps/desktop/src/views/SearchView/composables/useSearchPage.ts index 09ce2c48..eae7aeae 100644 --- a/apps/desktop/src/views/SearchView/composables/useSearchPage.ts +++ b/apps/desktop/src/views/SearchView/composables/useSearchPage.ts @@ -4,7 +4,10 @@ */ import { useAlert } from '@composables/useAlert'; import { AppEvent, eventService } from '@services/EventService'; -import type { SessionStatusReminderActionEvent } from '@services/EventService/types'; +import type { + SearchSurfaceCommandEvent, + SessionStatusReminderActionEvent, +} from '@services/EventService/types'; import { native } from '@services/NativeService'; import { initNotificationPermission, notify } from '@services/NotificationService'; import type { ModelDropdownData, ModelDropdownPopupItem } from '@services/PopupService'; @@ -13,9 +16,11 @@ import { runStartupTasks } from '@services/StartupService'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { nextTick, onMounted, onUnmounted, type Ref, ref, watch } from 'vue'; +import { SEARCH_KEYBINDING_DEFINITIONS, type SearchKeybindings } from '@/config/searchKeybindings'; import { type MessageKey, type MessageParams, t } from '@/i18n'; import { useSettingsStore } from '@/stores/settings'; import { isE2eTestMode } from '@/utils/runtimeMode'; +import { normalizeLocalShortcutString } from '@/utils/shortcuts'; import type { ConversationPanelHandle, @@ -34,6 +39,13 @@ import { useModelDropdownPopup } from './useModelDropdownPopup'; const HIDE_TIMEOUT_MS = 5 * 60 * 1000; +function buildSearchSurfaceShortcutEntries(searchKeybindings: SearchKeybindings) { + return SEARCH_KEYBINDING_DEFINITIONS.flatMap((definition) => { + const shortcut = normalizeLocalShortcutString(searchKeybindings[definition.id]); + return shortcut ? [{ actionId: definition.id, shortcut }] : []; + }); +} + export function useSearchWindowPin() { const currentWindow = getCurrentWindow(); const isPinned = ref(false); @@ -377,6 +389,7 @@ interface UseSearchPageLifecycleOptions { isDragging: Ref; isPinned: Ref; isMaximized?: Readonly>; + searchKeybindings?: Readonly>; interactionContext: ReturnType; syncWindowPinState: () => Promise; clearSession: () => void | Promise; @@ -389,6 +402,7 @@ interface UseSearchPageLifecycleOptions { ) => void | Promise; handleAiModelsUpdated?: () => void | Promise; handleShortcutAutoPaste?: () => void | Promise; + handleSearchSurfaceCommand?: (payload: SearchSurfaceCommandEvent) => void | Promise; } export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { @@ -398,6 +412,7 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { isDragging, isPinned, isMaximized, + searchKeybindings, interactionContext, syncWindowPinState, clearSession, @@ -408,6 +423,7 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { handleSessionStatusReminderAction, handleAiModelsUpdated, handleShortcutAutoPaste, + handleSearchSurfaceCommand, } = options; const settingsStore = useSettingsStore(); @@ -415,10 +431,12 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { let unlistenAiModelsUpdated: (() => void) | null = null; let unlistenSearchSurfaceShown: (() => void) | null = null; let unlistenSearchSurfaceHidden: (() => void) | null = null; + let unlistenSearchSurfaceCommand: (() => void) | null = null; let unlistenSessionTaskStatusChanged: (() => void) | null = null; let unlistenSessionStatusReminderAction: (() => void) | null = null; let stopReadyWatch: (() => void) | null = null; let stopPinnedWatch: (() => void) | null = null; + let stopSearchSurfaceShortcutWatch: (() => void) | null = null; let lifecycleInitialized = false; let restoredActivationEpoch: number | null = null; let latestSurfaceSequence = 0; @@ -565,6 +583,20 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { } } + async function syncSearchSurfaceShortcutsSafely() { + if (!searchKeybindings) { + return; + } + + try { + await native.shortcut.setSearchSurfaceShortcuts( + buildSearchSurfaceShortcutEntries(searchKeybindings.value) + ); + } catch (error) { + console.error('[SearchView] Failed to sync search surface shortcuts:', error); + } + } + async function initializeSearchView() { try { await initializeGlobalShortcut(); @@ -666,6 +698,20 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { } ); + if (handleSearchSurfaceCommand) { + unlistenSearchSurfaceCommand = await eventService.on( + AppEvent.SEARCH_SURFACE_COMMAND, + (payload) => { + void Promise.resolve(handleSearchSurfaceCommand(payload)).catch((error) => { + console.error( + '[SearchView] Failed to handle search surface command:', + error + ); + }); + } + ); + } + unlistenSessionTaskStatusChanged = await eventService.on( AppEvent.SESSION_TASK_STATUS_CHANGED, sessionStatusReminderCoordinator.handleTaskStatusChanged @@ -721,6 +767,16 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { { immediate: true, flush: 'sync' } ); + if (searchKeybindings) { + stopSearchSurfaceShortcutWatch = watch( + searchKeybindings, + () => { + void syncSearchSurfaceShortcutsSafely(); + }, + { deep: true, immediate: true, flush: 'post' } + ); + } + if (viewReady.value) { void startLifecycleOnceReady(); return; @@ -749,12 +805,16 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { stopReadyWatch = null; stopPinnedWatch?.(); stopPinnedWatch = null; + stopSearchSurfaceShortcutWatch?.(); + stopSearchSurfaceShortcutWatch = null; unlistenAiModelsUpdated?.(); unlistenAiModelsUpdated = null; unlistenSearchSurfaceShown?.(); unlistenSearchSurfaceShown = null; unlistenSearchSurfaceHidden?.(); unlistenSearchSurfaceHidden = null; + unlistenSearchSurfaceCommand?.(); + unlistenSearchSurfaceCommand = null; unlistenSessionTaskStatusChanged?.(); unlistenSessionTaskStatusChanged = null; unlistenSessionStatusReminderAction?.(); diff --git a/apps/desktop/src/views/SearchView/index.vue b/apps/desktop/src/views/SearchView/index.vue index 6b35ec92..0d27fbee 100644 --- a/apps/desktop/src/views/SearchView/index.vue +++ b/apps/desktop/src/views/SearchView/index.vue @@ -14,6 +14,7 @@ import { storeToRefs } from 'pinia'; import { computed, nextTick, onMounted, onUnmounted, reactive, ref, toRef, watch } from 'vue'; + import type { SearchKeybindingActionId } from '@/config/searchKeybindings'; import { t } from '@/i18n'; import { mcpManager } from '@/services/AgentService/infrastructure/mcp'; import type { SessionTaskStatus } from '@/services/AgentService/task/types'; @@ -399,12 +400,47 @@ await refreshModelDropdownData(); } + async function handleSearchKeybindingAction(actionId: SearchKeybindingActionId) { + switch (actionId) { + case 'search.history.open': + await openHistoryDialog(); + return; + case 'search.input.focus': + await hideAllPopups(); + await controller.focusSearchInput(); + return; + case 'search.session.new': + await handleStartNewSession(); + return; + case 'search.session.reopenLastClosed': + await handleReopenLastClosedSession(); + return; + case 'search.model.toggle': + await handleToggleModelDropdownRequest(); + return; + case 'search.window.pin': + await handleToggleWindowPin(); + return; + case 'search.window.maximize': + await handleToggleMaximize(); + return; + case 'search.settings.open': + await native.window.openSettingsWindow(); + return; + default: { + const exhaustiveActionId: never = actionId; + throw new Error(`Unhandled search keybinding action: ${exhaustiveActionId}`); + } + } + } + const { hideSearchWindow } = useSearchPageLifecycle({ controller, viewReady, isDragging, isPinned, isMaximized: effectiveWindowMaximized, + searchKeybindings, interactionContext: searchInteractionContext, syncWindowPinState, clearSession: clearSessionToIdle, @@ -415,6 +451,12 @@ handleSessionStatusReminderAction, handleAiModelsUpdated, handleShortcutAutoPaste: tryShortcutAutoPaste, + handleSearchSurfaceCommand: async (payload) => { + if (!viewReady.value) { + return; + } + await handleSearchKeybindingAction(payload.actionId); + }, }); function getSessionHistoryPopupData(): SessionHistoryData { @@ -537,6 +579,7 @@ toggleWindowPin: handleToggleWindowPin, toggleWindowMaximize: handleToggleMaximize, openSettingsWindow: native.window.openSettingsWindow, + handleSearchKeybindingAction, handleSubmit, cancelRequest, clearSession: clearSessionToIdle, diff --git a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue index f6418462..4f80d206 100644 --- a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue +++ b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue @@ -249,6 +249,18 @@ const normalizedShortcut = shortcut === null ? null : normalizeLocalShortcutString(shortcut); + if (shortcut !== null && !normalizedShortcut) { + reportSearchShortcutError( + actionId, + 'settings.general.searchShortcuts.errors.unsupported' + ); + updateSearchShortcutDisplay( + actionId, + formatSearchShortcutForSettings(settings.value.searchKeybindings[actionId]) + ); + return false; + } + if (normalizedShortcut) { const definition = getSearchKeybindingDefinition(actionId); const allowsModifierlessFunctionShortcut = diff --git a/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts b/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts index 57a278ff..1d145faf 100644 --- a/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts +++ b/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts @@ -3,6 +3,7 @@ import { mountComposable } from '@tests/utils/composables'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { nextTick, ref } from 'vue'; +import { createDefaultSearchKeybindings } from '@/config/searchKeybindings'; import { createSearchInteractionContext } from '@/views/SearchView/composables/searchInteraction'; import { useSearchPageController, @@ -47,6 +48,7 @@ const { }, shortcut: { registerGlobalShortcut: vi.fn(), + setSearchSurfaceShortcuts: vi.fn(), }, window: { hideSearchWindow: vi.fn(), @@ -236,6 +238,7 @@ describe('useSearchPageLifecycle', () => { currentWindowMock.setAlwaysOnTop.mockResolvedValue(undefined); nativeMock.shortcut.registerGlobalShortcut.mockResolvedValue(undefined); + nativeMock.shortcut.setSearchSurfaceShortcuts.mockResolvedValue(undefined); nativeMock.runtime.getRuntimeInfo.mockResolvedValue({ isE2eTestMode: false }); nativeMock.window.hideSearchWindow.mockResolvedValue(undefined); nativeMock.window.setTrayStatusIndicator.mockResolvedValue(undefined); @@ -304,6 +307,74 @@ describe('useSearchPageLifecycle', () => { mounted.unmount(); }); + it('syncs search surface shortcuts and delegates host accelerator commands', async () => { + const controller = createController(); + const interactionContext = createSearchInteractionContext(); + const searchKeybindings = ref(createDefaultSearchKeybindings()); + const handleSearchSurfaceCommand = vi.fn().mockResolvedValue(undefined); + + const mounted = await mountComposable(() => + useSearchPageLifecycle({ + controller: controller as never, + viewReady: ref(true), + isDragging: ref(false), + isPinned: ref(false), + searchKeybindings, + interactionContext, + syncWindowPinState: vi.fn().mockResolvedValue(false), + clearSession: vi.fn(), + handleSearchSurfaceCommand, + }) + ); + + await flushLifecycle(); + + expect(nativeMock.shortcut.setSearchSurfaceShortcuts).toHaveBeenCalledWith( + expect.arrayContaining([ + { + actionId: 'search.model.toggle', + shortcut: 'Mod+M', + }, + { + actionId: 'search.settings.open', + shortcut: 'Mod+,', + }, + ]) + ); + + const commandHandler = eventHandlers.get(AppEvent.SEARCH_SURFACE_COMMAND); + expect(commandHandler).toBeDefined(); + await commandHandler!({ + actionId: 'search.model.toggle', + shortcut: 'Mod+M', + source: 'webview2-accelerator', + }); + await flushLifecycle(); + + expect(handleSearchSurfaceCommand).toHaveBeenCalledWith({ + actionId: 'search.model.toggle', + shortcut: 'Mod+M', + source: 'webview2-accelerator', + }); + + searchKeybindings.value = { + ...searchKeybindings.value, + 'search.model.toggle': null, + }; + await flushLifecycle(); + + expect(nativeMock.shortcut.setSearchSurfaceShortcuts).toHaveBeenLastCalledWith( + expect.not.arrayContaining([ + { + actionId: 'search.model.toggle', + shortcut: 'Mod+M', + }, + ]) + ); + + mounted.unmount(); + }); + it('does not send status notifications while the search surface is visible', async () => { const controller = createController(); const interactionContext = createSearchInteractionContext(); diff --git a/apps/desktop/tests/services/native-service.test.ts b/apps/desktop/tests/services/native-service.test.ts index 28e312d6..233b5afe 100644 --- a/apps/desktop/tests/services/native-service.test.ts +++ b/apps/desktop/tests/services/native-service.test.ts @@ -868,6 +868,25 @@ describe('NativeService supporting boundaries', () => { cmd: 'register_global_shortcut', payload: { shortcut: 'Ctrl+Shift+K' }, }, + { + name: 'syncs search surface shortcuts', + call: () => + shortcut.setSearchSurfaceShortcuts([ + { + actionId: 'search.model.toggle', + shortcut: 'Mod+M', + }, + ]), + cmd: 'set_search_surface_shortcuts', + payload: { + entries: [ + { + actionId: 'search.model.toggle', + shortcut: 'Mod+M', + }, + ], + }, + }, { name: 'enables autostart', call: () => autostart.enableAutostart(), diff --git a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts index 13cb82e1..c1da2ac5 100644 --- a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts @@ -558,6 +558,39 @@ describe('SettingsGeneralSection', () => { }); }); + it('rejects captured plus shortcuts instead of saving a disabled shortcut', async () => { + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: '+', + code: 'Equal', + ctrlKey: true, + shiftKey: true, + }) + ); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + expect((input.element as HTMLInputElement).value).toBe('Ctrl+H'); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1' })); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': 'F1', + }); + }); + it('shows fixed search shortcuts as unsupported for editing', async () => { const wrapper = mount(GeneralSection); From ceb291279a18a2f36020522b2a23a8cea812e297 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:04:53 +0800 Subject: [PATCH 40/55] fix(desktop): harden search shortcut accelerators --- .../src-tauri/src/core/system/shortcut.rs | 67 +++++++++++++-- .../src/core/window/webview_defaults.rs | 7 +- apps/desktop/src/utils/shortcuts.ts | 20 ++++- .../interaction/useSearchKeyboardRouter.ts | 46 ++++++++++- .../composables/searchInteraction.ts | 36 ++++++++- .../SearchView/composables/useSearchPage.ts | 29 ++++--- apps/desktop/src/views/SearchView/index.vue | 12 +-- .../General/SearchShortcutSettings.vue | 26 +++++- .../SearchView/searchInteraction.test.ts | 81 +++++++++++++++++++ .../useSearchKeyboardRouter.test.ts | 38 +++++++++ .../SearchView/useSearchPage.test.ts | 12 +++ .../tests/config/searchKeybindings.test.ts | 2 + apps/desktop/tests/utils/shortcuts.test.ts | 4 + .../settingsGeneralComponent.test.ts | 43 +++++++++- 14 files changed, 388 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src-tauri/src/core/system/shortcut.rs b/apps/desktop/src-tauri/src/core/system/shortcut.rs index 13f2c544..cf5b25fb 100644 --- a/apps/desktop/src-tauri/src/core/system/shortcut.rs +++ b/apps/desktop/src-tauri/src/core/system/shortcut.rs @@ -138,19 +138,30 @@ pub fn set_search_surface_shortcuts( entries: Vec, ) -> Result<(), String> { let mut parsed_entries = Vec::with_capacity(entries.len()); + let mut parse_errors = Vec::new(); for entry in entries { - parsed_entries.push(SearchSurfaceShortcut { - parsed: parse_shortcut(&entry.shortcut)?, - action_id: entry.action_id, - shortcut: entry.shortcut, - }); + match parse_shortcut(&entry.shortcut) { + Ok(parsed) => parsed_entries.push(SearchSurfaceShortcut { + parsed, + action_id: entry.action_id, + shortcut: entry.shortcut, + }), + Err(error) => parse_errors.push(format!("{}: {}", entry.action_id, error)), + } } let mut shortcuts = SEARCH_SURFACE_SHORTCUTS .lock() .map_err(|_| "Failed to lock search surface shortcuts".to_string())?; *shortcuts = parsed_entries; - Ok(()) + if parse_errors.is_empty() { + Ok(()) + } else { + Err(format!( + "Ignored unsupported search surface shortcuts: {}", + parse_errors.join("; ") + )) + } } pub fn find_search_surface_command_for_windows_accelerator( @@ -411,6 +422,9 @@ pub fn parse_shortcut(shortcut_str: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + static SEARCH_SURFACE_SHORTCUT_TEST_LOCK: Mutex<()> = Mutex::new(()); #[test] fn parse_shortcut_accepts_ctrl_alias() { @@ -488,6 +502,7 @@ mod tests { #[test] fn search_surface_command_matches_windows_accelerator() { + let _guard = SEARCH_SURFACE_SHORTCUT_TEST_LOCK.lock().expect("test lock"); set_search_surface_shortcuts(vec![ SearchSurfaceShortcutEntry { action_id: "search.model.toggle".to_string(), @@ -516,4 +531,44 @@ mod tests { ) .is_none()); } + + #[test] + fn search_surface_shortcut_sync_drops_invalid_entries_without_retaining_old_commands() { + let _guard = SEARCH_SURFACE_SHORTCUT_TEST_LOCK.lock().expect("test lock"); + set_search_surface_shortcuts(vec![SearchSurfaceShortcutEntry { + action_id: "search.model.toggle".to_string(), + shortcut: "Mod+M".to_string(), + }]) + .expect("initial shortcut sync"); + + let result = set_search_surface_shortcuts(vec![ + SearchSurfaceShortcutEntry { + action_id: "search.model.toggle".to_string(), + shortcut: "Mod+F13".to_string(), + }, + SearchSurfaceShortcutEntry { + action_id: "search.settings.open".to_string(), + shortcut: "Mod+,".to_string(), + }, + ]); + + assert!(result.is_err()); + assert!(find_search_surface_command_for_windows_accelerator( + 0x4D, + !cfg!(target_os = "macos"), + false, + false, + cfg!(target_os = "macos"), + ) + .is_none()); + let command = find_search_surface_command_for_windows_accelerator( + 0xBC, + !cfg!(target_os = "macos"), + false, + false, + cfg!(target_os = "macos"), + ) + .expect("valid shortcut remains synced"); + assert_eq!(command.action_id, "search.settings.open"); + } } diff --git a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs index f7bb8a94..d4002c48 100644 --- a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs +++ b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs @@ -209,6 +209,7 @@ fn register_system_menu_accelerator_handler( controller: &ICoreWebView2Controller, ) -> Result<(), String> { let app_handle = window.app_handle().clone(); + let search_surface_window = (window.label() == "main").then(|| window.clone()); let mut token = 0i64; let handler = AcceleratorKeyPressedEventHandler::create(Box::new( move |_controller: Option, @@ -229,6 +230,10 @@ fn register_system_menu_accelerator_handler( return Ok(()); } + let Some(search_surface_window) = &search_surface_window else { + return Ok(()); + }; + let is_ctrl_down = (GetKeyState(i32::from(VK_CONTROL.0)) as u16 & 0x8000) != 0; let is_alt_down = (GetKeyState(i32::from(VK_MENU.0)) as u16 & 0x8000) != 0; let is_shift_down = (GetKeyState(i32::from(VK_SHIFT.0)) as u16 & 0x8000) != 0; @@ -253,7 +258,7 @@ fn register_system_menu_accelerator_handler( let _ = args2.SetIsBrowserAcceleratorKeyEnabled(false); } let _ = args.SetHandled(true); - let _ = app_handle.emit("search-surface-command", command); + let _ = search_surface_window.emit("search-surface-command", command); return Ok(()); } diff --git a/apps/desktop/src/utils/shortcuts.ts b/apps/desktop/src/utils/shortcuts.ts index 16a3075d..0df696f6 100644 --- a/apps/desktop/src/utils/shortcuts.ts +++ b/apps/desktop/src/utils/shortcuts.ts @@ -14,6 +14,21 @@ export interface CapturedShortcutResult { const MODIFIER_DISPLAY_ORDER = ['Mod', 'Ctrl', 'Alt', 'Shift'] as const; const SUPPORTED_CAPTURE_MODIFIERS = new Set(['Ctrl', 'Alt', 'Shift', 'Mod']); +const SUPPORTED_CHARACTER_KEYS = new Set([ + ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''), + ...'0123456789'.split(''), + ',', + '.', + '=', + '-', + ';', + '/', + "'", + '`', + '[', + ']', + '\\', +]); const SUPPORTED_NON_CHARACTER_KEYS = new Set([ 'Backspace', 'Del', @@ -130,13 +145,14 @@ function normalizeShortcutToken(token: string): string | null { } if (trimmed.length === 1) { - return trimmed.toUpperCase(); + const normalizedCharacter = trimmed.toUpperCase(); + return SUPPORTED_CHARACTER_KEYS.has(normalizedCharacter) ? normalizedCharacter : null; } const functionKeyMatch = /^f(\d{1,2})$/i.exec(trimmed); if (functionKeyMatch) { const functionKeyNumber = Number(functionKeyMatch[1]); - return functionKeyNumber >= 1 && functionKeyNumber <= 24 ? trimmed.toUpperCase() : null; + return functionKeyNumber >= 1 && functionKeyNumber <= 12 ? trimmed.toUpperCase() : null; } if (SUPPORTED_NON_CHARACTER_KEYS.has(trimmed)) { diff --git a/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts b/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts index 2dd1d3e7..7bc10fdc 100644 --- a/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts +++ b/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts @@ -1,5 +1,5 @@ import type { SearchKeybindingActionId, SearchKeybindings } from '@/config/searchKeybindings'; -import { matchShortcut } from '@/utils/shortcuts'; +import { matchShortcut, normalizeLocalShortcutString } from '@/utils/shortcuts'; export type SearchPopupSurfaceType = 'model-dropdown-surface' | 'session-history-surface'; type SearchKeyboardSurface = 'search-surface' | SearchPopupSurfaceType; @@ -104,6 +104,28 @@ function resolveSearchKeybindingAction( return null; } +function resolveSearchKeybindingActionByShortcut( + shortcut: string, + keybindings: SearchKeybindings +): SearchKeybindingActionId | null { + const normalizedShortcut = normalizeLocalShortcutString(shortcut); + if (!normalizedShortcut) { + return null; + } + + for (const [actionId, candidate] of Object.entries(keybindings) as Array< + [SearchKeybindingActionId, string | null] + >) { + if (normalizeLocalShortcutString(candidate) !== normalizedShortcut) { + continue; + } + + return actionId; + } + + return null; +} + /** * 纯键盘语义路由器。 */ @@ -148,6 +170,24 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp onSearchKeybindingAction, } = options; + function routeSearchKeybindingAction(actionId: SearchKeybindingActionId) { + if (hasActivePopupWindowFocus()) { + return true; + } + + runKeyboardEffect(() => onSearchKeybindingAction(actionId)); + return true; + } + + function routeShortcut(shortcut: string) { + const actionId = resolveSearchKeybindingActionByShortcut(shortcut, getSearchKeybindings()); + if (!actionId) { + return false; + } + + return routeSearchKeybindingAction(actionId); + } + function route(input: SearchKeyboardRouteInput) { const queryText = getQueryText(); const pendingApproval = getPendingApproval(); @@ -178,8 +218,7 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp const searchKeybindingAction = resolveSearchKeybindingAction(input, getSearchKeybindings()); if (searchKeybindingAction) { - runKeyboardEffect(() => onSearchKeybindingAction(searchKeybindingAction)); - return true; + return routeSearchKeybindingAction(searchKeybindingAction); } if (input.key === 'Escape' || input.key === 'Esc') { @@ -331,5 +370,6 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp return { route, + routeShortcut, }; } diff --git a/apps/desktop/src/views/SearchView/composables/searchInteraction.ts b/apps/desktop/src/views/SearchView/composables/searchInteraction.ts index 74f7fd70..14d96cc6 100644 --- a/apps/desktop/src/views/SearchView/composables/searchInteraction.ts +++ b/apps/desktop/src/views/SearchView/composables/searchInteraction.ts @@ -143,6 +143,10 @@ export interface UseSearchKeyboardOptions { clearSession: () => void; } +export type SearchKeydownHandler = ((event: KeyboardEvent) => Promise) & { + routeSearchSurfaceShortcut: (shortcut: string) => boolean; +}; + function createEmptyModelOverride(): SearchModelOverride { return { modelId: null, @@ -644,7 +648,9 @@ export function useSearchOverlayMachine(options: UseSearchOverlayMachineOptions) /** * 创建 SearchView 页面级键盘处理器。 */ -export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { +export function createSearchKeydownHandler( + options: UseSearchKeyboardOptions +): SearchKeydownHandler { const { viewReady, searchKeybindings, @@ -821,7 +827,27 @@ export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { const askUserStore = useAskUserStore(); - return async function handleKeyDown(event: KeyboardEvent) { + function shouldSkipSearchKeyboardRouting() { + if (!viewReady.value) { + return true; + } + + if (askUserStore.current) { + return true; + } + + return controller.isQuickSearchContextMenuOpen(); + } + + function routeSearchSurfaceShortcut(shortcut: string) { + if (shouldSkipSearchKeyboardRouting()) { + return false; + } + + return keyboardRouter.routeShortcut(shortcut); + } + + async function handleKeyDown(event: KeyboardEvent) { if (!viewReady.value) { return; } @@ -877,5 +903,9 @@ export function createSearchKeydownHandler(options: UseSearchKeyboardOptions) { modelOverride.value = createEmptyModelOverride(); } } - }; + } + + return Object.assign(handleKeyDown, { + routeSearchSurfaceShortcut, + }); } diff --git a/apps/desktop/src/views/SearchView/composables/useSearchPage.ts b/apps/desktop/src/views/SearchView/composables/useSearchPage.ts index eae7aeae..f7aed5d3 100644 --- a/apps/desktop/src/views/SearchView/composables/useSearchPage.ts +++ b/apps/desktop/src/views/SearchView/composables/useSearchPage.ts @@ -643,12 +643,27 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { } await syncWindowPinStateSafely('initialize'); await initFocusListener(); + startSearchSurfaceShortcutSync(); if (!(await isE2eTestMode())) { await initNotificationPermission(); } await runStartupTasks(); } + function startSearchSurfaceShortcutSync() { + if (!searchKeybindings || stopSearchSurfaceShortcutWatch) { + return; + } + + stopSearchSurfaceShortcutWatch = watch( + searchKeybindings, + () => { + void syncSearchSurfaceShortcutsSafely(); + }, + { deep: true, immediate: true, flush: 'post' } + ); + } + async function initFocusListener() { unlistenAiModelsUpdated = await eventService.on(AppEvent.AI_MODELS_UPDATED, () => { if (!handleAiModelsUpdated) { @@ -767,16 +782,6 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { { immediate: true, flush: 'sync' } ); - if (searchKeybindings) { - stopSearchSurfaceShortcutWatch = watch( - searchKeybindings, - () => { - void syncSearchSurfaceShortcutsSafely(); - }, - { deep: true, immediate: true, flush: 'post' } - ); - } - if (viewReady.value) { void startLifecycleOnceReady(); return; @@ -1113,4 +1118,8 @@ export function useSearchKeyboard(options: UseSearchKeyboardOptions) { document.removeEventListener('mousedown', handleSearchWindowMouseDown, true); document.body.removeEventListener('click', handleSearchWindowClick); }); + + return { + routeSearchSurfaceShortcut: handleKeyDown.routeSearchSurfaceShortcut, + }; } diff --git a/apps/desktop/src/views/SearchView/index.vue b/apps/desktop/src/views/SearchView/index.vue index 0d27fbee..2503fca4 100644 --- a/apps/desktop/src/views/SearchView/index.vue +++ b/apps/desktop/src/views/SearchView/index.vue @@ -434,6 +434,8 @@ } } + let routeSearchSurfaceShortcut: ((shortcut: string) => boolean) | null = null; + const { hideSearchWindow } = useSearchPageLifecycle({ controller, viewReady, @@ -451,11 +453,8 @@ handleSessionStatusReminderAction, handleAiModelsUpdated, handleShortcutAutoPaste: tryShortcutAutoPaste, - handleSearchSurfaceCommand: async (payload) => { - if (!viewReady.value) { - return; - } - await handleSearchKeybindingAction(payload.actionId); + handleSearchSurfaceCommand: (payload) => { + routeSearchSurfaceShortcut?.(payload.shortcut); }, }); @@ -545,7 +544,7 @@ return 'navigated'; } - useSearchKeyboard({ + const searchKeyboard = useSearchKeyboard({ viewReady, searchKeybindings, queryText, @@ -584,6 +583,7 @@ cancelRequest, clearSession: clearSessionToIdle, }); + routeSearchSurfaceShortcut = searchKeyboard.routeSearchSurfaceShortcut; function handleQueryTextChange(value: string) { queryText.value = value; diff --git a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue index 4f80d206..245b6fe1 100644 --- a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue +++ b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue @@ -16,6 +16,7 @@ findShortcutConflict, formatShortcutForDisplay, hasCommandModifier, + isMacPlatform, isModifierlessFunctionShortcut, isReservedLocalShortcut, isReservedLocalShortcutKey, @@ -214,6 +215,12 @@ alertMessage.value?.error(t(messageKey, params), 3000); } + function isModifierOnlyKey(key: string) { + return ( + key === 'Control' || key === 'Alt' || key === 'Shift' || key === 'Meta' || key === 'OS' + ); + } + const captureSearchShortcut = (event: KeyboardEvent) => { const actionId = activeSearchShortcutActionId.value; if (!actionId) { @@ -226,9 +233,26 @@ const captured = captureShortcutFromKeyboardEvent(event); if (!captured) { - if (event.metaKey) { + if (isModifierOnlyKey(event.key)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (!isMacPlatform() && event.metaKey) { alertMessage.value?.warning(t('settings.general.winKeyUnsupported'), 3000); + return; } + + reportSearchShortcutError( + actionId, + 'settings.general.searchShortcuts.errors.unsupported' + ); + updateSearchShortcutDisplay( + actionId, + formatSearchShortcutForSettings(settings.value.searchKeybindings[actionId]) + ); return; } diff --git a/apps/desktop/tests/composables/SearchView/searchInteraction.test.ts b/apps/desktop/tests/composables/SearchView/searchInteraction.test.ts index cf3aad95..ea107cf5 100644 --- a/apps/desktop/tests/composables/SearchView/searchInteraction.test.ts +++ b/apps/desktop/tests/composables/SearchView/searchInteraction.test.ts @@ -72,6 +72,58 @@ function createControllerStub() { } satisfies SearchPageController; } +function createSearchKeydownHandlerForTest( + overrides: Partial[0]> = {} +) { + const controller = createControllerStub(); + return createSearchKeydownHandler({ + viewReady: ref(true), + searchKeybindings: ref(createDefaultSearchKeybindings()), + queryText: ref(''), + attachments: ref([]), + cursorContext: ref({ + isMultiLine: false, + cursorAtStart: true, + cursorAtTextStart: true, + cursorAtEnd: true, + }), + modelOverride: ref({ + modelId: null, + providerId: null, + }), + modelDropdownState: ref({ isOpen: false }), + controller, + sessionHistory: ref([]), + pendingRequest: ref(null), + isWaitingForCompletion: ref(false), + isLoading: ref(false), + pendingToolApproval: ref(null), + approvePendingToolApproval: vi.fn(() => false), + rejectPendingToolApproval: vi.fn(() => false), + promptPendingToolApprovalAttention: vi.fn(), + getActivePopupType: () => null, + hasActivePopupWindowFocus: () => false, + isQuickSearchOpen: computed(() => false), + shouldTriggerQuickSearch: () => false, + sessionHistoryPopupOpen: ref(false), + hideAllPopups: vi.fn().mockResolvedValue(undefined), + hideSearchWindow: vi.fn().mockResolvedValue(undefined), + navigateInputHistory: vi.fn(() => 'ignored' as const), + closeModelDropdown: vi.fn().mockResolvedValue(undefined), + toggleModelDropdown: vi.fn().mockResolvedValue(undefined), + openHistoryDialog: vi.fn().mockResolvedValue(undefined), + startNewSession: vi.fn().mockResolvedValue(undefined), + reopenLastClosedSession: vi.fn().mockResolvedValue(undefined), + toggleWindowPin: vi.fn().mockResolvedValue(undefined), + toggleWindowMaximize: vi.fn().mockResolvedValue(undefined), + openSettingsWindow: vi.fn().mockResolvedValue(undefined), + handleSubmit: vi.fn().mockResolvedValue(undefined), + cancelRequest: vi.fn(), + clearSession: vi.fn(), + ...overrides, + }); +} + describe('extractSessionInputHistoryEntries', () => { it('returns only user prompts that still have visible input history content', () => { const entries = extractSessionInputHistoryEntries([ @@ -295,6 +347,35 @@ describe('useQuickSearchCoordinator', () => { }); describe('createSearchKeydownHandler', () => { + it('routes host accelerator shortcuts through the same search keyboard guards', async () => { + const handleSearchKeybindingAction = vi.fn().mockResolvedValue(undefined); + const handleKeyDown = createSearchKeydownHandlerForTest({ + handleSearchKeybindingAction, + }); + + expect(handleKeyDown.routeSearchSurfaceShortcut('Mod+M')).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + + expect(handleSearchKeybindingAction).toHaveBeenCalledWith('search.model.toggle'); + }); + + it('ignores host accelerator shortcuts while the quick search context menu is open', async () => { + const controller = createControllerStub(); + controller.isQuickSearchContextMenuOpen.mockReturnValue(true); + const handleSearchKeybindingAction = vi.fn().mockResolvedValue(undefined); + const handleKeyDown = createSearchKeydownHandlerForTest({ + controller, + handleSearchKeybindingAction, + }); + + expect(handleKeyDown.routeSearchSurfaceShortcut('Mod+M')).toBe(false); + await Promise.resolve(); + await Promise.resolve(); + + expect(handleSearchKeybindingAction).not.toHaveBeenCalled(); + }); + it('routes the default F11 maximize shortcut to the maximize callback', async () => { const controller = createControllerStub(); const toggleWindowMaximize = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts b/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts index 899b6251..1e2a2d9f 100644 --- a/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts +++ b/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts @@ -180,6 +180,44 @@ describe('createSearchKeyboardRouter', () => { ); }); + it('routes host accelerator shortcuts through the current keybinding configuration', async () => { + const disabledRouter = createKeyboardRouter({ + getSearchKeybindings: () => ({ + ...createDefaultSearchKeybindings(), + 'search.model.toggle': null, + }), + }); + + expect(disabledRouter.router.routeShortcut('Mod+M')).toBe(false); + await flushAsyncWork(); + expect(disabledRouter.callbacks.onSearchKeybindingAction).not.toHaveBeenCalled(); + + const remappedRouter = createKeyboardRouter({ + getSearchKeybindings: () => ({ + ...createDefaultSearchKeybindings(), + 'search.history.open': 'Mod+M', + 'search.model.toggle': null, + }), + }); + + expect(remappedRouter.router.routeShortcut('Mod+M')).toBe(true); + await flushAsyncWork(); + expect(remappedRouter.callbacks.onSearchKeybindingAction).toHaveBeenCalledWith( + 'search.history.open' + ); + }); + + it('swallows host accelerator shortcuts while a popup window has focus', async () => { + const { router, callbacks } = createKeyboardRouter({ + hasActivePopupWindowFocus: () => true, + }); + + expect(router.routeShortcut('Mod+M')).toBe(true); + await flushAsyncWork(); + + expect(callbacks.onSearchKeybindingAction).not.toHaveBeenCalled(); + }); + it('routes function-row search shortcuts by keyboard code when the key value is remapped', async () => { const { router, callbacks } = createKeyboardRouter({ getSearchKeybindings: () => ({ diff --git a/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts b/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts index 1d145faf..87a26803 100644 --- a/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts +++ b/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts @@ -329,6 +329,18 @@ describe('useSearchPageLifecycle', () => { await flushLifecycle(); + const commandListenerCallIndex = eventServiceMock.on.mock.calls.findIndex( + ([eventName]) => eventName === AppEvent.SEARCH_SURFACE_COMMAND + ); + const commandListenerOrder = + eventServiceMock.on.mock.invocationCallOrder[commandListenerCallIndex]; + const firstShortcutSyncOrder = + nativeMock.shortcut.setSearchSurfaceShortcuts.mock.invocationCallOrder[0]; + expect(commandListenerCallIndex).toBeGreaterThanOrEqual(0); + expect(commandListenerOrder).toBeDefined(); + expect(firstShortcutSyncOrder).toBeDefined(); + expect(commandListenerOrder!).toBeLessThan(firstShortcutSyncOrder!); + expect(nativeMock.shortcut.setSearchSurfaceShortcuts).toHaveBeenCalledWith( expect.arrayContaining([ { diff --git a/apps/desktop/tests/config/searchKeybindings.test.ts b/apps/desktop/tests/config/searchKeybindings.test.ts index 3b536679..df14c6ab 100644 --- a/apps/desktop/tests/config/searchKeybindings.test.ts +++ b/apps/desktop/tests/config/searchKeybindings.test.ts @@ -112,6 +112,8 @@ describe('search keybinding configuration', () => { 'search.input.focus': false, 'search.model.toggle': 'Ctrl+DefinitelyNotAKey', 'search.window.pin': 'Ctrl+DefinitelyNotAKey+P', + 'search.window.maximize': 'F13', + 'search.settings.open': 'Ctrl+@', }) ).toEqual(defaults); }); diff --git a/apps/desktop/tests/utils/shortcuts.test.ts b/apps/desktop/tests/utils/shortcuts.test.ts index 3cfc6728..2c7f920f 100644 --- a/apps/desktop/tests/utils/shortcuts.test.ts +++ b/apps/desktop/tests/utils/shortcuts.test.ts @@ -48,6 +48,8 @@ describe('shortcut utilities', () => { expect(normalizeLocalShortcutString('Ctrl+DefinitelyNotAKey')).toBeNull(); expect(normalizeLocalShortcutString('Ctrl+DefinitelyNotAKey+A')).toBeNull(); expect(normalizeLocalShortcutString('Ctrl++A')).toBeNull(); + expect(normalizeLocalShortcutString('Ctrl+@')).toBeNull(); + expect(normalizeLocalShortcutString('F13')).toBeNull(); expect(normalizeLocalShortcutString('F25')).toBeNull(); expect(normalizeLocalShortcutString(' ')).toBeNull(); @@ -75,6 +77,8 @@ describe('shortcut utilities', () => { expect(resolveKeyboardEventShortcutKey('BrightnessUp', 'F2')).toBe('F2'); expect(resolveKeyboardEventShortcutKey('F2', 'F3')).toBe('F2'); expect(resolveKeyboardEventShortcutKey('BrightnessUp', 'F25')).toBeNull(); + expect(resolveKeyboardEventShortcutKey('BrightnessUp', 'F13')).toBeNull(); + expect(resolveKeyboardEventShortcutKey('@')).toBeNull(); expect(resolveKeyboardEventShortcutKey('x', 'Space')).toBe('X'); expect(resolveKeyboardEventShortcutKey('BrightnessUp', 'Space')).toBeNull(); expect(resolveKeyboardEventShortcutKey('', 'KeyA')).toBeNull(); diff --git a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts index c1da2ac5..d3a1e1a5 100644 --- a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts @@ -66,6 +66,12 @@ const nativeMock = vi.hoisted(() => ({ }, })); +const alertMessageMock = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), +})); + vi.mock('pinia', () => ({ storeToRefs: (store: typeof settingsStoreMock) => ({ settings: store.settings, @@ -89,9 +95,9 @@ vi.mock('@components/AlertMessage.vue', () => ({ name: 'AlertMessageStub', template: '
', methods: { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), + success: alertMessageMock.success, + error: alertMessageMock.error, + warning: alertMessageMock.warning, }, }, })); @@ -582,6 +588,12 @@ describe('SettingsGeneralSection', () => { expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); expect((input.element as HTMLInputElement).value).toBe('Ctrl+H'); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F13' })); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + expect((input.element as HTMLInputElement).value).toBe('Ctrl+H'); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1' })); await flushPromises(); @@ -591,6 +603,31 @@ describe('SettingsGeneralSection', () => { }); }); + it('reports unsupported mac command search shortcuts without showing the Windows key warning', async () => { + setPlatform('MacIntel'); + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + alertMessageMock.error.mockClear(); + alertMessageMock.warning.mockClear(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: '@', metaKey: true })); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + expect(alertMessageMock.warning).not.toHaveBeenCalled(); + expect(alertMessageMock.error).toHaveBeenCalledTimes(1); + expect((input.element as HTMLInputElement).value).toBe('Cmd+H'); + + wrapper.unmount(); + }); + it('shows fixed search shortcuts as unsupported for editing', async () => { const wrapper = mount(GeneralSection); From 403b7406b73ae768b1450a660290e8e0d999221c Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:04:06 +0800 Subject: [PATCH 41/55] fix(desktop): handle search shortcut conflicts --- .../src-tauri/src/core/system/shortcut.rs | 16 +++++ .../src/core/window/webview_defaults.rs | 24 ++++++++ .../BuiltInToolService/tools/setting/index.ts | 51 +++++++++++++++- .../General/SearchShortcutSettings.vue | 59 ++++++++++++++++++- .../tools/setting/i18n.test.ts | 30 ++++++++++ .../settingsGeneralComponent.test.ts | 28 +++++++++ 6 files changed, 205 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/core/system/shortcut.rs b/apps/desktop/src-tauri/src/core/system/shortcut.rs index cf5b25fb..a3bb36ba 100644 --- a/apps/desktop/src-tauri/src/core/system/shortcut.rs +++ b/apps/desktop/src-tauri/src/core/system/shortcut.rs @@ -532,6 +532,22 @@ mod tests { .is_none()); } + #[test] + fn search_surface_command_matches_alt_space_windows_system_accelerator() { + let _guard = SEARCH_SURFACE_SHORTCUT_TEST_LOCK.lock().expect("test lock"); + set_search_surface_shortcuts(vec![SearchSurfaceShortcutEntry { + action_id: "search.model.toggle".to_string(), + shortcut: "Alt+Space".to_string(), + }]) + .expect("shortcuts sync"); + + let command = + find_search_surface_command_for_windows_accelerator(0x20, false, true, false, false) + .expect("alt+space shortcut matches"); + assert_eq!(command.action_id, "search.model.toggle"); + assert_eq!(command.shortcut, "Alt+Space"); + } + #[test] fn search_surface_shortcut_sync_drops_invalid_entries_without_retaining_old_commands() { let _guard = SEARCH_SURFACE_SHORTCUT_TEST_LOCK.lock().expect("test lock"); diff --git a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs index d4002c48..2f0ab928 100644 --- a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs +++ b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs @@ -264,6 +264,30 @@ fn register_system_menu_accelerator_handler( let is_ctrl_down = (GetKeyState(i32::from(VK_CONTROL.0)) as u16 & 0x8000) != 0; let is_shift_down = (GetKeyState(i32::from(VK_SHIFT.0)) as u16 & 0x8000) != 0; + let is_super_down = (GetKeyState(i32::from(VK_LWIN.0)) as u16 & 0x8000) != 0 + || (GetKeyState(i32::from(VK_RWIN.0)) as u16 & 0x8000) != 0; + + if let Some(search_surface_window) = &search_surface_window { + if let Some(command) = + crate::core::system::shortcut::find_search_surface_command_for_windows_accelerator( + virtual_key, + is_ctrl_down, + true, + is_shift_down, + is_super_down, + ) + { + if let Ok(args2) = + Interface::cast::(&args) + { + let _ = args2.SetIsBrowserAcceleratorKeyEnabled(false); + } + let _ = args.SetHandled(true); + let _ = search_surface_window.emit("search-surface-command", command); + return Ok(()); + } + } + log::info!( "[sysmenu-accel] Alt+Space detected, emitting shortcut-capture-system-key (ctrl={} shift={})", is_ctrl_down, diff --git a/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts index bd60fb60..634a318a 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts @@ -2,10 +2,15 @@ import { native } from '@services/NativeService'; +import { + getSearchKeybindingDefinition, + type SearchKeybindingActionId, +} from '@/config/searchKeybindings'; import { resolveSearchWindowDefaultSize } from '@/config/searchWindow'; -import { tt } from '@/i18n'; +import { t, tt } from '@/i18n'; import type { ToolApprovalRequest } from '@/services/AgentService/contracts/tooling'; import type { GeneralSettingsData } from '@/stores/settings'; +import { normalizeLocalShortcutString } from '@/utils/shortcuts'; import { truncateText } from '@/utils/text'; import { @@ -70,10 +75,15 @@ function buildSettingConversationSemantic( } async function applySettingSideEffect( + settingsStore: SettingsStore, key: SupportedSettingKey, value: SupportedSettingValue ): Promise { if (key === 'global_shortcut') { + ensureGlobalShortcutDoesNotConflictWithSearchShortcuts( + settingsStore, + value as GeneralSettingsData['globalShortcut'] + ); try { await native.shortcut.registerGlobalShortcut( value as GeneralSettingsData['globalShortcut'] @@ -107,6 +117,43 @@ async function applySettingSideEffect( } } +function findGlobalShortcutSearchConflict( + settingsStore: SettingsStore, + shortcut: GeneralSettingsData['globalShortcut'] +): SearchKeybindingActionId | null { + const normalizedShortcut = normalizeLocalShortcutString(shortcut); + if (!normalizedShortcut) { + return null; + } + + const searchKeybindings = settingsStore.settings.searchKeybindings ?? {}; + for (const [actionId, searchShortcut] of Object.entries(searchKeybindings) as Array< + [SearchKeybindingActionId, string | null] + >) { + if (normalizeLocalShortcutString(searchShortcut) === normalizedShortcut) { + return actionId; + } + } + + return null; +} + +function ensureGlobalShortcutDoesNotConflictWithSearchShortcuts( + settingsStore: SettingsStore, + shortcut: GeneralSettingsData['globalShortcut'] +): void { + const conflictActionId = findGlobalShortcutSearchConflict(settingsStore, shortcut); + if (!conflictActionId) { + return; + } + + throw new Error( + t('settings.general.searchShortcuts.errors.duplicate', { + action: t(getSearchKeybindingDefinition(conflictActionId).labelKey), + }) + ); +} + async function persistSettingValue( settingsStore: SettingsStore, key: SupportedSettingKey, @@ -149,7 +196,7 @@ async function applySettingUpdate( key: SupportedSettingKey, value: SupportedSettingValue ): Promise { - await applySettingSideEffect(key, value); + await applySettingSideEffect(settingsStore, key, value); await persistSettingValue(settingsStore, key, value); } diff --git a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue index 245b6fe1..c303f4bd 100644 --- a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue +++ b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue @@ -1,8 +1,14 @@ diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/setting/i18n.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/setting/i18n.test.ts index 89ae4c1f..4dccf490 100644 --- a/apps/desktop/tests/services/BuiltInToolService/tools/setting/i18n.test.ts +++ b/apps/desktop/tests/services/BuiltInToolService/tools/setting/i18n.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createDefaultSearchKeybindings } from '@/config/searchKeybindings'; import { setLocale } from '@/i18n'; import { buildSettingApprovalRequest, @@ -30,6 +31,7 @@ const { mockSettingsStore: { settings: { globalShortcut: 'Alt+Space', + searchKeybindings: {}, startOnBoot: false, startMinimized: true, outputScrollBehavior: 'follow_output', @@ -68,6 +70,7 @@ vi.mock('@services/NativeService', () => ({ const settingsStore = { settings: { globalShortcut: 'Alt+Space', + searchKeybindings: {}, startOnBoot: false, startMinimized: true, outputScrollBehavior: 'follow_output', @@ -92,6 +95,7 @@ describe('Setting built-in tool i18n', () => { mockUpdateGlobalShortcut.mockResolvedValue(undefined); mockUpdateLanguage.mockResolvedValue(undefined); mockSettingsStore.settings.globalShortcut = 'Alt+Space'; + mockSettingsStore.settings.searchKeybindings = createDefaultSearchKeybindings(); mockSettingsStore.settings.language = 'zh-CN'; }); @@ -193,6 +197,32 @@ describe('Setting built-in tool i18n', () => { ); }); + it('rejects global shortcuts that duplicate search shortcuts before registration', async () => { + setLocale('en-US'); + mockSettingsStore.settings.searchKeybindings = { + ...createDefaultSearchKeybindings(), + 'search.history.open': 'Mod+K', + }; + + const result = await executeSettingTool( + { + action: 'set', + key: 'global_shortcut', + value: 'Ctrl+K', + reason: 'User asked for it.', + }, + {}, + createExecutionContext() + ); + + expect(result).toMatchObject({ + isError: true, + status: 'error', + }); + expect(result.errorMessage).toContain('Open session history'); + expect(mockRegisterGlobalShortcut).not.toHaveBeenCalledWith('Ctrl+K'); + }); + it('formats failed rollback in English', async () => { setLocale('en-US'); mockRegisterGlobalShortcut diff --git a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts index d3a1e1a5..0fcfaeb1 100644 --- a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts @@ -4,6 +4,7 @@ import { vi } from 'vitest'; import { createDefaultSearchKeybindings } from '@/config/searchKeybindings'; import { setLocale } from '@/i18n'; +import { AppEvent, eventService } from '@/services/EventService'; import GeneralSection from '@/views/SettingsView/components/General/index.vue'; const originalPlatform = navigator.platform; @@ -492,6 +493,33 @@ describe('SettingsGeneralSection', () => { }); }); + it('captures Windows system key events while editing a search shortcut', async () => { + settingsStoreMock.settings.value.globalShortcut = 'Ctrl+Space'; + settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + + await eventService.emit(AppEvent.SHORTCUT_CAPTURE_SYSTEM_KEY, { + key: 'Space', + alt: true, + ctrl: false, + shift: false, + }); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': 'Alt+Space', + }); + }); + it('captures a function-row key by keyboard code when the key value is not an F-key', async () => { settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; const wrapper = mount(GeneralSection); From f6f0cb7a9715387ecb7ef970096fd08177b7173e Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:25:59 +0800 Subject: [PATCH 42/55] fix(desktop): normalize function key shortcuts --- apps/desktop/src/utils/shortcuts.ts | 2 +- apps/desktop/tests/config/searchKeybindings.test.ts | 2 +- apps/desktop/tests/utils/shortcuts.test.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/utils/shortcuts.ts b/apps/desktop/src/utils/shortcuts.ts index 0df696f6..1bcf3e7f 100644 --- a/apps/desktop/src/utils/shortcuts.ts +++ b/apps/desktop/src/utils/shortcuts.ts @@ -152,7 +152,7 @@ function normalizeShortcutToken(token: string): string | null { const functionKeyMatch = /^f(\d{1,2})$/i.exec(trimmed); if (functionKeyMatch) { const functionKeyNumber = Number(functionKeyMatch[1]); - return functionKeyNumber >= 1 && functionKeyNumber <= 12 ? trimmed.toUpperCase() : null; + return functionKeyNumber >= 1 && functionKeyNumber <= 12 ? `F${functionKeyNumber}` : null; } if (SUPPORTED_NON_CHARACTER_KEYS.has(trimmed)) { diff --git a/apps/desktop/tests/config/searchKeybindings.test.ts b/apps/desktop/tests/config/searchKeybindings.test.ts index df14c6ab..edc18a5a 100644 --- a/apps/desktop/tests/config/searchKeybindings.test.ts +++ b/apps/desktop/tests/config/searchKeybindings.test.ts @@ -55,7 +55,7 @@ describe('search keybinding configuration', () => { 'search.session.new': ' ', 'search.session.reopenLastClosed': 'Mod+Up', 'search.model.toggle': 'Ctrl+Backspace', - 'search.window.maximize': 'f2', + 'search.window.maximize': 'f02', 'search.settings.open': 'ctrl + ,', unknown: 'Alt+U', }) diff --git a/apps/desktop/tests/utils/shortcuts.test.ts b/apps/desktop/tests/utils/shortcuts.test.ts index 2c7f920f..4432914e 100644 --- a/apps/desktop/tests/utils/shortcuts.test.ts +++ b/apps/desktop/tests/utils/shortcuts.test.ts @@ -41,6 +41,8 @@ describe('shortcut utilities', () => { expect(normalizeLocalShortcutString('return')).toBe('Enter'); expect(normalizeLocalShortcutString('tab')).toBe('Tab'); expect(normalizeLocalShortcutString('insert')).toBe('Insert'); + expect(normalizeLocalShortcutString('F01')).toBe('F1'); + expect(normalizeLocalShortcutString('Ctrl+F09')).toBe('Mod+F9'); expect(normalizeLocalShortcutString('f12')).toBe('F12'); expect(normalizeLocalShortcutString(null)).toBeNull(); expect(normalizeLocalShortcutString('Ctrl+Alt')).toBeNull(); From 94413d895513d75c2c9edc01cd5e65e8eb33d579 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:02:25 +0800 Subject: [PATCH 43/55] fix(desktop): tolerate settings default persistence failures --- apps/desktop/src/stores/settings.ts | 4 +-- .../tests/stores/settings-keybindings.test.ts | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/stores/settings.ts b/apps/desktop/src/stores/settings.ts index 55cb8838..e345b40d 100644 --- a/apps/desktop/src/stores/settings.ts +++ b/apps/desktop/src/stores/settings.ts @@ -96,8 +96,8 @@ export const useSettingsStore = defineStore('settings', () => { : [] ); if (failedPersistenceKeys.length > 0) { - throw new Error( - `Failed to persist general setting rewrite/default value(s): ${failedPersistenceKeys.join( + console.warn( + `[SettingsStore] Failed to persist general setting rewrite/default value(s): ${failedPersistenceKeys.join( ', ' )}` ); diff --git a/apps/desktop/tests/stores/settings-keybindings.test.ts b/apps/desktop/tests/stores/settings-keybindings.test.ts index ce10f499..6ec27ea5 100644 --- a/apps/desktop/tests/stores/settings-keybindings.test.ts +++ b/apps/desktop/tests/stores/settings-keybindings.test.ts @@ -77,6 +77,35 @@ describe('settings search keybindings state', () => { }); }); + it('keeps initialization usable when default keybinding persistence fails', async () => { + mockSettings({}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + setSettingMock.mockImplementation( + async ({ key, value }: { key: string; value: string }) => { + if (key === 'search_keybindings') { + throw new Error('database locked'); + } + + return { + id: 1, + key, + value, + created_at: '2026-06-03 00:00:00', + updated_at: '2026-06-03 00:00:00', + }; + } + ); + + const { useSettingsStore } = await import('@/stores/settings'); + const store = useSettingsStore(); + + await expect(store.initialize()).resolves.toBeUndefined(); + + expect(store.initialized).toBe(true); + expect(store.settings.searchKeybindings).toEqual(createDefaultSearchKeybindings()); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('search_keybindings')); + }); + it('loads persisted search keybindings and merges missing defaults', async () => { mockSettings({ search_keybindings: JSON.stringify({ From 0648a548279d123239341b6df3e93726e8437359 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:03:29 +0800 Subject: [PATCH 44/55] fix(desktop): make search shortcuts configurable --- README.en.md | 2 +- README.md | 2 +- .../src-tauri/src/core/system/shortcut.rs | 9 ++++ apps/desktop/src/config/searchKeybindings.ts | 9 ++++ apps/desktop/src/i18n/messages.ts | 6 +++ apps/desktop/src/i18n/textMap.ts | 4 +- .../src/services/PopupService/manager.ts | 2 +- .../src/services/PopupService/types.ts | 2 + .../components/ModelDropdownPopup/index.vue | 3 +- .../SessionHistoryPopover/index.vue | 4 +- .../interaction/useSearchKeyboardRouter.ts | 19 ++++--- .../composables/searchInteraction.ts | 8 +-- .../SearchView/composables/useSearchPage.ts | 3 ++ apps/desktop/src/views/SearchView/index.vue | 7 +++ .../General/SearchShortcutSettings.vue | 6 ++- .../PopupView/model-dropdown-i18n.test.ts | 50 +++++++++++++++++++ .../PopupView/session-history-i18n.test.ts | 44 ++++++++++++++++ .../useSearchKeyboardRouter.test.ts | 38 ++++++++++++-- .../tests/config/searchKeybindings.test.ts | 1 + .../touchai-intro-en/touchai-components.html | 4 +- .../touchai-intro/touchai-components.html | 4 +- 21 files changed, 199 insertions(+), 28 deletions(-) diff --git a/README.en.md b/README.en.md index 491859bb..1679a5ed 100644 --- a/README.en.md +++ b/README.en.md @@ -33,7 +33,7 @@ ### Features -- **One shortcut away** - `Alt+Space` appears and disappears without interrupting your workflow +- **One shortcut away** - use the default global shortcut, or customize it to fit your workflow - **Full keyboard operation** - operate entirely without a mouse for much higher efficiency - **Desktop context** - aware of files, screen, and clipboard - **Tool support** - includes 7 built-in tools and supports MCP tool extensions diff --git a/README.md b/README.md index 28c30626..6c3c0edc 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ ### 功能特性 -- **一触即达** - `Alt+Space` 召之即来挥之即去,不打断工作流 +- **一触即达** - 默认全局快捷键可唤起/隐藏,也可按习惯自定义,不打断工作流 - **全键盘操作** - 支持无鼠标全键盘操作,极大提升效率 - **桌面上下文** - 文件、屏幕、剪贴板智能感知 - **工具支持** - 内置7大工具,支持MCP工具拓展 diff --git a/apps/desktop/src-tauri/src/core/system/shortcut.rs b/apps/desktop/src-tauri/src/core/system/shortcut.rs index a3bb36ba..f7dde53e 100644 --- a/apps/desktop/src-tauri/src/core/system/shortcut.rs +++ b/apps/desktop/src-tauri/src/core/system/shortcut.rs @@ -325,6 +325,9 @@ pub fn parse_shortcut(shortcut_str: &str) -> Result { } } key => { + if key_code.is_some() { + return Err("Shortcut must contain exactly one key code".to_string()); + } key_code = Some(match key.to_lowercase().as_str() { "space" => Code::Space, "enter" | "return" => Code::Enter, @@ -500,6 +503,12 @@ mod tests { assert_eq!(shortcut.key, Code::Equal); } + #[test] + fn parse_shortcut_rejects_multiple_key_codes() { + let error = parse_shortcut("Ctrl+A+B").expect_err("multiple keys should be rejected"); + assert_eq!(error, "Shortcut must contain exactly one key code"); + } + #[test] fn search_surface_command_matches_windows_accelerator() { let _guard = SEARCH_SURFACE_SHORTCUT_TEST_LOCK.lock().expect("test lock"); diff --git a/apps/desktop/src/config/searchKeybindings.ts b/apps/desktop/src/config/searchKeybindings.ts index 517eb0d7..67180dd5 100644 --- a/apps/desktop/src/config/searchKeybindings.ts +++ b/apps/desktop/src/config/searchKeybindings.ts @@ -12,6 +12,7 @@ export const SEARCH_KEYBINDING_ACTION_IDS = [ 'search.session.new', 'search.session.reopenLastClosed', 'search.model.toggle', + 'search.quickSearch.toggleView', 'search.window.pin', 'search.window.maximize', 'search.settings.open', @@ -71,6 +72,14 @@ export const SEARCH_KEYBINDING_DEFINITIONS: SearchKeybindingDefinition[] = [ allowDisable: true, allowModifierlessFunctionShortcut: true, }, + { + id: 'search.quickSearch.toggleView', + labelKey: 'settings.general.searchActions.quickSearchToggleView', + descriptionKey: 'settings.general.searchActionDescriptions.quickSearchToggleView', + defaultShortcut: 'Mod+G', + allowDisable: true, + allowModifierlessFunctionShortcut: true, + }, { id: 'search.window.pin', labelKey: 'settings.general.searchActions.windowPin', diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index 710b18e9..e185a265 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -101,6 +101,7 @@ const zhCNMessages = { 'settings.general.searchActions.newSession': '开始新会话', 'settings.general.searchActions.reopenLastClosedSession': '打开最近关闭的会话', 'settings.general.searchActions.modelToggle': '切换模型选择', + 'settings.general.searchActions.quickSearchToggleView': '切换快捷搜索视图', 'settings.general.searchActions.windowPin': '切换窗口置顶', 'settings.general.searchActions.windowMaximize': '切换窗口最大化', 'settings.general.searchActions.openSettings': '打开设置', @@ -117,6 +118,8 @@ const zhCNMessages = { 'settings.general.searchActionDescriptions.reopenLastClosedSession': '重新打开最近关闭的一条会话', 'settings.general.searchActionDescriptions.modelToggle': '快速打开或收起模型列表', + 'settings.general.searchActionDescriptions.quickSearchToggleView': + '在快捷搜索打开时切换结果视图', 'settings.general.searchActionDescriptions.windowPin': '切换搜索窗口的置顶状态', 'settings.general.searchActionDescriptions.windowMaximize': '切换搜索窗口最大化', 'settings.general.searchActionDescriptions.openSettings': '快速打开设置窗口', @@ -1067,6 +1070,7 @@ const enUSMessages: Record = { 'settings.general.searchActions.newSession': 'Start new session', 'settings.general.searchActions.reopenLastClosedSession': 'Open most recently closed session', 'settings.general.searchActions.modelToggle': 'Toggle model picker', + 'settings.general.searchActions.quickSearchToggleView': 'Toggle quick search view', 'settings.general.searchActions.windowPin': 'Toggle window pin', 'settings.general.searchActions.windowMaximize': 'Toggle window maximize', 'settings.general.searchActions.openSettings': 'Open settings', @@ -1083,6 +1087,8 @@ const enUSMessages: Record = { 'settings.general.searchActionDescriptions.reopenLastClosedSession': 'Reopen the most recently closed session.', 'settings.general.searchActionDescriptions.modelToggle': 'Open or close the model picker.', + 'settings.general.searchActionDescriptions.quickSearchToggleView': + 'Switch the result view while quick search is open.', 'settings.general.searchActionDescriptions.windowPin': 'Toggle whether the search window stays on top.', 'settings.general.searchActionDescriptions.windowMaximize': diff --git a/apps/desktop/src/i18n/textMap.ts b/apps/desktop/src/i18n/textMap.ts index 59f02b34..6024efd3 100644 --- a/apps/desktop/src/i18n/textMap.ts +++ b/apps/desktop/src/i18n/textMap.ts @@ -667,8 +667,8 @@ export const zhToEnTextMap = { 加载设置失败: 'Failed to load settings', 快捷键保存成功: 'Shortcut saved', 搜索窗口尺寸已更新: 'Search window size updated', - '不支持 Win 键组合,请使用 Ctrl、Alt、Shift': - 'Win key combinations are not supported. Use Ctrl, Alt, or Shift.', + '不支持 Win/Super 键组合,请使用 Ctrl、Alt、Shift': + 'Win/Super key combinations are not supported. Use Ctrl, Alt, or Shift.', '确定要删除服务器 "{serverName}" 吗?': 'Delete server "{serverName}"?', 标准输入输出: 'Standard input/output', '兼容Streamable HTTP与SSE': 'Compatible with Streamable HTTP and SSE', diff --git a/apps/desktop/src/services/PopupService/manager.ts b/apps/desktop/src/services/PopupService/manager.ts index fa14b710..7fe9f061 100644 --- a/apps/desktop/src/services/PopupService/manager.ts +++ b/apps/desktop/src/services/PopupService/manager.ts @@ -128,7 +128,7 @@ export class PopupManager { await this.ensureReadyListener(); await this.syncPopupConfigs(); - // 让初始化真正等待 popup 预热完成,避免首次 Ctrl+H 还在走冷启动链路。 + // 让初始化真正等待 popup 预热完成,避免首次快捷键打开弹窗还在走冷启动链路。 await this.transport.preloadWindows(); this.isInitialized = true; diff --git a/apps/desktop/src/services/PopupService/types.ts b/apps/desktop/src/services/PopupService/types.ts index 73f6c804..bcba5a6c 100644 --- a/apps/desktop/src/services/PopupService/types.ts +++ b/apps/desktop/src/services/PopupService/types.ts @@ -79,6 +79,7 @@ export interface ModelDropdownData { selectedModelId: string; selectedProviderId: number | null; searchQuery: string; + toggleShortcut?: string | null; models?: ModelDropdownPopupItem[]; } @@ -110,6 +111,7 @@ export interface SessionHistoryData { activeSessionId: number | null; searchQuery: string; isLoading: boolean; + toggleShortcut?: string | null; } export type PopupData = ModelDropdownData | SessionHistoryData; diff --git a/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue b/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue index 24869ae7..cf1b55b8 100644 --- a/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue +++ b/apps/desktop/src/views/PopupView/components/ModelDropdownPopup/index.vue @@ -138,6 +138,7 @@ import { computed, nextTick, ref, watch } from 'vue'; import { t } from '@/i18n'; + import { matchShortcut } from '@/utils/shortcuts'; defineOptions({ name: 'PopupModelDropdown', @@ -244,7 +245,7 @@ }; function handleKeyDown(event: KeyboardEvent) { - if (event.ctrlKey && event.key.toLowerCase() === 'm') { + if (matchShortcut(props.data?.toggleShortcut, event)) { event.preventDefault(); emit('close'); return; diff --git a/apps/desktop/src/views/PopupView/components/SessionHistoryPopover/index.vue b/apps/desktop/src/views/PopupView/components/SessionHistoryPopover/index.vue index 387a918d..0d23d18d 100644 --- a/apps/desktop/src/views/PopupView/components/SessionHistoryPopover/index.vue +++ b/apps/desktop/src/views/PopupView/components/SessionHistoryPopover/index.vue @@ -188,6 +188,7 @@ import { type MessageKey, t } from '@/i18n'; import { formatMonthDay, formatTime } from '@/i18n/format'; + import { matchShortcut } from '@/utils/shortcuts'; defineOptions({ name: 'SessionHistoryPopover', @@ -706,8 +707,7 @@ } function handleKeyDown(event: KeyboardEvent) { - // Ctrl+H 关闭弹窗 - if (event.ctrlKey && event.key === 'h') { + if (matchShortcut(props.data?.toggleShortcut, event)) { event.preventDefault(); emit('close'); return; diff --git a/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts b/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts index 7bc10fdc..22f5307c 100644 --- a/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts +++ b/apps/desktop/src/views/SearchView/composables/interaction/useSearchKeyboardRouter.ts @@ -49,7 +49,6 @@ interface CreateSearchKeyboardRouterOptions { onQuickSearchPageUp: () => void; onQuickSearchPageDown: () => void; onQuickSearchContextMenu: () => void; - onQuickSearchToggleView: () => void; onQuickSearchCollapse: () => void; onNavigateInputHistory: ( direction: SessionInputHistoryDirection @@ -158,7 +157,6 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp onQuickSearchPageUp, onQuickSearchPageDown, onQuickSearchContextMenu, - onQuickSearchToggleView, onQuickSearchCollapse, onNavigateInputHistory, onHideAllPopups, @@ -170,11 +168,23 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp onSearchKeybindingAction, } = options; + function canRouteSearchKeybindingAction(actionId: SearchKeybindingActionId) { + if (actionId === 'search.quickSearch.toggleView') { + return isQuickSearchOpen(); + } + + return true; + } + function routeSearchKeybindingAction(actionId: SearchKeybindingActionId) { if (hasActivePopupWindowFocus()) { return true; } + if (!canRouteSearchKeybindingAction(actionId)) { + return false; + } + runKeyboardEffect(() => onSearchKeybindingAction(actionId)); return true; } @@ -280,11 +290,6 @@ export function createSearchKeyboardRouter(options: CreateSearchKeyboardRouterOp return true; } - if (input.key.toLowerCase() === 'g' && (input.ctrlKey || input.metaKey)) { - onQuickSearchToggleView(); - return true; - } - if (hasQuickSearchHighlight()) { const directionMap: Partial> = { ArrowUp: 'up', diff --git a/apps/desktop/src/views/SearchView/composables/searchInteraction.ts b/apps/desktop/src/views/SearchView/composables/searchInteraction.ts index 14d96cc6..05ecdf08 100644 --- a/apps/desktop/src/views/SearchView/composables/searchInteraction.ts +++ b/apps/desktop/src/views/SearchView/composables/searchInteraction.ts @@ -756,9 +756,6 @@ export function createSearchKeydownHandler( onQuickSearchContextMenu: () => { controller.openQuickSearchContextMenu(); }, - onQuickSearchToggleView: () => { - controller.toggleQuickSearchView(); - }, onQuickSearchCollapse: () => { controller.collapseQuickSearch(); }, @@ -808,6 +805,11 @@ export function createSearchKeydownHandler( case 'search.model.toggle': await toggleModelDropdown(); return; + case 'search.quickSearch.toggleView': + if (isQuickSearchOpen.value) { + controller.toggleQuickSearchView(); + } + return; case 'search.window.pin': await toggleWindowPin(); return; diff --git a/apps/desktop/src/views/SearchView/composables/useSearchPage.ts b/apps/desktop/src/views/SearchView/composables/useSearchPage.ts index f7aed5d3..6bac2dee 100644 --- a/apps/desktop/src/views/SearchView/composables/useSearchPage.ts +++ b/apps/desktop/src/views/SearchView/composables/useSearchPage.ts @@ -837,6 +837,7 @@ interface UseSearchModelDropdownCoordinatorOptions { modelOverride: Ref; modelDropdownState: Ref; modelDropdownQuery: Ref; + getModelToggleShortcut?: () => string | null | undefined; requestModelDropdownOpen: () => SearchOverlayCommand; handleQuickSearchClosedForModelDropdown: () => SearchOverlayCommand; handleLayoutStableForModelDropdown: () => SearchOverlayCommand; @@ -856,6 +857,7 @@ export function useSearchModelDropdownCoordinator( modelOverride, modelDropdownState, modelDropdownQuery, + getModelToggleShortcut, requestModelDropdownOpen, handleQuickSearchClosedForModelDropdown, handleLayoutStableForModelDropdown, @@ -874,6 +876,7 @@ export function useSearchModelDropdownCoordinator( selectedModelId: context.selectedModelId ?? '', selectedProviderId: context.selectedProviderId, searchQuery: modelDropdownQuery.value, + toggleShortcut: getModelToggleShortcut?.() ?? null, models: filterModelDropdownItems( context.models.filter((model) => model.provider_enabled === 1).map(mapPopupModel), modelDropdownQuery.value diff --git a/apps/desktop/src/views/SearchView/index.vue b/apps/desktop/src/views/SearchView/index.vue index 2503fca4..22e4c156 100644 --- a/apps/desktop/src/views/SearchView/index.vue +++ b/apps/desktop/src/views/SearchView/index.vue @@ -371,6 +371,7 @@ modelOverride, modelDropdownState, modelDropdownQuery, + getModelToggleShortcut: () => searchKeybindings.value['search.model.toggle'], requestModelDropdownOpen, handleQuickSearchClosedForModelDropdown, handleLayoutStableForModelDropdown, @@ -418,6 +419,11 @@ case 'search.model.toggle': await handleToggleModelDropdownRequest(); return; + case 'search.quickSearch.toggleView': + if (isQuickSearchOpen.value) { + controller.toggleQuickSearchView(); + } + return; case 'search.window.pin': await handleToggleWindowPin(); return; @@ -470,6 +476,7 @@ activeSessionId: currentSessionId.value, searchQuery: sessionListQuery.value, isLoading: isSessionListLoading.value, + toggleShortcut: searchKeybindings.value['search.history.open'], }; } diff --git a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue index c303f4bd..b73867ba 100644 --- a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue +++ b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue @@ -173,7 +173,11 @@ { id: 'inputAndRequest', title: t('settings.general.searchShortcutGroups.inputAndRequest'), - actionIds: ['search.input.focus', 'search.model.toggle'], + actionIds: [ + 'search.input.focus', + 'search.model.toggle', + 'search.quickSearch.toggleView', + ], }, { id: 'window', diff --git a/apps/desktop/tests/PopupView/model-dropdown-i18n.test.ts b/apps/desktop/tests/PopupView/model-dropdown-i18n.test.ts index 7c86911e..9cb06cd8 100644 --- a/apps/desktop/tests/PopupView/model-dropdown-i18n.test.ts +++ b/apps/desktop/tests/PopupView/model-dropdown-i18n.test.ts @@ -126,4 +126,54 @@ describe('ModelDropdownPopup i18n', () => { expect(normalizedText).toContain('Configure models in Settings first'); expect(normalizedText).not.toContain('Configure models inSettingsfirst'); }); + + it('uses the configured toggle shortcut and ignores the old default', () => { + const wrapper = mount(ModelDropdownPopup, { + props: { + data: { + activeModelId: '', + activeProviderId: null, + selectedModelId: '', + selectedProviderId: null, + searchQuery: '', + toggleShortcut: 'Alt+M', + models: [], + }, + isInPopup: true, + }, + }); + const handleKeyDown = ( + wrapper.vm as unknown as { handleKeyDown: (event: KeyboardEvent) => void } + ).handleKeyDown; + + handleKeyDown(new KeyboardEvent('keydown', { key: 'm', ctrlKey: true })); + expect(wrapper.emitted('close')).toBeUndefined(); + + handleKeyDown(new KeyboardEvent('keydown', { key: 'm', altKey: true })); + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('does not close from the old toggle shortcut when the shortcut is disabled', () => { + const wrapper = mount(ModelDropdownPopup, { + props: { + data: { + activeModelId: '', + activeProviderId: null, + selectedModelId: '', + selectedProviderId: null, + searchQuery: '', + toggleShortcut: null, + models: [], + }, + isInPopup: true, + }, + }); + const handleKeyDown = ( + wrapper.vm as unknown as { handleKeyDown: (event: KeyboardEvent) => void } + ).handleKeyDown; + + handleKeyDown(new KeyboardEvent('keydown', { key: 'm', ctrlKey: true })); + + expect(wrapper.emitted('close')).toBeUndefined(); + }); }); diff --git a/apps/desktop/tests/PopupView/session-history-i18n.test.ts b/apps/desktop/tests/PopupView/session-history-i18n.test.ts index 3af44cec..4a7f808d 100644 --- a/apps/desktop/tests/PopupView/session-history-i18n.test.ts +++ b/apps/desktop/tests/PopupView/session-history-i18n.test.ts @@ -285,4 +285,48 @@ describe('SessionHistoryPopover i18n', () => { expect(wrapper.get('.history-session-preview').attributes('translate')).toBe('no'); expect(wrapper.get('.history-session-preview').text()).toBe('关闭'); }); + + it('uses the configured toggle shortcut and ignores the old default', () => { + const wrapper = mount(SessionHistoryPopover, { + props: { + data: { + activeSessionId: null, + searchQuery: '', + isLoading: false, + toggleShortcut: 'Alt+H', + sessions: [], + }, + }, + }); + const handleKeyDown = ( + wrapper.vm as unknown as { handleKeyDown: (event: KeyboardEvent) => void } + ).handleKeyDown; + + handleKeyDown(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true })); + expect(wrapper.emitted('close')).toBeUndefined(); + + handleKeyDown(new KeyboardEvent('keydown', { key: 'h', altKey: true })); + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('does not close from the old history shortcut when the shortcut is disabled', () => { + const wrapper = mount(SessionHistoryPopover, { + props: { + data: { + activeSessionId: null, + searchQuery: '', + isLoading: false, + toggleShortcut: null, + sessions: [], + }, + }, + }); + const handleKeyDown = ( + wrapper.vm as unknown as { handleKeyDown: (event: KeyboardEvent) => void } + ).handleKeyDown; + + handleKeyDown(new KeyboardEvent('keydown', { key: 'h', ctrlKey: true })); + + expect(wrapper.emitted('close')).toBeUndefined(); + }); }); diff --git a/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts b/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts index 1e2a2d9f..f8e1029c 100644 --- a/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts +++ b/apps/desktop/tests/composables/SearchView/useSearchKeyboardRouter.test.ts @@ -148,7 +148,7 @@ describe('createSearchKeyboardRouter', () => { expect(callbacks.onSearchKeybindingAction).toHaveBeenCalledWith('search.history.open'); }); - it('routes Ctrl+M only through the current keybinding configuration', async () => { + it('routes the model toggle shortcut only through the current keybinding configuration', async () => { const defaultRouter = createKeyboardRouter(); expect(defaultRouter.router.route({ key: 'm', ctrlKey: true })).toBe(true); await flushAsyncWork(); @@ -420,7 +420,7 @@ describe('createSearchKeyboardRouter', () => { expect(dropdownRouter.callbacks.onForwardToPopup).toHaveBeenNthCalledWith(2, 'Enter'); }); - it('routes quick-search page, menu, and view-toggle keys', () => { + it('routes quick-search page and menu keys', () => { const { router, callbacks } = createKeyboardRouter({ isQuickSearchOpen: () => true, hasQuickSearchHighlight: () => false, @@ -430,13 +430,41 @@ describe('createSearchKeyboardRouter', () => { expect(router.route({ key: 'PageDown' })).toBe(true); expect(router.route({ key: 'ContextMenu' })).toBe(true); expect(router.route({ key: 'F10', shiftKey: true })).toBe(true); - expect(router.route({ key: 'g', ctrlKey: true })).toBe(true); - expect(router.route({ key: 'G', metaKey: true })).toBe(true); expect(callbacks.onQuickSearchPageUp).toHaveBeenCalledTimes(1); expect(callbacks.onQuickSearchPageDown).toHaveBeenCalledTimes(1); expect(callbacks.onQuickSearchContextMenu).toHaveBeenCalledTimes(2); - expect(callbacks.onQuickSearchToggleView).toHaveBeenCalledTimes(2); + }); + + it('routes quick-search view toggle through the current keybinding configuration', async () => { + const { router, callbacks } = createKeyboardRouter({ + isQuickSearchOpen: () => true, + hasQuickSearchHighlight: () => false, + }); + + expect(router.route({ key: 'g', ctrlKey: true })).toBe(true); + await flushAsyncWork(); + expect(callbacks.onSearchKeybindingAction).toHaveBeenCalledWith( + 'search.quickSearch.toggleView' + ); + + const disabledRouter = createKeyboardRouter({ + isQuickSearchOpen: () => true, + getSearchKeybindings: () => ({ + ...createDefaultSearchKeybindings(), + 'search.quickSearch.toggleView': null, + }), + }); + expect(disabledRouter.router.route({ key: 'g', ctrlKey: true })).toBe(false); + await flushAsyncWork(); + expect(disabledRouter.callbacks.onSearchKeybindingAction).not.toHaveBeenCalled(); + + const closedRouter = createKeyboardRouter({ + isQuickSearchOpen: () => false, + }); + expect(closedRouter.router.route({ key: 'g', ctrlKey: true })).toBe(false); + await flushAsyncWork(); + expect(closedRouter.callbacks.onSearchKeybindingAction).not.toHaveBeenCalled(); }); it('routes highlighted quick-search navigation and opening through the quick-search contract', async () => { diff --git a/apps/desktop/tests/config/searchKeybindings.test.ts b/apps/desktop/tests/config/searchKeybindings.test.ts index edc18a5a..dcc85bc5 100644 --- a/apps/desktop/tests/config/searchKeybindings.test.ts +++ b/apps/desktop/tests/config/searchKeybindings.test.ts @@ -41,6 +41,7 @@ describe('search keybinding configuration', () => { 'search.session.new': 'Mod+N', 'search.session.reopenLastClosed': 'Mod+Shift+T', 'search.model.toggle': 'Mod+M', + 'search.quickSearch.toggleView': 'Mod+G', 'search.window.pin': 'Mod+P', 'search.window.maximize': 'F11', 'search.settings.open': 'Mod+,', diff --git a/apps/site/public/touchai-intro-en/touchai-components.html b/apps/site/public/touchai-intro-en/touchai-components.html index bc60c287..cbe1cc9b 100644 --- a/apps/site/public/touchai-intro-en/touchai-components.html +++ b/apps/site/public/touchai-intro-en/touchai-components.html @@ -1134,11 +1134,11 @@ const answerMarkdown = [ 'TouchAI is a one-touch desktop productivity agent.', '', - 'It is not just a web chat box. It is an AI entry point that lives on your desktop. You can summon it with `Alt+Space`, describe a goal in natural language, and let it work with files, the screen, the clipboard, models, and tools.', + 'It is not just a web chat box. It is an AI entry point that lives on your desktop. You can summon it with a customizable global shortcut, describe a goal in natural language, and let it work with files, the screen, the clipboard, models, and tools.', '', '## Core abilities', '', - '- **Instant access:** Open or hide it quickly with `Alt+Space`, without switching to a browser or another app.', + '- **Instant access:** Open or hide it quickly with the default global shortcut, or customize it to fit your workflow.', '- **Desktop context:** Understands tasks around local files, screen content, clipboard text, and the current conversation.', '- **Built-in tools:** Supports file search, local file and folder reading, web fetching, terminal execution, settings updates, model upgrades, and inline visualization.', '- **MCP extension:** Connects external MCP servers so more tools can be used inside the same conversation.', diff --git a/apps/site/public/touchai-intro/touchai-components.html b/apps/site/public/touchai-intro/touchai-components.html index 0930a7d2..13a8a984 100644 --- a/apps/site/public/touchai-intro/touchai-components.html +++ b/apps/site/public/touchai-intro/touchai-components.html @@ -1142,13 +1142,13 @@ const answerMarkdown = [ 'TouchAI 是一个“一触即达”的桌面效率 Agent。', '', - '它不是单纯的网页聊天框,而是一个运行在桌面上的 AI 工作入口。你可以在当前工作流里按下 `Alt+Space` 唤起它,用自然语言说出目标,让它结合文件、屏幕、剪贴板、模型和工具去完成任务。', + '它不是单纯的网页聊天框,而是一个运行在桌面上的 AI 工作入口。你可以在当前工作流里通过可自定义的全局快捷键唤起它,用自然语言说出目标,让它结合文件、屏幕、剪贴板、模型和工具去完成任务。', '', '项目 README 对 TouchAI 的定位是:**一触即达的桌面效率 Agent**。这个定位很关键,因为它强调的不是“打开一个 AI 网站再问问题”,而是让 AI 变成电脑系统里的一个随叫随到的操作层。', '', '## 1. 一触即达的桌面入口', '', - 'TouchAI 默认以全局快捷键作为入口。源码里的通用设置将全局快捷键设为 `Alt+Space`,所以它的交互方式更接近系统级搜索栏、启动器或命令面板。', + 'TouchAI 默认以全局快捷键作为入口,并允许在设置中按习惯自定义,所以它的交互方式更接近系统级搜索栏、启动器或命令面板。', '', '- **随叫随到:** 需要时唤起,不需要时隐藏。', '- **不打断工作流:** 不必切到浏览器或单独应用窗口。', From 11f90d56e1b65f8590cfd592621072d320e90c43 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:05:16 +0800 Subject: [PATCH 45/55] fix(desktop): avoid swallowing quick search toggle shortcut --- .../SearchView/composables/useSearchPage.ts | 36 +++++++++++++++-- .../SearchView/useSearchPage.test.ts | 39 ++++++++++++++++++- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/views/SearchView/composables/useSearchPage.ts b/apps/desktop/src/views/SearchView/composables/useSearchPage.ts index 6bac2dee..820379bd 100644 --- a/apps/desktop/src/views/SearchView/composables/useSearchPage.ts +++ b/apps/desktop/src/views/SearchView/composables/useSearchPage.ts @@ -16,7 +16,11 @@ import { runStartupTasks } from '@services/StartupService'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { nextTick, onMounted, onUnmounted, type Ref, ref, watch } from 'vue'; -import { SEARCH_KEYBINDING_DEFINITIONS, type SearchKeybindings } from '@/config/searchKeybindings'; +import { + SEARCH_KEYBINDING_DEFINITIONS, + type SearchKeybindingActionId, + type SearchKeybindings, +} from '@/config/searchKeybindings'; import { type MessageKey, type MessageParams, t } from '@/i18n'; import { useSettingsStore } from '@/stores/settings'; import { isE2eTestMode } from '@/utils/runtimeMode'; @@ -39,8 +43,30 @@ import { useModelDropdownPopup } from './useModelDropdownPopup'; const HIDE_TIMEOUT_MS = 5 * 60 * 1000; -function buildSearchSurfaceShortcutEntries(searchKeybindings: SearchKeybindings) { +interface SearchSurfaceShortcutSyncContext { + quickSearchOpen: boolean; +} + +function shouldSyncSearchSurfaceShortcutToNative( + actionId: SearchKeybindingActionId, + context: SearchSurfaceShortcutSyncContext +) { + if (actionId === 'search.quickSearch.toggleView') { + return context.quickSearchOpen; + } + + return true; +} + +function buildSearchSurfaceShortcutEntries( + searchKeybindings: SearchKeybindings, + context: SearchSurfaceShortcutSyncContext +) { return SEARCH_KEYBINDING_DEFINITIONS.flatMap((definition) => { + if (!shouldSyncSearchSurfaceShortcutToNative(definition.id, context)) { + return []; + } + const shortcut = normalizeLocalShortcutString(searchKeybindings[definition.id]); return shortcut ? [{ actionId: definition.id, shortcut }] : []; }); @@ -590,7 +616,9 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { try { await native.shortcut.setSearchSurfaceShortcuts( - buildSearchSurfaceShortcutEntries(searchKeybindings.value) + buildSearchSurfaceShortcutEntries(searchKeybindings.value, { + quickSearchOpen: controller.isQuickSearchOpen(), + }) ); } catch (error) { console.error('[SearchView] Failed to sync search surface shortcuts:', error); @@ -656,7 +684,7 @@ export function useSearchPageLifecycle(options: UseSearchPageLifecycleOptions) { } stopSearchSurfaceShortcutWatch = watch( - searchKeybindings, + [searchKeybindings, () => controller.isQuickSearchOpen()], () => { void syncSearchSurfaceShortcutsSafely(); }, diff --git a/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts b/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts index 87a26803..a318d0e7 100644 --- a/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts +++ b/apps/desktop/tests/composables/SearchView/useSearchPage.test.ts @@ -131,6 +131,7 @@ function createController() { focusSearchInput: vi.fn().mockResolvedValue(undefined), loadActiveModel: vi.fn().mockResolvedValue(undefined), invalidateModelDropdownData: vi.fn(), + isQuickSearchOpen: vi.fn(() => false), }; } @@ -308,7 +309,11 @@ describe('useSearchPageLifecycle', () => { }); it('syncs search surface shortcuts and delegates host accelerator commands', async () => { - const controller = createController(); + const quickSearchOpen = ref(false); + const controller = { + ...createController(), + isQuickSearchOpen: vi.fn(() => quickSearchOpen.value), + }; const interactionContext = createSearchInteractionContext(); const searchKeybindings = ref(createDefaultSearchKeybindings()); const handleSearchSurfaceCommand = vi.fn().mockResolvedValue(undefined); @@ -353,6 +358,14 @@ describe('useSearchPageLifecycle', () => { }, ]) ); + expect(nativeMock.shortcut.setSearchSurfaceShortcuts).toHaveBeenCalledWith( + expect.not.arrayContaining([ + { + actionId: 'search.quickSearch.toggleView', + shortcut: 'Mod+G', + }, + ]) + ); const commandHandler = eventHandlers.get(AppEvent.SEARCH_SURFACE_COMMAND); expect(commandHandler).toBeDefined(); @@ -369,6 +382,30 @@ describe('useSearchPageLifecycle', () => { source: 'webview2-accelerator', }); + quickSearchOpen.value = true; + await flushLifecycle(); + + expect(nativeMock.shortcut.setSearchSurfaceShortcuts).toHaveBeenLastCalledWith( + expect.arrayContaining([ + { + actionId: 'search.quickSearch.toggleView', + shortcut: 'Mod+G', + }, + ]) + ); + + quickSearchOpen.value = false; + await flushLifecycle(); + + expect(nativeMock.shortcut.setSearchSurfaceShortcuts).toHaveBeenLastCalledWith( + expect.not.arrayContaining([ + { + actionId: 'search.quickSearch.toggleView', + shortcut: 'Mod+G', + }, + ]) + ); + searchKeybindings.value = { ...searchKeybindings.value, 'search.model.toggle': null, From bfb2d9b75ac78ff93fcba8b0d94b1001447b2913 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:12:09 +0800 Subject: [PATCH 46/55] fix(desktop): preserve persisted search shortcuts --- apps/desktop/src/config/searchKeybindings.ts | 56 +++++++++++++++---- .../tests/config/searchKeybindings.test.ts | 37 ++++++++++++ 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/config/searchKeybindings.ts b/apps/desktop/src/config/searchKeybindings.ts index 67180dd5..29dec439 100644 --- a/apps/desktop/src/config/searchKeybindings.ts +++ b/apps/desktop/src/config/searchKeybindings.ts @@ -140,15 +140,16 @@ export function normalizeSearchKeybindings(value: unknown): SearchKeybindings { } const candidates = value as Record; + const resolved = new Map< + SearchKeybindingActionId, + { shortcut: string | null; isPersisted: boolean } + >(); - // 第一轮:先解析每个动作期望使用的快捷键(合法自定义值、显式禁用或默认值), - // 暂不处理快捷键冲突。 - const resolved = new Map(); for (const definition of SEARCH_KEYBINDING_DEFINITIONS) { const candidate = candidates[definition.id]; if (candidate === null && definition.allowDisable) { - resolved.set(definition.id, null); + resolved.set(definition.id, { shortcut: null, isPersisted: true }); continue; } @@ -161,30 +162,42 @@ export function normalizeSearchKeybindings(value: unknown): SearchKeybindings { const passesModifierPolicy = hasCommandModifier(shortcut) || allowsModifierlessFunction; if (passesModifierPolicy && !isReservedLocalShortcut(shortcut)) { - resolved.set(definition.id, shortcut); + resolved.set(definition.id, { shortcut, isPersisted: true }); continue; } } } - resolved.set(definition.id, defaults[definition.id]); + resolved.set(definition.id, { + shortcut: defaults[definition.id], + isPersisted: false, + }); } - // 第二轮:按定义顺序稳定处理冲突,先出现的动作保留快捷键。 - // 这样干净的快捷键互换不会因为撞到对方默认值而被同时丢弃。 const result = createDefaultSearchKeybindings(); + const assignedActionIds = new Set(); const usedShortcuts = new Set(); - for (const definition of SEARCH_KEYBINDING_DEFINITIONS) { - const desired = resolved.get(definition.id) ?? null; + + function assignShortcut( + definition: SearchKeybindingDefinition, + desired: string | null, + fallbackOnConflict = true + ) { if (desired === null) { result[definition.id] = null; - continue; + assignedActionIds.add(definition.id); + return true; } if (!usedShortcuts.has(desired)) { usedShortcuts.add(desired); result[definition.id] = desired; - continue; + assignedActionIds.add(definition.id); + return true; + } + + if (!fallbackOnConflict) { + return false; } const fallback = normalizeLocalShortcutString(definition.defaultShortcut); @@ -194,6 +207,25 @@ export function normalizeSearchKeybindings(value: unknown): SearchKeybindings { } else { result[definition.id] = null; } + assignedActionIds.add(definition.id); + return true; + } + + for (const definition of SEARCH_KEYBINDING_DEFINITIONS) { + const resolvedShortcut = resolved.get(definition.id); + if (!resolvedShortcut?.isPersisted) { + continue; + } + + assignShortcut(definition, resolvedShortcut.shortcut, false); + } + + for (const definition of SEARCH_KEYBINDING_DEFINITIONS) { + if (assignedActionIds.has(definition.id)) { + continue; + } + + assignShortcut(definition, defaults[definition.id]); } return result; diff --git a/apps/desktop/tests/config/searchKeybindings.test.ts b/apps/desktop/tests/config/searchKeybindings.test.ts index dcc85bc5..8adf1028 100644 --- a/apps/desktop/tests/config/searchKeybindings.test.ts +++ b/apps/desktop/tests/config/searchKeybindings.test.ts @@ -100,6 +100,43 @@ describe('search keybinding configuration', () => { }); }); + it('does not let duplicate fallback defaults steal later persisted shortcuts', () => { + expect( + normalizeSearchKeybindings({ + 'search.history.open': 'Ctrl+Y', + 'search.input.focus': 'Ctrl+Y', + 'search.settings.open': 'Ctrl+L', + }) + ).toEqual({ + ...createDefaultSearchKeybindings(), + 'search.history.open': 'Mod+Y', + 'search.input.focus': null, + 'search.settings.open': 'Mod+L', + }); + }); + + it('preserves persisted shortcuts when they conflict with newly added defaults', () => { + expect( + normalizeSearchKeybindings({ + 'search.window.pin': 'Mod+G', + }) + ).toEqual({ + ...createDefaultSearchKeybindings(), + 'search.quickSearch.toggleView': null, + 'search.window.pin': 'Mod+G', + }); + }); + + it('keeps normalized key order stable for persistence', () => { + expect( + Object.keys( + normalizeSearchKeybindings({ + 'search.window.pin': 'Mod+G', + }) + ) + ).toEqual(SEARCH_KEYBINDING_ACTION_IDS); + }); + it('falls back to defaults for invalid persisted payloads', () => { const defaults = createDefaultSearchKeybindings(); From 1f557f25ce99926b98488feb87229bc7e235afe1 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Mon, 15 Jun 2026 02:56:07 +0800 Subject: [PATCH 47/55] fix(desktop): reject macOS reserved shortcuts --- apps/desktop/src/config/searchKeybindings.ts | 7 +++- apps/desktop/src/i18n/messages.ts | 4 ++ .../BuiltInToolService/tools/setting/index.ts | 13 ++++++- .../General/SearchShortcutSettings.vue | 13 +++++++ .../tests/config/searchKeybindings.test.ts | 30 +++++++++++++- .../tools/setting/i18n.test.ts | 39 ++++++++++++++++++- .../settingsGeneralComponent.test.ts | 31 +++++++++++++++ 7 files changed, 133 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/config/searchKeybindings.ts b/apps/desktop/src/config/searchKeybindings.ts index 29dec439..b6f423be 100644 --- a/apps/desktop/src/config/searchKeybindings.ts +++ b/apps/desktop/src/config/searchKeybindings.ts @@ -2,6 +2,7 @@ import type { MessageKey } from '@/i18n'; import { hasCommandModifier, isModifierlessFunctionShortcut, + isReservedGlobalShortcut, isReservedLocalShortcut, normalizeLocalShortcutString, } from '@/utils/shortcuts'; @@ -161,7 +162,11 @@ export function normalizeSearchKeybindings(value: unknown): SearchKeybindings { isModifierlessFunctionShortcut(shortcut); const passesModifierPolicy = hasCommandModifier(shortcut) || allowsModifierlessFunction; - if (passesModifierPolicy && !isReservedLocalShortcut(shortcut)) { + if ( + passesModifierPolicy && + !isReservedLocalShortcut(shortcut) && + !isReservedGlobalShortcut(shortcut) + ) { resolved.set(definition.id, { shortcut, isPersisted: true }); continue; } diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index e185a265..cf470586 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -133,6 +133,8 @@ const zhCNMessages = { '快捷键需要包含 Ctrl、Alt 或 Cmd,或使用允许的功能键', 'settings.general.searchShortcuts.errors.reserved': '该快捷键保留给输入/导航行为,请选择其他组合', + 'settings.general.searchShortcuts.errors.systemReserved': + '该快捷键容易与 macOS 系统功能或输入法冲突,请换一个组合', 'settings.general.searchShortcuts.errors.unsupported': '暂不支持该按键作为搜索页快捷键,请换一个组合', 'settings.general.searchShortcuts.errors.duplicate': '该快捷键已被“{action}”使用,请换一个组合', @@ -1106,6 +1108,8 @@ const enUSMessages: Record = { 'A shortcut must include Ctrl, Alt, or Cmd, or use an allowed function key', 'settings.general.searchShortcuts.errors.reserved': 'This shortcut is reserved for typing or navigation. Choose another combination.', + 'settings.general.searchShortcuts.errors.systemReserved': + 'This shortcut can conflict with macOS system features or input methods. Choose another combination.', 'settings.general.searchShortcuts.errors.unsupported': 'This key is not supported for search shortcuts yet. Choose another combination.', 'settings.general.searchShortcuts.errors.duplicate': diff --git a/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts index 634a318a..9d7977c6 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/setting/index.ts @@ -10,7 +10,7 @@ import { resolveSearchWindowDefaultSize } from '@/config/searchWindow'; import { t, tt } from '@/i18n'; import type { ToolApprovalRequest } from '@/services/AgentService/contracts/tooling'; import type { GeneralSettingsData } from '@/stores/settings'; -import { normalizeLocalShortcutString } from '@/utils/shortcuts'; +import { isReservedGlobalShortcut, normalizeLocalShortcutString } from '@/utils/shortcuts'; import { truncateText } from '@/utils/text'; import { @@ -80,6 +80,7 @@ async function applySettingSideEffect( value: SupportedSettingValue ): Promise { if (key === 'global_shortcut') { + ensureGlobalShortcutIsNotSystemReserved(value as GeneralSettingsData['globalShortcut']); ensureGlobalShortcutDoesNotConflictWithSearchShortcuts( settingsStore, value as GeneralSettingsData['globalShortcut'] @@ -117,6 +118,16 @@ async function applySettingSideEffect( } } +function ensureGlobalShortcutIsNotSystemReserved( + shortcut: GeneralSettingsData['globalShortcut'] +): void { + if (!isReservedGlobalShortcut(shortcut)) { + return; + } + + throw new Error(t('settings.general.globalShortcutReservedOnMac')); +} + function findGlobalShortcutSearchConflict( settingsStore: SettingsStore, shortcut: GeneralSettingsData['globalShortcut'] diff --git a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue index b73867ba..312fff39 100644 --- a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue +++ b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue @@ -24,6 +24,7 @@ hasCommandModifier, isMacPlatform, isModifierlessFunctionShortcut, + isReservedGlobalShortcut, isReservedLocalShortcut, isReservedLocalShortcutKey, normalizeLocalShortcutString, @@ -351,6 +352,18 @@ return false; } + if (isReservedGlobalShortcut(normalizedShortcut)) { + reportSearchShortcutError( + actionId, + 'settings.general.searchShortcuts.errors.systemReserved' + ); + updateSearchShortcutDisplay( + actionId, + formatSearchShortcutForSettings(settings.value.searchKeybindings[actionId]) + ); + return false; + } + if (isReservedLocalShortcut(normalizedShortcut)) { reportSearchShortcutError( actionId, diff --git a/apps/desktop/tests/config/searchKeybindings.test.ts b/apps/desktop/tests/config/searchKeybindings.test.ts index 8adf1028..47a61268 100644 --- a/apps/desktop/tests/config/searchKeybindings.test.ts +++ b/apps/desktop/tests/config/searchKeybindings.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { createDefaultSearchKeybindings, @@ -10,7 +10,20 @@ import { type SearchKeybindingActionId, } from '@/config/searchKeybindings'; +const originalPlatform = navigator.platform; + +function setPlatform(platform: string) { + Object.defineProperty(window.navigator, 'platform', { + configurable: true, + value: platform, + }); +} + describe('search keybinding configuration', () => { + afterEach(() => { + setPlatform(originalPlatform); + }); + it('keeps action ids and definitions in sync', () => { expect(SEARCH_KEYBINDING_DEFINITIONS.map((definition) => definition.id)).toEqual( SEARCH_KEYBINDING_ACTION_IDS @@ -84,6 +97,21 @@ describe('search keybinding configuration', () => { }); }); + it('rejects persisted macOS system-reserved shortcuts', () => { + setPlatform('MacIntel'); + + expect( + normalizeSearchKeybindings({ + 'search.history.open': 'Cmd+Space', + 'search.input.focus': 'Ctrl+Space', + 'search.window.maximize': 'F12', + }) + ).toEqual({ + ...createDefaultSearchKeybindings(), + 'search.window.maximize': 'F12', + }); + }); + it('rejects persisted shortcut duplicates while preserving disabled actions', () => { expect( normalizeSearchKeybindings({ diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/setting/i18n.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/setting/i18n.test.ts index 4dccf490..8f238cf2 100644 --- a/apps/desktop/tests/services/BuiltInToolService/tools/setting/i18n.test.ts +++ b/apps/desktop/tests/services/BuiltInToolService/tools/setting/i18n.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createDefaultSearchKeybindings } from '@/config/searchKeybindings'; import { setLocale } from '@/i18n'; @@ -16,6 +16,15 @@ import { parseSettingRequest, } from '@/services/BuiltInToolService/tools/setting/helper'; +const originalPlatform = navigator.platform; + +function setPlatform(platform: string) { + Object.defineProperty(window.navigator, 'platform', { + configurable: true, + value: platform, + }); +} + const { mockSettingsStore, mockRegisterGlobalShortcut, @@ -91,6 +100,7 @@ function createExecutionContext(): Parameters[2] { describe('Setting built-in tool i18n', () => { beforeEach(() => { vi.clearAllMocks(); + setPlatform('Win32'); mockRegisterGlobalShortcut.mockResolvedValue(undefined); mockUpdateGlobalShortcut.mockResolvedValue(undefined); mockUpdateLanguage.mockResolvedValue(undefined); @@ -99,6 +109,10 @@ describe('Setting built-in tool i18n', () => { mockSettingsStore.settings.language = 'zh-CN'; }); + afterEach(() => { + setPlatform(originalPlatform); + }); + it('formats list and get outputs in English when active locale is English', async () => { setLocale('en-US'); @@ -223,6 +237,29 @@ describe('Setting built-in tool i18n', () => { expect(mockRegisterGlobalShortcut).not.toHaveBeenCalledWith('Ctrl+K'); }); + it('rejects macOS system-reserved global shortcuts before registration', async () => { + setLocale('en-US'); + setPlatform('MacIntel'); + + const result = await executeSettingTool( + { + action: 'set', + key: 'global_shortcut', + value: 'Cmd+Space', + reason: 'User asked for it.', + }, + {}, + createExecutionContext() + ); + + expect(result).toMatchObject({ + isError: true, + status: 'error', + }); + expect(result.errorMessage).toContain('macOS'); + expect(mockRegisterGlobalShortcut).not.toHaveBeenCalledWith('Cmd+Space'); + }); + it('formats failed rollback in English', async () => { setLocale('en-US'); mockRegisterGlobalShortcut diff --git a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts index 0fcfaeb1..83d59a6d 100644 --- a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts @@ -391,6 +391,37 @@ describe('SettingsGeneralSection', () => { expect((input.element as HTMLInputElement).value).toBe('Option+Space'); }); + it('rejects macOS system-reserved search shortcuts', async () => { + setPlatform('MacIntel'); + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + alertMessageMock.error.mockClear(); + + window.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', metaKey: true }) + ); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + expect(alertMessageMock.error).toHaveBeenCalledWith(expect.stringContaining('macOS'), 3000); + expect((input.element as HTMLInputElement).value).toBe('Cmd+H'); + + window.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', ctrlKey: true }) + ); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + expect((input.element as HTMLInputElement).value).toBe('Cmd+H'); + }); + it('saves the global shortcut immediately after a shortcut is pressed', async () => { settingsStoreMock.settings.value.searchKeybindings['search.session.new'] = 'Mod+Shift+N'; const wrapper = mount(GeneralSection); From fab4520d3367c88f9c443341ba44b6a71e2374c2 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:15:45 +0800 Subject: [PATCH 48/55] fix(desktop): harden search accelerator command routing --- .../src/core/window/webview_defaults.rs | 34 +++++++++-- apps/desktop/src/views/PopupView/index.vue | 22 ++++++- .../interaction/useSearchKeyboardRouter.ts | 26 +++----- .../composables/searchInteraction.ts | 10 +-- .../SearchView/composables/useSearchPage.ts | 2 +- apps/desktop/src/views/SearchView/index.vue | 11 ++-- .../tests/PopupView/popup-view-i18n.test.ts | 61 ++++++++++++++++++- .../SearchView/searchInteraction.test.ts | 16 ++++- .../useSearchKeyboardRouter.test.ts | 12 ++-- 9 files changed, 152 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs index 2f0ab928..fe3587a1 100644 --- a/apps/desktop/src-tauri/src/core/window/webview_defaults.rs +++ b/apps/desktop/src-tauri/src/core/window/webview_defaults.rs @@ -197,6 +197,11 @@ fn is_accelerator_key_down_event(key_event_kind: i32) -> bool { || key_event_kind == COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN.0 } +#[cfg(target_os = "windows")] +fn should_emit_search_surface_command_to_window(label: &str) -> bool { + label == "main" || label.starts_with("popup-") +} + #[cfg(target_os = "windows")] /// 注册 WebView2 accelerator 处理器,将 Alt+Space 转成 Tauri 事件供前端捕获。 /// @@ -209,7 +214,8 @@ fn register_system_menu_accelerator_handler( controller: &ICoreWebView2Controller, ) -> Result<(), String> { let app_handle = window.app_handle().clone(); - let search_surface_window = (window.label() == "main").then(|| window.clone()); + let search_surface_command_window = + should_emit_search_surface_command_to_window(window.label()).then(|| window.clone()); let mut token = 0i64; let handler = AcceleratorKeyPressedEventHandler::create(Box::new( move |_controller: Option, @@ -230,7 +236,7 @@ fn register_system_menu_accelerator_handler( return Ok(()); } - let Some(search_surface_window) = &search_surface_window else { + let Some(search_surface_command_window) = &search_surface_command_window else { return Ok(()); }; @@ -258,7 +264,7 @@ fn register_system_menu_accelerator_handler( let _ = args2.SetIsBrowserAcceleratorKeyEnabled(false); } let _ = args.SetHandled(true); - let _ = search_surface_window.emit("search-surface-command", command); + let _ = search_surface_command_window.emit("search-surface-command", command); return Ok(()); } @@ -267,7 +273,7 @@ fn register_system_menu_accelerator_handler( let is_super_down = (GetKeyState(i32::from(VK_LWIN.0)) as u16 & 0x8000) != 0 || (GetKeyState(i32::from(VK_RWIN.0)) as u16 & 0x8000) != 0; - if let Some(search_surface_window) = &search_surface_window { + if let Some(search_surface_command_window) = &search_surface_command_window { if let Some(command) = crate::core::system::shortcut::find_search_surface_command_for_windows_accelerator( virtual_key, @@ -283,7 +289,7 @@ fn register_system_menu_accelerator_handler( let _ = args2.SetIsBrowserAcceleratorKeyEnabled(false); } let _ = args.SetHandled(true); - let _ = search_surface_window.emit("search-surface-command", command); + let _ = search_surface_command_window.emit("search-surface-command", command); return Ok(()); } } @@ -440,6 +446,24 @@ pub(crate) fn apply_webview_runtime_defaults( })? } +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::should_emit_search_surface_command_to_window; + + #[test] + fn search_surface_commands_target_main_and_popup_windows_only() { + assert!(should_emit_search_surface_command_to_window("main")); + assert!(should_emit_search_surface_command_to_window( + "popup-model-dropdown-popup" + )); + assert!(should_emit_search_surface_command_to_window( + "popup-session-history-popup" + )); + assert!(!should_emit_search_surface_command_to_window("settings")); + assert!(!should_emit_search_surface_command_to_window("assistant")); + } +} + #[cfg(not(target_os = "windows"))] /** * 非 Windows 平台无需额外的 WebView2 默认配置。 diff --git a/apps/desktop/src/views/PopupView/index.vue b/apps/desktop/src/views/PopupView/index.vue index 8ba57a1a..71a9750e 100644 --- a/apps/desktop/src/views/PopupView/index.vue +++ b/apps/desktop/src/views/PopupView/index.vue @@ -2,7 +2,7 @@ + + + + diff --git a/apps/desktop/src/views/SettingsView/components/General/index.vue b/apps/desktop/src/views/SettingsView/components/General/index.vue index a7985573..2b23952f 100644 --- a/apps/desktop/src/views/SettingsView/components/General/index.vue +++ b/apps/desktop/src/views/SettingsView/components/General/index.vue @@ -1,47 +1,17 @@ - - diff --git a/apps/desktop/src/views/SettingsView/components/Shortcuts/index.vue b/apps/desktop/src/views/SettingsView/components/Shortcuts/index.vue new file mode 100644 index 00000000..c9ecd819 --- /dev/null +++ b/apps/desktop/src/views/SettingsView/components/Shortcuts/index.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/desktop/src/views/SettingsView/index.vue b/apps/desktop/src/views/SettingsView/index.vue index 70385453..697e7736 100644 --- a/apps/desktop/src/views/SettingsView/index.vue +++ b/apps/desktop/src/views/SettingsView/index.vue @@ -28,6 +28,7 @@ }); const GeneralView = defineAsyncComponent(() => import('./components/General/index.vue')); + const ShortcutsView = defineAsyncComponent(() => import('./components/Shortcuts/index.vue')); const AiServicesView = defineAsyncComponent(() => import('./components/AiServices/index.vue')); const BuiltInToolsView = defineAsyncComponent( () => import('./components/BuiltInTools/index.vue') @@ -56,6 +57,11 @@ loadingKey: 'settings.loading.general', scrollable: true, }, + shortcuts: { + component: ShortcutsView, + loadingKey: 'settings.loading.shortcuts', + scrollable: true, + }, 'ai-services': { component: AiServicesView, loadingKey: 'settings.loading.aiServices', diff --git a/apps/desktop/src/views/SettingsView/settingsNavigation.ts b/apps/desktop/src/views/SettingsView/settingsNavigation.ts index 013bb9bc..d7dfa6af 100644 --- a/apps/desktop/src/views/SettingsView/settingsNavigation.ts +++ b/apps/desktop/src/views/SettingsView/settingsNavigation.ts @@ -5,6 +5,7 @@ import { JSON_SETTINGS_SECTIONS } from '@/stores/setting/sections/registry'; export type NavigationSection = | 'general' + | 'shortcuts' | 'ai-services' | 'built-in-tools' | 'search' @@ -57,6 +58,12 @@ const settingsNavigationDefinitions: SettingsNavigationGroupDefinition[] = [ labelKey: 'settings.nav.general.label', descriptionKey: 'settings.nav.general.description', }, + { + id: 'shortcuts', + icon: 'keyboard', + labelKey: 'settings.nav.shortcuts.label', + descriptionKey: 'settings.nav.shortcuts.description', + }, ], }, { diff --git a/apps/desktop/tests/SettingsView/general-language.test.ts b/apps/desktop/tests/SettingsView/general-language.test.ts index a61594f9..4c1c1c7c 100644 --- a/apps/desktop/tests/SettingsView/general-language.test.ts +++ b/apps/desktop/tests/SettingsView/general-language.test.ts @@ -165,11 +165,6 @@ describe('Settings General shortcut capture i18n', () => { await flushMountedPromises(); expect(wrapper.text()).toContain('General'); - expect(wrapper.text()).toContain('Shortcuts'); - expect(wrapper.text()).toContain('Set the global shortcut used to open TouchAI.'); - expect(wrapper.text()).toContain('Toggle window maximize'); - expect(wrapper.text()).toContain('Toggle the search window maximized state.'); - expect(wrapper.text()).toContain('Activation shortcut'); expect(wrapper.text()).toContain('Startup and window'); expect(wrapper.text()).toContain( 'Control startup behavior and the default search window size' @@ -186,6 +181,10 @@ describe('Settings General shortcut capture i18n', () => { expect(wrapper.text()).toContain('Language'); expect(wrapper.text()).toContain('Controls the display language used by TouchAI'); expect(wrapper.text()).toContain('Interface language'); + expect(wrapper.text()).not.toContain('Activation shortcut'); + expect(wrapper.text()).not.toContain('Set the global shortcut used to open TouchAI.'); + expect(wrapper.text()).not.toContain('Toggle window maximize'); + expect(wrapper.text()).not.toContain('Toggle the search window maximized state.'); expect(wrapper.text()).not.toContain('常规设置'); expect(wrapper.text()).not.toContain('输出时滚动策略'); }); diff --git a/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts b/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts index b432e49c..662a149f 100644 --- a/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts +++ b/apps/desktop/tests/SettingsView/navigation-sidebar-i18n.test.ts @@ -34,6 +34,9 @@ describe('Settings navigation sidebar i18n', () => { expect(wrapper.get('[data-testid="settings-nav-general"]').attributes('title')).toBe( 'General' ); + expect(wrapper.get('[data-testid="settings-nav-shortcuts"]').attributes('title')).toBe( + 'Shortcuts' + ); expect(wrapper.get('[data-testid="settings-nav-ai-services"]').attributes('title')).toBe( 'Providers and models' ); @@ -61,6 +64,7 @@ describe('Settings navigation sidebar i18n', () => { expect(settingsNavigationGroups[0]?.label).toBe('Basics'); expect(flattenSettingsNavigation().map((item) => item.label)).toEqual([ 'General', + 'Shortcuts', 'Providers and models', 'Built-in tools', 'Search', diff --git a/apps/desktop/tests/SettingsView/settings-view-i18n.test.ts b/apps/desktop/tests/SettingsView/settings-view-i18n.test.ts index 8faeb9a5..0e861164 100644 --- a/apps/desktop/tests/SettingsView/settings-view-i18n.test.ts +++ b/apps/desktop/tests/SettingsView/settings-view-i18n.test.ts @@ -55,6 +55,16 @@ vi.mock('@/views/SettingsView/components/General/index.vue', () => ({ }, }, })); +vi.mock('@/views/SettingsView/components/Shortcuts/index.vue', () => ({ + __esModule: true, + default: { + name: 'ShortcutsViewStub', + async setup() { + await asyncViewControls.waitForResolve(); + return () => null; + }, + }, +})); vi.mock('@/views/SettingsView/components/AiServices/index.vue', () => ({ __esModule: true, default: { @@ -146,8 +156,12 @@ describe('SettingsView lazy loading i18n', () => { await flushMountedPromises(); expect(wrapper.text()).toContain('Loading general settings...'); + expect(wrapper.text()).not.toContain('Loading shortcut settings...'); expect(setTitleMock).toHaveBeenCalledWith('TouchAI - Settings'); + await wrapper.get('[data-testid="settings-nav-shortcuts"]').trigger('click'); + expect(wrapper.text()).toContain('Loading shortcut settings...'); + await wrapper.get('[data-testid="settings-nav-ai-services"]').trigger('click'); expect(wrapper.text()).toContain('Loading model service settings...'); diff --git a/apps/desktop/tests/views/SettingsView/navigationSidebar.test.ts b/apps/desktop/tests/views/SettingsView/navigationSidebar.test.ts index 7337aa27..26e94dd6 100644 --- a/apps/desktop/tests/views/SettingsView/navigationSidebar.test.ts +++ b/apps/desktop/tests/views/SettingsView/navigationSidebar.test.ts @@ -66,6 +66,7 @@ describe('NavigationSidebar', () => { expect(wrapper.text()).not.toContain('返回'); expect(wrapper.text()).not.toContain('概览'); expect(wrapper.text()).toContain('通用'); + expect(wrapper.text()).toContain('快捷键'); expect(wrapper.text()).toContain('服务商与模型'); expect(wrapper.text()).not.toContain('关于'); expect(wrapper.text()).not.toContain('Provider、模型、默认模型和密钥'); diff --git a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts index c959cfe8..91af6965 100644 --- a/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsGeneralComponent.test.ts @@ -2,9 +2,7 @@ import type { AppUpdateState } from '@services/AppUpdateService/types'; import { flushPromises, mount } from '@vue/test-utils'; import { vi } from 'vitest'; -import { createDefaultSearchKeybindings } from '@/config/searchKeybindings'; import { setLocale } from '@/i18n'; -import { AppEvent, eventService } from '@/services/EventService'; import GeneralSection from '@/views/SettingsView/components/General/index.vue'; const originalPlatform = navigator.platform; @@ -17,11 +15,9 @@ function setPlatform(platform: string) { } const settingsStoreMock = vi.hoisted(() => { - const createGeneralSettingsMock = (searchKeybindings: Record) => ({ + const createGeneralSettingsMock = () => ({ globalShortcut: 'Alt+Space', - searchKeybindings: { - ...searchKeybindings, - }, + searchKeybindings: {}, startOnBoot: false, startMinimized: true, language: 'zh-CN', @@ -36,11 +32,9 @@ const settingsStoreMock = vi.hoisted(() => { return { createGeneralSettingsMock, settings: { - value: createGeneralSettingsMock({}), + value: createGeneralSettingsMock(), }, initialize: vi.fn().mockResolvedValue(undefined), - updateGlobalShortcut: vi.fn().mockResolvedValue(undefined), - updateSearchKeybindings: vi.fn().mockResolvedValue(undefined), updateStartOnBoot: vi.fn().mockResolvedValue(undefined), updateStartMinimized: vi.fn().mockResolvedValue(undefined), updateOutputScrollBehavior: vi.fn().mockResolvedValue(undefined), @@ -53,10 +47,6 @@ const settingsStoreMock = vi.hoisted(() => { }); const nativeMock = vi.hoisted(() => ({ - shortcut: { - getShortcutStatus: vi.fn().mockResolvedValue([false, null]), - registerGlobalShortcut: vi.fn().mockResolvedValue(undefined), - }, autostart: { isAutostartEnabled: vi.fn().mockResolvedValue(false), enableAutostart: vi.fn().mockResolvedValue(undefined), @@ -87,10 +77,6 @@ vi.mock('@services/NativeService', () => ({ native: nativeMock, })); -vi.mock('@services/NotificationService', () => ({ - notify: vi.fn(), -})); - vi.mock('@components/AlertMessage.vue', () => ({ default: { name: 'AlertMessageStub', @@ -103,35 +89,18 @@ vi.mock('@components/AlertMessage.vue', () => ({ }, })); -vi.mock('@components/AppIcon.vue', () => ({ - default: { - name: 'AppIconStub', - props: ['name'], - template: '', - }, -})); - vi.mock('@components/CustomSelect.vue', () => ({ default: { name: 'CustomSelectStub', - props: ['modelValue', 'options', 'open', 'contentTestId', 'optionTestIdPrefix'], - emits: ['update:modelValue', 'update:open'], + props: ['modelValue', 'options'], + emits: ['update:modelValue'], template: `
- - -
- +
+ {{ option.label }} {{ option.description }}
`, @@ -197,72 +166,20 @@ describe('SettingsGeneralSection', () => { setPlatform('Win32'); setLocale('zh-CN'); appUpdateServiceMock.state = appUpdateServiceMock.createState(); - nativeMock.shortcut.getShortcutStatus.mockResolvedValue([false, null]); nativeMock.autostart.isAutostartEnabled.mockResolvedValue(false); - settingsStoreMock.settings.value = settingsStoreMock.createGeneralSettingsMock( - createDefaultSearchKeybindings() - ); + settingsStoreMock.settings.value = settingsStoreMock.createGeneralSettingsMock(); }); afterEach(() => { setPlatform(originalPlatform); document.body.innerHTML = ''; }); - it('renders the general settings groups and row controls', async () => { const wrapper = mount(GeneralSection); await flushPromises(); expect(wrapper.get('h1').text()).toBe('通用'); - expect(wrapper.text()).toContain('快捷键'); - expect(wrapper.text()).toContain('唤起快捷键'); - expect( - ( - wrapper.get('[data-testid="settings-global-shortcut-input"]') - .element as HTMLInputElement - ).value - ).toBe('Alt+Space'); - expect(wrapper.text()).toContain('设置呼出 TouchAI 的全局快捷键'); - expect(wrapper.text()).not.toContain('Ctrl+Space'); - expect(wrapper.find('[data-testid="settings-shortcut-suggestions"]').exists()).toBe(false); - expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( - false - ); - expect(wrapper.text()).not.toContain('搜索页快捷键'); - expect(wrapper.text()).toContain('会话'); - expect(wrapper.text()).toContain('输入与请求'); - expect(wrapper.text()).toContain('窗口'); - expect(wrapper.text()).toContain('打开会话历史'); - expect(wrapper.text()).toContain('快速打开或收起会话历史列表'); - expect(wrapper.text()).toContain('开始新会话'); - expect(wrapper.text()).toContain('切换窗口最大化'); - expect(wrapper.text()).toContain('切换搜索窗口最大化'); - expect(wrapper.text()).toContain('打开设置'); - expect(wrapper.text()).toContain('快速打开设置窗口'); - expect( - ( - wrapper.get('[data-testid="settings-search-shortcut-input-search.window.maximize"]') - .element as HTMLInputElement - ).value - ).toBe('F11'); - expect( - ( - wrapper.get('[data-testid="settings-search-shortcut-input-search.settings.open"]') - .element as HTMLInputElement - ).value - ).toBe('Ctrl+,'); - expect( - ( - wrapper.get('[data-testid="settings-search-shortcut-input-search.request.cancel"]') - .element as HTMLInputElement - ).value - ).toBe('Esc'); - expect( - wrapper - .get('[data-testid="settings-search-shortcut-input-search.history.open"]') - .classes() - ).not.toContain('font-mono'); expect(wrapper.text()).toContain('启动与窗口'); expect(wrapper.text()).toContain('开机自启动'); expect(wrapper.text()).toContain('启动时最小化'); @@ -275,6 +192,12 @@ describe('SettingsGeneralSection', () => { expect(wrapper.text()).toContain('当前已是最新版(V0.1.0)'); expect(wrapper.text()).toContain('自动检查更新'); expect(wrapper.text()).toContain('检查更新'); + expect(wrapper.text()).not.toContain('快捷键'); + expect(wrapper.text()).not.toContain('唤起快捷键'); + expect(wrapper.find('[data-testid="settings-global-shortcut-input"]').exists()).toBe(false); + expect(wrapper.find('[data-testid^="settings-search-shortcut-input-"]').exists()).toBe( + false + ); expect(wrapper.text()).not.toContain('快捷唤起'); expect(wrapper.text()).not.toContain('管理全局唤起'); expect(wrapper.text()).not.toContain('启动设置'); @@ -288,544 +211,7 @@ describe('SettingsGeneralSection', () => { expect(controls.length).toBeGreaterThanOrEqual(3); const rowLabels = wrapper.findAll('[data-testid="settings-general-row-label"]'); - expect(rowLabels.length).toBeGreaterThanOrEqual(13); - }); - - it('opens global shortcut presets from the shortcut field and saves a preset', async () => { - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); - await input.trigger('focus'); - await flushPromises(); - - expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( - true - ); - expect( - wrapper.get('[data-testid="settings-global-shortcut-preset-Alt+Space"]').text() - ).toBe('Alt+Space'); - expect( - wrapper.get('[data-testid="settings-global-shortcut-preset-Ctrl+Space"]').text() - ).toBe('Ctrl+Space'); - - await wrapper - .get('[data-testid="settings-global-shortcut-preset-Ctrl+Space"]') - .trigger('click'); - await flushPromises(); - - expect(nativeMock.shortcut.registerGlobalShortcut).toHaveBeenCalledWith('Ctrl+Space'); - expect(settingsStoreMock.updateGlobalShortcut).toHaveBeenCalledWith('Ctrl+Space'); - expect((input.element as HTMLInputElement).value).toBe('Ctrl+Space'); - expect(settingsStoreMock.settings.value.globalShortcut).toBe('Ctrl+Space'); - expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( - false - ); - }); - - it('uses macOS shortcut labels and omits input-method-conflicting presets', async () => { - setPlatform('MacIntel'); - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); - expect((input.element as HTMLInputElement).value).toBe('Option+Space'); - - await input.trigger('focus'); - await flushPromises(); - - expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( - true - ); - expect( - wrapper.get('[data-testid="settings-global-shortcut-preset-Option+Space"]').text() - ).toBe('Option+Space'); - expect( - wrapper.get('[data-testid="settings-global-shortcut-preset-Option+Shift+Space"]').text() - ).toBe('Option+Shift+Space'); - expect( - wrapper.find('[data-testid="settings-global-shortcut-preset-Ctrl+Space"]').exists() - ).toBe(false); - - await wrapper - .get('[data-testid="settings-global-shortcut-preset-Option+Shift+Space"]') - .trigger('click'); - await flushPromises(); - - expect(nativeMock.shortcut.registerGlobalShortcut).toHaveBeenCalledWith( - 'Option+Shift+Space' - ); - expect(settingsStoreMock.updateGlobalShortcut).toHaveBeenCalledWith('Option+Shift+Space'); - expect((input.element as HTMLInputElement).value).toBe('Option+Shift+Space'); - }); - - it('blocks macOS system-reserved global shortcuts before registration', async () => { - setPlatform('MacIntel'); - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); - await input.trigger('focus'); - await flushPromises(); - - window.dispatchEvent( - new KeyboardEvent('keydown', { key: ' ', code: 'Space', metaKey: true }) - ); - await flushPromises(); - - expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); - expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); - expect((input.element as HTMLInputElement).value).toBe('Option+Space'); - - await input.trigger('focus'); - await flushPromises(); - window.dispatchEvent( - new KeyboardEvent('keydown', { key: ' ', code: 'Space', ctrlKey: true }) - ); - await flushPromises(); - - expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); - expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); - expect((input.element as HTMLInputElement).value).toBe('Option+Space'); - }); - - it('rejects macOS system-reserved search shortcuts', async () => { - setPlatform('MacIntel'); - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get( - '[data-testid="settings-search-shortcut-input-search.history.open"]' - ); - await input.trigger('focus'); - await flushPromises(); - alertMessageMock.error.mockClear(); - - window.dispatchEvent( - new KeyboardEvent('keydown', { key: ' ', code: 'Space', metaKey: true }) - ); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); - expect(alertMessageMock.error).toHaveBeenCalledWith(expect.stringContaining('macOS'), 3000); - expect((input.element as HTMLInputElement).value).toBe('Cmd+H'); - - window.dispatchEvent( - new KeyboardEvent('keydown', { key: ' ', code: 'Space', ctrlKey: true }) - ); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); - expect((input.element as HTMLInputElement).value).toBe('Cmd+H'); - }); - - it('saves the global shortcut immediately after a shortcut is pressed', async () => { - settingsStoreMock.settings.value.searchKeybindings['search.session.new'] = 'Mod+Shift+N'; - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); - await input.trigger('focus'); - await flushPromises(); - - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', ctrlKey: true })); - await flushPromises(); - - expect(nativeMock.shortcut.registerGlobalShortcut).toHaveBeenCalledWith('Ctrl+N'); - expect(settingsStoreMock.updateGlobalShortcut).toHaveBeenCalledWith('Ctrl+N'); - expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( - false - ); - }); - - it('captures Ctrl+Space from the global shortcut input before the preset menu handles Space', async () => { - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); - await input.trigger('focus'); - await flushPromises(); - - await input.trigger('keydown', { - key: ' ', - code: 'Space', - ctrlKey: true, - }); - await flushPromises(); - - expect(nativeMock.shortcut.registerGlobalShortcut).toHaveBeenCalledWith('Ctrl+Space'); - expect(settingsStoreMock.updateGlobalShortcut).toHaveBeenCalledWith('Ctrl+Space'); - expect((input.element as HTMLInputElement).value).toBe('Ctrl+Space'); - expect(settingsStoreMock.settings.value.globalShortcut).toBe('Ctrl+Space'); - }); - - it('does not capture navigation keys while global shortcut presets are open', async () => { - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); - await input.trigger('focus'); - await flushPromises(); - - const event = new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }); - window.dispatchEvent(event); - await flushPromises(); - - expect(event.defaultPrevented).toBe(false); - expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); - expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); - expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( - true - ); - - wrapper.unmount(); - }); - - it('reports invalid global shortcut attempts only once through the capture stack', async () => { - const wrapper = mount(GeneralSection, { - attachTo: document.body, - }); - - await flushPromises(); - - const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); - await input.trigger('focus'); - await flushPromises(); - alertMessageMock.warning.mockClear(); - - const event = new KeyboardEvent('keydown', { - key: 'a', - shiftKey: true, - bubbles: true, - cancelable: true, - }); - input.element.dispatchEvent(event); - await flushPromises(); - - expect(event.defaultPrevented).toBe(true); - expect(alertMessageMock.warning).toHaveBeenCalledTimes(1); - expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); - expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); - - wrapper.unmount(); - }); - - it('does not save a global shortcut that duplicates a search shortcut', async () => { - settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+A'; - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); - await input.trigger('focus'); - await flushPromises(); - - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', ctrlKey: true })); - await flushPromises(); - - expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); - expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); - expect((input.element as HTMLInputElement).value).toBe('Alt+Space'); - }); - it('accepts modifierless function keys for configurable search shortcuts', async () => { - settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get( - '[data-testid="settings-search-shortcut-input-search.history.open"]' - ); - await input.trigger('focus'); - await flushPromises(); - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1' })); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ - ...settingsStoreMock.settings.value.searchKeybindings, - 'search.history.open': 'F1', - }); - }); - - it('captures Windows system key events while editing a search shortcut', async () => { - settingsStoreMock.settings.value.globalShortcut = 'Ctrl+Space'; - settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get( - '[data-testid="settings-search-shortcut-input-search.history.open"]' - ); - await input.trigger('focus'); - await flushPromises(); - - await eventService.emit(AppEvent.SHORTCUT_CAPTURE_SYSTEM_KEY, { - key: 'Space', - alt: true, - ctrl: false, - shift: false, - }); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ - ...settingsStoreMock.settings.value.searchKeybindings, - 'search.history.open': 'Alt+Space', - }); - }); - - it('captures a function-row key by keyboard code when the key value is not an F-key', async () => { - settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get( - '[data-testid="settings-search-shortcut-input-search.history.open"]' - ); - await input.trigger('focus'); - await flushPromises(); - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'BrightnessUp', code: 'F2' })); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ - ...settingsStoreMock.settings.value.searchKeybindings, - 'search.history.open': 'F2', - }); - }); - - it('does not capture navigation keys while editing a search shortcut', async () => { - settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get( - '[data-testid="settings-search-shortcut-input-search.history.open"]' - ); - await input.trigger('focus'); - await flushPromises(); - - const event = new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }); - window.dispatchEvent(event); - await flushPromises(); - - expect(event.defaultPrevented).toBe(false); - expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); - - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1' })); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ - ...settingsStoreMock.settings.value.searchKeybindings, - 'search.history.open': 'F1', - }); - }); - - it('keeps search shortcut capture active after a validation failure', async () => { - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get( - '[data-testid="settings-search-shortcut-input-search.history.open"]' - ); - await input.trigger('focus'); - await flushPromises(); - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', shiftKey: true })); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); - - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1' })); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ - ...settingsStoreMock.settings.value.searchKeybindings, - 'search.history.open': 'F1', - }); - }); - - it('captures shifted punctuation shortcuts through their physical key code', async () => { - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get( - '[data-testid="settings-search-shortcut-input-search.history.open"]' - ); - await input.trigger('focus'); - await flushPromises(); - - window.dispatchEvent( - new KeyboardEvent('keydown', { - key: '+', - code: 'Equal', - ctrlKey: true, - shiftKey: true, - }) - ); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ - ...settingsStoreMock.settings.value.searchKeybindings, - 'search.history.open': 'Mod+Shift+=', - }); - expect((input.element as HTMLInputElement).value).toBe('Ctrl+Shift+='); - }); - - it('reports unsupported mac command search shortcuts without showing the Windows key warning', async () => { - setPlatform('MacIntel'); - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get( - '[data-testid="settings-search-shortcut-input-search.history.open"]' - ); - await input.trigger('focus'); - await flushPromises(); - alertMessageMock.error.mockClear(); - alertMessageMock.warning.mockClear(); - - window.dispatchEvent(new KeyboardEvent('keydown', { key: '@', metaKey: true })); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); - expect(alertMessageMock.warning).not.toHaveBeenCalled(); - expect(alertMessageMock.error).toHaveBeenCalledTimes(1); - expect((input.element as HTMLInputElement).value).toBe('Cmd+H'); - - wrapper.unmount(); - }); - - it('shows fixed search shortcuts as unsupported for editing', async () => { - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const input = wrapper.get('[data-testid="settings-search-shortcut-input-search.submit"]'); - expect(input.attributes('title')).toBe('暂不支持修改该快捷键'); - expect(input.attributes('tabindex')).toBe('-1'); - - await input.trigger('focus'); - await flushPromises(); - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2' })); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); - expect((input.element as HTMLInputElement).value).toBe('Enter'); - }); - - it('uses an inline x icon to clear a default search shortcut', async () => { - const wrapper = mount(GeneralSection); - - await flushPromises(); - - expect( - wrapper - .find('[data-testid="settings-search-shortcut-reset-search.history.open"]') - .exists() - ).toBe(false); - expect( - wrapper - .find('[data-testid="settings-search-shortcut-disable-search.history.open"]') - .exists() - ).toBe(false); - - const action = wrapper.get( - '[data-testid="settings-search-shortcut-action-search.history.open"]' - ); - expect(action.attributes('data-shortcut-action')).toBe('clear'); - expect(action.get('[data-testid="app-icon"]').attributes('data-name')).toBe('x'); - - await action.trigger('click'); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ - ...settingsStoreMock.settings.value.searchKeybindings, - 'search.history.open': null, - }); - }); - - it('uses an inline undo icon to restore non-default search shortcuts', async () => { - settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; - const wrapper = mount(GeneralSection); - - await flushPromises(); - - const action = wrapper.get( - '[data-testid="settings-search-shortcut-action-search.history.open"]' - ); - expect(action.attributes('data-shortcut-action')).toBe('restore'); - expect(action.get('[data-testid="app-icon"]').attributes('data-name')).toBe('undo'); - - await action.trigger('click'); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ - ...settingsStoreMock.settings.value.searchKeybindings, - 'search.history.open': 'Mod+H', - }); - }); - - it('restores the default search shortcut from the cleared state', async () => { - const clearedSearchKeybindings = { - ...settingsStoreMock.settings.value.searchKeybindings, - 'search.history.open': null, - }; - settingsStoreMock.settings.value.searchKeybindings = clearedSearchKeybindings; - const wrapper = mount(GeneralSection); - - await flushPromises(); - - expect( - ( - wrapper.get('[data-testid="settings-search-shortcut-input-search.history.open"]') - .element as HTMLInputElement - ).value - ).toBe('无'); - - const action = wrapper.get( - '[data-testid="settings-search-shortcut-action-search.history.open"]' - ); - expect(action.attributes('data-shortcut-action')).toBe('restore'); - expect(action.get('[data-testid="app-icon"]').attributes('data-name')).toBe('undo'); - - await action.trigger('click'); - await flushPromises(); - - expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ - ...clearedSearchKeybindings, - 'search.history.open': 'Mod+H', - }); - }); - - it('localizes the cleared search shortcut fallback', async () => { - setLocale('en-US'); - settingsStoreMock.settings.value.language = 'en-US'; - settingsStoreMock.settings.value.searchKeybindings = { - ...settingsStoreMock.settings.value.searchKeybindings, - 'search.history.open': null, - }; - - const wrapper = mount(GeneralSection); - - await flushPromises(); - - expect( - ( - wrapper.get('[data-testid="settings-search-shortcut-input-search.history.open"]') - .element as HTMLInputElement - ).value - ).toBe('None'); + expect(rowLabels.length).toBeGreaterThanOrEqual(5); }); it('shows the current version in the latest update details', async () => { @@ -1088,20 +474,4 @@ describe('SettingsGeneralSection', () => { expect(wrapper.text()).toContain('暂无更新日志'); }); - - it('shows a compact occupied-shortcut indicator inside the fixed-width control area', async () => { - nativeMock.shortcut.getShortcutStatus.mockResolvedValueOnce([true, 'occupied']); - const wrapper = mount(GeneralSection); - - await flushPromises(); - - expect(wrapper.find('[data-testid="settings-shortcut-error"]').exists()).toBe(false); - expect( - wrapper.get('[data-testid="settings-shortcut-occupied-indicator"]').attributes('title') - ).toBe('快捷键注册失败,可能已被其他应用占用'); - expect(wrapper.find('[data-testid="settings-shortcut-retry-button"]').exists()).toBe(false); - expect(wrapper.find('[data-testid="settings-shortcut-cancel-button"]').exists()).toBe( - false - ); - }); }); diff --git a/apps/desktop/tests/views/SettingsView/settingsWindowView.test.ts b/apps/desktop/tests/views/SettingsView/settingsWindowView.test.ts index f6fe1dc5..57ded6ad 100644 --- a/apps/desktop/tests/views/SettingsView/settingsWindowView.test.ts +++ b/apps/desktop/tests/views/SettingsView/settingsWindowView.test.ts @@ -45,6 +45,13 @@ vi.mock('@/views/SettingsView/components/General/index.vue', () => ({ }, })); +vi.mock('@/views/SettingsView/components/Shortcuts/index.vue', () => ({ + default: { + name: 'ShortcutsView', + template: '
', + }, +})); + vi.mock('@/views/SettingsView/components/AiServices/index.vue', () => ({ default: { name: 'AiServicesView', @@ -198,6 +205,11 @@ describe('SettingsWindowView', () => { expect(loadingState().attributes('fill')).toBe('min'); expect(loadingState().attributes('message')).toBe('正在加载常规设置...'); + nav.vm.$emit('navigate', 'shortcuts'); + await flushPromises(); + expect(loadingState().attributes('message')).toBe('正在加载快捷键设置...'); + expect(loadingState().attributes('variant')).toBe('brand'); + nav.vm.$emit('navigate', 'ai-services'); await flushPromises(); expect(loadingState().attributes('variant')).toBe('brand'); @@ -225,6 +237,10 @@ describe('SettingsWindowView', () => { expect(wrapper.find('general-view-stub').exists()).toBe(true); + nav.vm.$emit('navigate', 'shortcuts'); + await flushPromises(); + expect(wrapper.find('shortcuts-view-stub').exists()).toBe(true); + nav.vm.$emit('navigate', 'ai-services'); await flushPromises(); expect(wrapper.find('ai-services-view-stub').exists()).toBe(true); diff --git a/apps/desktop/tests/views/SettingsView/shortcutsComponent.test.ts b/apps/desktop/tests/views/SettingsView/shortcutsComponent.test.ts new file mode 100644 index 00000000..83f0c7d7 --- /dev/null +++ b/apps/desktop/tests/views/SettingsView/shortcutsComponent.test.ts @@ -0,0 +1,796 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { vi } from 'vitest'; + +import { createDefaultSearchKeybindings } from '@/config/searchKeybindings'; +import { setLocale } from '@/i18n'; +import { AppEvent, eventService } from '@/services/EventService'; +import GeneralSection from '@/views/SettingsView/components/Shortcuts/index.vue'; + +const originalPlatform = navigator.platform; + +function setPlatform(platform: string) { + Object.defineProperty(window.navigator, 'platform', { + configurable: true, + value: platform, + }); +} + +const settingsStoreMock = vi.hoisted(() => { + const createGeneralSettingsMock = (searchKeybindings: Record) => ({ + globalShortcut: 'Alt+Space', + searchKeybindings: { + ...searchKeybindings, + }, + startOnBoot: false, + startMinimized: true, + language: 'zh-CN', + outputScrollBehavior: 'follow_output', + searchWindowSizePreset: 'normal', + searchWindowDefaultSize: { width: 720, height: 520 }, + appUpdateChannel: 'stable', + appUpdateAutoCheck: true, + appUpdateLastCheckedAt: null, + }); + + return { + createGeneralSettingsMock, + settings: { + value: createGeneralSettingsMock({}), + }, + initialize: vi.fn().mockResolvedValue(undefined), + updateGlobalShortcut: vi.fn().mockResolvedValue(undefined), + updateSearchKeybindings: vi.fn().mockResolvedValue(undefined), + updateStartOnBoot: vi.fn().mockResolvedValue(undefined), + updateStartMinimized: vi.fn().mockResolvedValue(undefined), + updateOutputScrollBehavior: vi.fn().mockResolvedValue(undefined), + updateSearchWindowSizePreset: vi.fn().mockResolvedValue(undefined), + updateLanguage: vi.fn().mockResolvedValue(undefined), + updateAppUpdateChannel: vi.fn().mockResolvedValue(undefined), + updateAppUpdateAutoCheck: vi.fn().mockResolvedValue(undefined), + updateAppUpdateLastCheckedAt: vi.fn().mockResolvedValue(undefined), + }; +}); + +const nativeMock = vi.hoisted(() => ({ + shortcut: { + getShortcutStatus: vi.fn().mockResolvedValue([false, null]), + registerGlobalShortcut: vi.fn().mockResolvedValue(undefined), + }, + autostart: { + isAutostartEnabled: vi.fn().mockResolvedValue(false), + enableAutostart: vi.fn().mockResolvedValue(undefined), + disableAutostart: vi.fn().mockResolvedValue(undefined), + }, + window: { + setSearchWindowDefaults: vi.fn().mockResolvedValue(undefined), + }, +})); + +const alertMessageMock = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), +})); + +vi.mock('pinia', () => ({ + storeToRefs: (store: typeof settingsStoreMock) => ({ + settings: store.settings, + }), +})); + +vi.mock('@/stores/settings', () => ({ + useSettingsStore: () => settingsStoreMock, +})); + +vi.mock('@services/NativeService', () => ({ + native: nativeMock, +})); + +vi.mock('@services/NotificationService', () => ({ + notify: vi.fn(), +})); + +vi.mock('@components/AlertMessage.vue', () => ({ + default: { + name: 'AlertMessageStub', + template: '
', + methods: { + success: alertMessageMock.success, + error: alertMessageMock.error, + warning: alertMessageMock.warning, + }, + }, +})); + +vi.mock('@components/AppIcon.vue', () => ({ + default: { + name: 'AppIconStub', + props: ['name'], + template: '', + }, +})); + +vi.mock('@components/CustomSelect.vue', () => ({ + default: { + name: 'CustomSelectStub', + props: ['modelValue', 'options', 'open', 'contentTestId', 'optionTestIdPrefix'], + emits: ['update:modelValue', 'update:open'], + template: ` +
+ + +
+ +
+
+ `, + }, +})); + +describe('SettingsShortcutsSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + setPlatform('Win32'); + setLocale('zh-CN'); + nativeMock.shortcut.getShortcutStatus.mockResolvedValue([false, null]); + nativeMock.autostart.isAutostartEnabled.mockResolvedValue(false); + settingsStoreMock.settings.value = settingsStoreMock.createGeneralSettingsMock( + createDefaultSearchKeybindings() + ); + }); + + afterEach(() => { + setPlatform(originalPlatform); + document.body.innerHTML = ''; + }); + + it('renders the shortcuts settings groups in the browser settings layout', async () => { + const wrapper = mount(GeneralSection); + + await flushPromises(); + + expect(wrapper.get('h1').text()).toBe('快捷键'); + expect(wrapper.get('[data-testid="shortcuts-settings-title"]').text()).toBe('快捷键'); + expect(wrapper.get('.settings-page-header').classes()).toEqual( + expect.arrayContaining(['flex', 'items-start', 'gap-4']) + ); + expect(wrapper.get('.settings-section-description').text()).toBe('全局呼出与搜索快捷键'); + expect(wrapper.find('.settings-page-description').exists()).toBe(false); + expect(wrapper.text()).toContain('快捷键'); + expect(wrapper.text()).toContain('唤起快捷键'); + expect(wrapper.text()).toContain('全局呼出与搜索快捷键'); + expect( + ( + wrapper.get('[data-testid="settings-global-shortcut-input"]') + .element as HTMLInputElement + ).value + ).toBe('Alt+Space'); + const sections = wrapper.findAll('section'); + expect(sections).toHaveLength(2); + expect(sections[0].classes()).toEqual(expect.arrayContaining(['space-y-4'])); + expect(sections[1].classes()).toEqual(expect.arrayContaining(['mt-10', 'space-y-4'])); + expect(wrapper.findAll('.settings-section-title').map((heading) => heading.text())).toEqual( + ['全局唤起', '搜索快捷键'] + ); + + expect(wrapper.text()).toContain('全局呼出快捷键'); + expect(wrapper.text()).not.toContain('Ctrl+Space'); + expect(wrapper.find('[data-testid="settings-shortcut-suggestions"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( + false + ); + expect(wrapper.text()).toContain( + '自定义搜索窗口内的命令型快捷键,不会影响输入导航与全局唤起。' + ); + expect(wrapper.text()).toContain('会话'); + expect(wrapper.text()).toContain('输入与请求'); + expect(wrapper.text()).toContain('窗口'); + expect(wrapper.text()).toContain('打开会话历史'); + expect(wrapper.text()).toContain('快速打开或收起会话历史列表'); + expect(wrapper.text()).toContain('开始新会话'); + expect(wrapper.text()).toContain('切换窗口最大化'); + expect(wrapper.text()).toContain('切换搜索窗口最大化'); + expect(wrapper.text()).toContain('打开设置'); + expect(wrapper.text()).toContain('快速打开设置窗口'); + expect( + ( + wrapper.get('[data-testid="settings-search-shortcut-input-search.window.maximize"]') + .element as HTMLInputElement + ).value + ).toBe('F11'); + expect( + ( + wrapper.get('[data-testid="settings-search-shortcut-input-search.settings.open"]') + .element as HTMLInputElement + ).value + ).toBe('Ctrl+,'); + expect( + ( + wrapper.get('[data-testid="settings-search-shortcut-input-search.request.cancel"]') + .element as HTMLInputElement + ).value + ).toBe('Esc'); + expect( + wrapper + .get('[data-testid="settings-search-shortcut-input-search.history.open"]') + .classes() + ).not.toContain('font-mono'); + expect(wrapper.text()).not.toContain('启动与窗口'); + expect(wrapper.text()).not.toContain('界面语言'); + expect(wrapper.text()).not.toContain('版本更新通道'); + expect(wrapper.text()).not.toContain('快捷唤起'); + expect(wrapper.text()).not.toContain('管理全局唤起'); + expect(wrapper.text()).not.toContain('启动设置'); + expect(wrapper.text()).not.toContain('支持的修饰键'); + expect(wrapper.find('[data-testid="settings-brand-accent"]').exists()).toBe(false); + + const controls = wrapper.findAll('[data-testid="settings-general-control"]'); + expect(controls.length).toBeGreaterThanOrEqual(1); + + const rowLabels = wrapper.findAll('[data-testid="settings-general-row-label"]'); + expect(rowLabels.length).toBeGreaterThanOrEqual(10); + }); + + it('opens global shortcut presets from the shortcut field and saves a preset', async () => { + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); + await input.trigger('focus'); + await flushPromises(); + + expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( + true + ); + expect( + wrapper.get('[data-testid="settings-global-shortcut-preset-Alt+Space"]').text() + ).toBe('Alt+Space'); + expect( + wrapper.get('[data-testid="settings-global-shortcut-preset-Ctrl+Space"]').text() + ).toBe('Ctrl+Space'); + + await wrapper + .get('[data-testid="settings-global-shortcut-preset-Ctrl+Space"]') + .trigger('click'); + await flushPromises(); + + expect(nativeMock.shortcut.registerGlobalShortcut).toHaveBeenCalledWith('Ctrl+Space'); + expect(settingsStoreMock.updateGlobalShortcut).toHaveBeenCalledWith('Ctrl+Space'); + expect((input.element as HTMLInputElement).value).toBe('Ctrl+Space'); + expect(settingsStoreMock.settings.value.globalShortcut).toBe('Ctrl+Space'); + expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( + false + ); + }); + + it('uses macOS shortcut labels and omits input-method-conflicting presets', async () => { + setPlatform('MacIntel'); + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); + expect((input.element as HTMLInputElement).value).toBe('Option+Space'); + + await input.trigger('focus'); + await flushPromises(); + + expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( + true + ); + expect( + wrapper.get('[data-testid="settings-global-shortcut-preset-Option+Space"]').text() + ).toBe('Option+Space'); + expect( + wrapper.get('[data-testid="settings-global-shortcut-preset-Option+Shift+Space"]').text() + ).toBe('Option+Shift+Space'); + expect( + wrapper.find('[data-testid="settings-global-shortcut-preset-Ctrl+Space"]').exists() + ).toBe(false); + + await wrapper + .get('[data-testid="settings-global-shortcut-preset-Option+Shift+Space"]') + .trigger('click'); + await flushPromises(); + + expect(nativeMock.shortcut.registerGlobalShortcut).toHaveBeenCalledWith( + 'Option+Shift+Space' + ); + expect(settingsStoreMock.updateGlobalShortcut).toHaveBeenCalledWith('Option+Shift+Space'); + expect((input.element as HTMLInputElement).value).toBe('Option+Shift+Space'); + }); + + it('blocks macOS system-reserved global shortcuts before registration', async () => { + setPlatform('MacIntel'); + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); + await input.trigger('focus'); + await flushPromises(); + + window.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', metaKey: true }) + ); + await flushPromises(); + + expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); + expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); + expect((input.element as HTMLInputElement).value).toBe('Option+Space'); + + await input.trigger('focus'); + await flushPromises(); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', ctrlKey: true }) + ); + await flushPromises(); + + expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); + expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); + expect((input.element as HTMLInputElement).value).toBe('Option+Space'); + }); + + it('rejects macOS system-reserved search shortcuts', async () => { + setPlatform('MacIntel'); + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + alertMessageMock.error.mockClear(); + + window.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', metaKey: true }) + ); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + expect(alertMessageMock.error).toHaveBeenCalledWith(expect.stringContaining('macOS'), 3000); + expect((input.element as HTMLInputElement).value).toBe('Cmd+H'); + + window.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', code: 'Space', ctrlKey: true }) + ); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + expect((input.element as HTMLInputElement).value).toBe('Cmd+H'); + }); + + it('saves the global shortcut immediately after a shortcut is pressed', async () => { + settingsStoreMock.settings.value.searchKeybindings['search.session.new'] = 'Mod+Shift+N'; + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); + await input.trigger('focus'); + await flushPromises(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', ctrlKey: true })); + await flushPromises(); + + expect(nativeMock.shortcut.registerGlobalShortcut).toHaveBeenCalledWith('Ctrl+N'); + expect(settingsStoreMock.updateGlobalShortcut).toHaveBeenCalledWith('Ctrl+N'); + expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( + false + ); + }); + + it('captures Ctrl+Space from the global shortcut input before the preset menu handles Space', async () => { + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); + await input.trigger('focus'); + await flushPromises(); + + await input.trigger('keydown', { + key: ' ', + code: 'Space', + ctrlKey: true, + }); + await flushPromises(); + + expect(nativeMock.shortcut.registerGlobalShortcut).toHaveBeenCalledWith('Ctrl+Space'); + expect(settingsStoreMock.updateGlobalShortcut).toHaveBeenCalledWith('Ctrl+Space'); + expect((input.element as HTMLInputElement).value).toBe('Ctrl+Space'); + expect(settingsStoreMock.settings.value.globalShortcut).toBe('Ctrl+Space'); + }); + + it('does not capture navigation keys while global shortcut presets are open', async () => { + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); + await input.trigger('focus'); + await flushPromises(); + + const event = new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }); + window.dispatchEvent(event); + await flushPromises(); + + expect(event.defaultPrevented).toBe(false); + expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); + expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); + expect(wrapper.find('[data-testid="settings-global-shortcut-preset-menu"]').exists()).toBe( + true + ); + + wrapper.unmount(); + }); + + it('reports invalid global shortcut attempts only once through the capture stack', async () => { + const wrapper = mount(GeneralSection, { + attachTo: document.body, + }); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); + await input.trigger('focus'); + await flushPromises(); + alertMessageMock.warning.mockClear(); + + const event = new KeyboardEvent('keydown', { + key: 'a', + shiftKey: true, + bubbles: true, + cancelable: true, + }); + input.element.dispatchEvent(event); + await flushPromises(); + + expect(event.defaultPrevented).toBe(true); + expect(alertMessageMock.warning).toHaveBeenCalledTimes(1); + expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); + expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); + + wrapper.unmount(); + }); + + it('does not save a global shortcut that duplicates a search shortcut', async () => { + settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+A'; + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-global-shortcut-input"]'); + await input.trigger('focus'); + await flushPromises(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', ctrlKey: true })); + await flushPromises(); + + expect(nativeMock.shortcut.registerGlobalShortcut).not.toHaveBeenCalled(); + expect(settingsStoreMock.updateGlobalShortcut).not.toHaveBeenCalled(); + expect((input.element as HTMLInputElement).value).toBe('Alt+Space'); + }); + it('accepts modifierless function keys for configurable search shortcuts', async () => { + settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1' })); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': 'F1', + }); + }); + + it('captures Windows system key events while editing a search shortcut', async () => { + settingsStoreMock.settings.value.globalShortcut = 'Ctrl+Space'; + settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + + await eventService.emit(AppEvent.SHORTCUT_CAPTURE_SYSTEM_KEY, { + key: 'Space', + alt: true, + ctrl: false, + shift: false, + }); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': 'Alt+Space', + }); + }); + + it('captures a function-row key by keyboard code when the key value is not an F-key', async () => { + settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'BrightnessUp', code: 'F2' })); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': 'F2', + }); + }); + + it('does not capture navigation keys while editing a search shortcut', async () => { + settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + + const event = new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }); + window.dispatchEvent(event); + await flushPromises(); + + expect(event.defaultPrevented).toBe(false); + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1' })); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': 'F1', + }); + }); + + it('keeps search shortcut capture active after a validation failure', async () => { + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', shiftKey: true })); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1' })); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': 'F1', + }); + }); + + it('captures shifted punctuation shortcuts through their physical key code', async () => { + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: '+', + code: 'Equal', + ctrlKey: true, + shiftKey: true, + }) + ); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': 'Mod+Shift+=', + }); + expect((input.element as HTMLInputElement).value).toBe('Ctrl+Shift+='); + }); + + it('reports unsupported mac command search shortcuts without showing the Windows key warning', async () => { + setPlatform('MacIntel'); + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get( + '[data-testid="settings-search-shortcut-input-search.history.open"]' + ); + await input.trigger('focus'); + await flushPromises(); + alertMessageMock.error.mockClear(); + alertMessageMock.warning.mockClear(); + + window.dispatchEvent(new KeyboardEvent('keydown', { key: '@', metaKey: true })); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + expect(alertMessageMock.warning).not.toHaveBeenCalled(); + expect(alertMessageMock.error).toHaveBeenCalledTimes(1); + expect((input.element as HTMLInputElement).value).toBe('Cmd+H'); + + wrapper.unmount(); + }); + + it('shows fixed search shortcuts as unsupported for editing', async () => { + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const input = wrapper.get('[data-testid="settings-search-shortcut-input-search.submit"]'); + expect(input.attributes('title')).toBe('暂不支持修改该快捷键'); + expect(input.attributes('tabindex')).toBe('-1'); + + await input.trigger('focus'); + await flushPromises(); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2' })); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).not.toHaveBeenCalled(); + expect((input.element as HTMLInputElement).value).toBe('Enter'); + }); + + it('uses an inline x icon to clear a default search shortcut', async () => { + const wrapper = mount(GeneralSection); + + await flushPromises(); + + expect( + wrapper + .find('[data-testid="settings-search-shortcut-reset-search.history.open"]') + .exists() + ).toBe(false); + expect( + wrapper + .find('[data-testid="settings-search-shortcut-disable-search.history.open"]') + .exists() + ).toBe(false); + + const action = wrapper.get( + '[data-testid="settings-search-shortcut-action-search.history.open"]' + ); + expect(action.attributes('data-shortcut-action')).toBe('clear'); + expect(action.get('[data-testid="app-icon"]').attributes('data-name')).toBe('x'); + + await action.trigger('click'); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': null, + }); + }); + + it('uses an inline undo icon to restore non-default search shortcuts', async () => { + settingsStoreMock.settings.value.searchKeybindings['search.history.open'] = 'Mod+Shift+H'; + const wrapper = mount(GeneralSection); + + await flushPromises(); + + const action = wrapper.get( + '[data-testid="settings-search-shortcut-action-search.history.open"]' + ); + expect(action.attributes('data-shortcut-action')).toBe('restore'); + expect(action.get('[data-testid="app-icon"]').attributes('data-name')).toBe('undo'); + + await action.trigger('click'); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': 'Mod+H', + }); + }); + + it('restores the default search shortcut from the cleared state', async () => { + const clearedSearchKeybindings = { + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': null, + }; + settingsStoreMock.settings.value.searchKeybindings = clearedSearchKeybindings; + const wrapper = mount(GeneralSection); + + await flushPromises(); + + expect( + ( + wrapper.get('[data-testid="settings-search-shortcut-input-search.history.open"]') + .element as HTMLInputElement + ).value + ).toBe('无'); + + const action = wrapper.get( + '[data-testid="settings-search-shortcut-action-search.history.open"]' + ); + expect(action.attributes('data-shortcut-action')).toBe('restore'); + expect(action.get('[data-testid="app-icon"]').attributes('data-name')).toBe('undo'); + + await action.trigger('click'); + await flushPromises(); + + expect(settingsStoreMock.updateSearchKeybindings).toHaveBeenCalledWith({ + ...clearedSearchKeybindings, + 'search.history.open': 'Mod+H', + }); + }); + + it('localizes the cleared search shortcut fallback', async () => { + setLocale('en-US'); + settingsStoreMock.settings.value.language = 'en-US'; + settingsStoreMock.settings.value.searchKeybindings = { + ...settingsStoreMock.settings.value.searchKeybindings, + 'search.history.open': null, + }; + + const wrapper = mount(GeneralSection); + + await flushPromises(); + + expect( + ( + wrapper.get('[data-testid="settings-search-shortcut-input-search.history.open"]') + .element as HTMLInputElement + ).value + ).toBe('None'); + }); + + it('shows a compact occupied-shortcut indicator inside the fixed-width control area', async () => { + nativeMock.shortcut.getShortcutStatus.mockResolvedValueOnce([true, 'occupied']); + const wrapper = mount(GeneralSection); + + await flushPromises(); + + expect(wrapper.find('[data-testid="settings-shortcut-error"]').exists()).toBe(false); + expect( + wrapper.get('[data-testid="settings-shortcut-occupied-indicator"]').attributes('title') + ).toBe('快捷键注册失败,可能已被其他应用占用'); + expect(wrapper.find('[data-testid="settings-shortcut-retry-button"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="settings-shortcut-cancel-button"]').exists()).toBe( + false + ); + }); +}); From fe48ab4a19eb792bc2a09ea92bb70a08fb6c6767 Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:04:40 +0800 Subject: [PATCH 55/55] fix(desktop): align shortcut settings headings --- apps/desktop/src/i18n/messages.ts | 22 +++++++------------ .../General/SearchShortcutSettings.vue | 8 +++---- .../components/Shortcuts/index.vue | 12 ++-------- .../SettingsView/shortcutsComponent.test.ts | 17 ++++++-------- 4 files changed, 21 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/i18n/messages.ts b/apps/desktop/src/i18n/messages.ts index 473ef12a..533dea7f 100644 --- a/apps/desktop/src/i18n/messages.ts +++ b/apps/desktop/src/i18n/messages.ts @@ -91,12 +91,9 @@ const zhCNMessages = { 'settings.general.shortcutSaved': '快捷键保存成功', 'settings.general.noShortcut': '无', 'settings.general.globalShortcutGroup': '全局唤起', - 'settings.general.searchShortcuts': '搜索快捷键', - 'settings.general.searchShortcutsDescription': - '自定义搜索窗口内的命令型快捷键,不会影响输入导航与全局唤起。', - 'settings.general.searchShortcutGroups.session': '会话', - 'settings.general.searchShortcutGroups.inputAndRequest': '输入与请求', - 'settings.general.searchShortcutGroups.window': '窗口', + 'settings.general.searchShortcutGroups.session': '会话管理', + 'settings.general.searchShortcutGroups.inputAndRequest': '输入请求', + 'settings.general.searchShortcutGroups.window': '窗口控制', 'settings.general.searchActions.history': '打开会话历史', 'settings.general.searchActions.focusInput': '聚焦输入框', 'settings.general.searchActions.newSession': '开始新会话', @@ -217,7 +214,7 @@ const zhCNMessages = { 'settings.nav.general.label': '通用', 'settings.nav.general.description': '启动、对话和窗口偏好', 'settings.nav.shortcuts.label': '快捷键', - 'settings.nav.shortcuts.description': '全局呼出与搜索快捷键', + 'settings.nav.shortcuts.description': '全局唤起与窗口命令', 'settings.nav.aiServices.label': '服务商与模型', 'settings.nav.aiServices.description': 'Provider、模型、默认模型和密钥', 'settings.nav.builtInTools.label': '内置工具', @@ -1066,12 +1063,9 @@ const enUSMessages: Record = { 'settings.general.shortcutSaved': 'Shortcut saved', 'settings.general.noShortcut': 'None', 'settings.general.globalShortcutGroup': 'Global activation', - 'settings.general.searchShortcuts': 'Search shortcuts', - 'settings.general.searchShortcutsDescription': - 'Customize command shortcuts inside the search window without changing typing, navigation, or the global activation shortcut.', - 'settings.general.searchShortcutGroups.session': 'Session', - 'settings.general.searchShortcutGroups.inputAndRequest': 'Input and request', - 'settings.general.searchShortcutGroups.window': 'Window', + 'settings.general.searchShortcutGroups.session': 'Session management', + 'settings.general.searchShortcutGroups.inputAndRequest': 'Input requests', + 'settings.general.searchShortcutGroups.window': 'Window control', 'settings.general.searchActions.history': 'Open session history', 'settings.general.searchActions.focusInput': 'Focus input', 'settings.general.searchActions.newSession': 'Start new session', @@ -1207,7 +1201,7 @@ const enUSMessages: Record = { 'settings.nav.general.label': 'General', 'settings.nav.general.description': 'Startup, conversation, and window preferences', 'settings.nav.shortcuts.label': 'Shortcuts', - 'settings.nav.shortcuts.description': 'Global activation and search shortcuts', + 'settings.nav.shortcuts.description': 'Global activation and window commands', 'settings.nav.aiServices.label': 'Providers and models', 'settings.nav.aiServices.description': 'Providers, models, default model, and keys', 'settings.nav.builtInTools.label': 'Built-in tools', diff --git a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue index 312fff39..a4dceaff 100644 --- a/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue +++ b/apps/desktop/src/views/SettingsView/components/General/SearchShortcutSettings.vue @@ -635,10 +635,10 @@