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/25] 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/25] 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 @@ -