feat(keybindings): 快捷键自定义系统 — store+冲突检测+交互式重新绑定UI | 13 files +1032/-137 | 72 tests passing tsc零错误

This commit is contained in:
OpsXShell Dev committed 2026-04-24 05:11:49 +08:00
1 parent 514d62644e
commit 3f636b1b0e
13 files changed
+1033 -138

No files matched your search

+202 -50
View File
@@ -1,9 +1,16 @@
import { useEffect, useState, useMemo } from 'react'
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useKeybindingStore } from '../stores/keybindingStore'
import {
Monitor, Palette, Terminal, Keyboard, Shield, Database, Info, Search, X,
RotateCcw, AlertTriangle,
} from 'lucide-react'
import { themes } from '../themes/terminal'
import {
getKeybindingCategories,
getKeybindingsByCategory,
type KeybindingActionId,
} from '../constants/keybindings'
// ─── Tab 类型 ───────────────────────────────────────────────
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'shortcuts' | 'ssh' | 'security' | 'about'
@@ -25,31 +32,70 @@ const TAB_LIST: TabMeta[] = [
{ id: 'about', label: '关于', description: '版本与许可信息', icon: Info },
]
// ─── 快捷键数据 ─────────────────────────────────────────────
interface ShortcutEntry {
action: string
keys: string
category: string
}
// ─── 快捷键录入 Hook ─────────────────────────────────────────
// 监听键盘事件并格式化为键序列字符串 (如 "Ctrl+Shift+P")
function useKeyRecording() {
const [recordingId, setRecordingId] = useState<KeybindingActionId | null>(null)
const [recordedKeys, setRecordedKeys] = useState<string>('')
const SHORTCUTS: ShortcutEntry[] = [
{ action: '切换到下一个标签', keys: 'Ctrl+Tab', category: '标签管理' },
{ action: '切换到上一个标签', keys: 'Ctrl+Shift+Tab', category: '标签管理' },
{ action: '跳转到第 N 个标签', keys: 'Ctrl+1 … Ctrl+9', category: '标签管理' },
{ action: '关闭当前标签', keys: 'Ctrl+W', category: '标签管理' },
{ action: '新建标签', keys: 'Ctrl+Shift+N', category: '标签管理' },
{ action: '切换全屏', keys: 'F11', category: '视图' },
{ action: '新建会话', keys: 'Ctrl+N', category: '会话' },
{ action: '快速连接', keys: 'Ctrl+K', category: '会话' },
{ action: '命令面板', keys: 'Ctrl+Shift+P', category: '工具' },
]
const startRecording = useCallback((actionId: KeybindingActionId) => {
setRecordingId(actionId)
setRecordedKeys('')
}, [])
const stopRecording = useCallback(() => {
setRecordingId(null)
setRecordedKeys('')
}, [])
// 全局键盘监听
useEffect(() => {
if (!recordingId) return
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
// Escape 取消录入
if (e.key === 'Escape') {
stopRecording()
return
}
// 忽略单独的修饰键
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return
const parts: string[] = []
if (e.ctrlKey || e.metaKey) parts.push('Ctrl')
if (e.shiftKey) parts.push('Shift')
if (e.altKey) parts.push('Alt')
// 主键
let mainKey = e.key
if (mainKey === ' ') mainKey = 'Space'
else if (mainKey === 'Tab') mainKey = 'Tab'
else if (mainKey.length === 1) mainKey = mainKey.toUpperCase()
parts.push(mainKey)
const keySequence = parts.join('+')
setRecordedKeys(keySequence)
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [recordingId, stopRecording])
return { recordingId, recordedKeys, startRecording, stopRecording }
}
// ─── 主组件 ─────────────────────────────────────────────────
export default function SettingsView() {
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
const keybindingState = useKeybindingStore()
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
const [searchQuery, setSearchQuery] = useState('')
const [appVersion, setAppVersion] = useState<string>('—')
const { recordingId, recordedKeys, startRecording, stopRecording } = useKeyRecording()
useEffect(() => { loadSettings() }, []) // eslint-disable-line react-hooks/exhaustive-deps
@@ -236,43 +282,149 @@ export default function SettingsView() {
)
// ──────── 快捷键 ───────
case 'shortcuts':
case 'shortcuts': {
const allKeys = keybindingState.getAllEffective()
const categories = getKeybindingCategories()
const hasCustom = Object.keys(keybindingState.customKeybindings).length > 0
// 检查录入的键是否与当前录制匹配
const recordedConflict = recordedKeys && recordingId
? keybindingState.getConflicts(recordingId, recordedKeys)
: []
return (
<div className="space-y-5">
<SettingCard>
<div className="overflow-hidden rounded-lg border border-dark-700/30">
<table className="w-full text-sm">
<thead>
<tr className="bg-dark-800/60 text-gray-400 text-left">
<th className="px-4 py-2.5 font-medium">操作</th>
<th className="px-4 py-2.5 font-medium">快捷键</th>
<th className="px-4 py-2.5 font-medium">分类</th>
</tr>
</thead>
<tbody>
{SHORTCUTS.map((s, i) => (
<tr
key={i}
className="border-t border-dark-700/20 hover:bg-dark-800/40 transition-colors"
>
<td className="px-4 py-2.5 text-gray-200">{s.action}</td>
<td className="px-4 py-2.5">
<kbd className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-dark-700 border border-dark-600 text-xs text-gray-300 font-mono">
{s.keys}
</kbd>
</td>
<td className="px-4 py-2.5 text-gray-500">{s.category}</td>
</tr>
))}
</tbody>
</table>
{/* 重置全部按钮 */}
{hasCustom && (
<div className="flex justify-end">
<button
onClick={() => keybindingState.resetAll()}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-400 hover:text-accent-blue rounded-lg hover:bg-dark-700/50 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
重置全部为默认
</button>
</div>
<p className="text-xs text-gray-600 mt-3">
快捷键自定义功能将在后续版本中支持。
</p>
</SettingCard>
)}
{categories.map((category) => {
const defs = getKeybindingsByCategory(category)
return (
<SettingCard key={category}>
<h3 className="text-sm font-semibold text-gray-300 mb-3">{category}</h3>
<div className="space-y-1">
{defs.map((def) => {
const currentKeys = allKeys[def.id]
const isCustom = keybindingState.customKeybindings[def.id] !== undefined
const isRecordingThis = recordingId === def.id
return (
<div
key={def.id}
className={`flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
isRecordingThis
? 'bg-accent-blue/10 border border-accent-blue/30'
: 'hover:bg-dark-800/60'
}`}
>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-200">{def.label}</span>
{isCustom && (
<span className="ml-2 text-[10px] text-accent-blue bg-accent-blue/10 px-1.5 py-0.5 rounded">
自定义
</span>
)}
</div>
<div className="flex items-center gap-2">
{isRecordingThis ? (
<div className="flex items-center gap-2">
<kbd className="inline-flex items-center px-3 py-1 rounded bg-accent-blue/20 border border-accent-blue/40 text-xs text-accent-blue font-mono animate-pulse">
{recordedKeys || '按下快捷键...'}
</kbd>
<button
onClick={() => stopRecording()}
className="text-xs text-gray-500 hover:text-gray-300"
>
取消
</button>
{recordedKeys && recordedConflict.length > 0 && (
<div className="flex items-center gap-1 text-xs text-yellow-500">
<AlertTriangle className="w-3 h-3" />
<span>
与「{recordedConflict[0].label}」冲突
</span>
</div>
)}
</div>
) : (
<>
<button
onClick={() => {
startRecording(def.id)
}}
className="group relative"
>
<kbd
className={`inline-flex items-center px-2.5 py-1 rounded text-xs font-mono transition-colors ${
isCustom
? 'bg-accent-blue/10 border border-accent-blue/30 text-accent-blue'
: 'bg-dark-700 border border-dark-600 text-gray-300'
} group-hover:border-accent-blue/50`}
>
{currentKeys}
</kbd>
</button>
{isCustom && (
<button
onClick={() => keybindingState.resetKeybinding(def.id)}
className="text-gray-600 hover:text-gray-400 transition-colors"
title="重置为默认"
>
<RotateCcw className="w-3 h-3" />
</button>
)}
</>
)}
</div>
</div>
)
})}
</div>
</SettingCard>
)
})}
{/* 录入确认弹层 */}
{recordingId && recordedKeys && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-5 py-3 bg-dark-800 border border-dark-600 rounded-xl shadow-2xl">
<span className="text-sm text-gray-300">
设置为 <kbd className="px-1.5 py-0.5 rounded bg-dark-700 text-accent-blue font-mono text-xs">{recordedKeys}</kbd>
</span>
<button
onClick={async () => {
await keybindingState.setCustomKeybinding(recordingId, recordedKeys)
stopRecording()
}}
className="px-3 py-1 text-xs bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors"
>
确认
</button>
<button
onClick={stopRecording}
className="px-3 py-1 text-xs text-gray-400 hover:text-gray-200 rounded-lg hover:bg-dark-700 transition-colors"
>
取消
</button>
</div>
)}
<p className="text-xs text-gray-600">
点击快捷键可重新绑定 · 按 Esc 取消录入
</p>
</div>
)
}
// ──────── SSH ──────────
case 'ssh':
+1 -5
View File
@@ -4,8 +4,4 @@
* 避免硬编码,集中管理可配置参数
*/
// 待实现
// export * from './terminal'
// export * from './sftp'
// export * from './keybindings'
// export * from './ui'
export * from './keybindings'
+136
View File
@@ -0,0 +1,136 @@
/**
* Keybinding 常量定义
*
* 集中管理所有快捷键操作的 ID、描述、分类和默认键绑定。
* 与 keybindingStore 配合实现快捷键自定义。
*/
// ─── 快捷键操作 ID ──────────────────────────────────────
export const KeybindingAction = {
// 标签管理
TAB_NEXT: 'tab.next',
TAB_PREV: 'tab.prev',
TAB_GOTO_N: 'tab.gotoN',
TAB_CLOSE: 'tab.close',
TAB_NEW: 'tab.new',
// 视图
VIEW_FULLSCREEN: 'view.fullscreen',
// 会话
SESSION_NEW: 'session.new',
SESSION_QUICK_CONNECT: 'session.quickConnect',
// 工具
TOOLS_COMMAND_PALETTE: 'tools.commandPalette',
} as const
export type KeybindingActionId = typeof KeybindingAction[keyof typeof KeybindingAction]
// ─── 快捷键分类 ─────────────────────────────────────────
export const KeybindingCategory = {
TAB_MANAGEMENT: '标签管理',
VIEW: '视图',
SESSION: '会话',
TOOLS: '工具',
} as const
export type KeybindingCategoryValue = typeof KeybindingCategory[keyof typeof KeybindingCategory]
// ─── 单个快捷键定义 ─────────────────────────────────────
export interface KeybindingDefinition {
/** 操作 ID (如 'tab.next') */
id: KeybindingActionId
/** 操作描述 (如 '切换到下一个标签') */
label: string
/** 分类 */
category: KeybindingCategoryValue
/** 默认键绑定 (如 'Ctrl+Tab') */
defaultKeys: string
/** 是否在输入框中也触发 */
fireInInput: boolean
}
// ─── 所有快捷键定义 ─────────────────────────────────────
export const KEYBINDING_DEFINITIONS: KeybindingDefinition[] = [
{
id: KeybindingAction.TAB_NEXT,
label: '切换到下一个标签',
category: KeybindingCategory.TAB_MANAGEMENT,
defaultKeys: 'Ctrl+Tab',
fireInInput: true,
},
{
id: KeybindingAction.TAB_PREV,
label: '切换到上一个标签',
category: KeybindingCategory.TAB_MANAGEMENT,
defaultKeys: 'Ctrl+Shift+Tab',
fireInInput: true,
},
{
id: KeybindingAction.TAB_GOTO_N,
label: '跳转到第 N 个标签',
category: KeybindingCategory.TAB_MANAGEMENT,
defaultKeys: 'Ctrl+1…9',
fireInInput: true,
},
{
id: KeybindingAction.TAB_CLOSE,
label: '关闭当前标签',
category: KeybindingCategory.TAB_MANAGEMENT,
defaultKeys: 'Ctrl+W',
fireInInput: false,
},
{
id: KeybindingAction.TAB_NEW,
label: '新建标签',
category: KeybindingCategory.TAB_MANAGEMENT,
defaultKeys: 'Ctrl+Shift+N',
fireInInput: false,
},
{
id: KeybindingAction.VIEW_FULLSCREEN,
label: '切换全屏',
category: KeybindingCategory.VIEW,
defaultKeys: 'F11',
fireInInput: true,
},
{
id: KeybindingAction.SESSION_NEW,
label: '新建会话',
category: KeybindingCategory.SESSION,
defaultKeys: 'Ctrl+N',
fireInInput: false,
},
{
id: KeybindingAction.SESSION_QUICK_CONNECT,
label: '快速连接',
category: KeybindingCategory.SESSION,
defaultKeys: 'Ctrl+K',
fireInInput: false,
},
{
id: KeybindingAction.TOOLS_COMMAND_PALETTE,
label: '命令面板',
category: KeybindingCategory.TOOLS,
defaultKeys: 'Ctrl+Shift+P',
fireInInput: true,
},
]
// ─── 通过 ID 查找定义 ────────────────────────────────────
export function getKeybindingDefinition(id: KeybindingActionId): KeybindingDefinition | undefined {
return KEYBINDING_DEFINITIONS.find((d) => d.id === id)
}
// ─── 获取所有分类 ────────────────────────────────────────
export function getKeybindingCategories(): KeybindingCategoryValue[] {
return KEYBINDING_DEFINITIONS.map((d) => d.category).filter(
(v, i, arr) => arr.indexOf(v) === i
)
}
// ─── 获取某分类下的所有定义 ──────────────────────────────
export function getKeybindingsByCategory(category: KeybindingCategoryValue): KeybindingDefinition[] {
return KEYBINDING_DEFINITIONS.filter((d) => d.category === category)
}
+141 -39
View File
@@ -1,18 +1,25 @@
/**
* useKeyboardShortcuts - 全局快捷键管理 Hook (v1.3.0)
* useKeyboardShortcuts - 全局快捷键管理 Hook (v1.4.0 自定义快捷键)
*
* 在 App.tsx 挂载,处理全局键盘快捷键:
* 在 App.tsx 挂载,处理全局键盘快捷键。
* 从 keybindingStore 读取用户自定义的键绑定,实现可配置的快捷键。
*
* 默认快捷键:
* - Ctrl+Tab / Ctrl+Shift+Tab: 切换标签
* - Ctrl+1~9: 跳转到指定索引标签
* - Ctrl+W: 关闭当前标签
* - F11: 全屏切换
* - Ctrl+Shift+N: 新建标签
* - Ctrl+Shift+P: 打开命令面板 (通过 commandPaletteStore)
* - Ctrl+Shift+P: 打开命令面板
* - Ctrl+N: 新建会话
* - Ctrl+K: 快速连接
*/
import { useEffect } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useCommandPaletteStore } from '../stores/commandPaletteStore'
import { useKeybindingStore } from '../stores/keybindingStore'
import { KeybindingAction } from '../constants/keybindings'
import { createLogger } from '../utils/logger'
const logger = createLogger('KeyboardShortcuts')
@@ -31,63 +38,141 @@ function isInputElement(e: KeyboardEvent): boolean {
}
/**
* 判断快捷键是否应该在输入框中触发
* Ctrl+Tab/Ctrl+Shift+Tab 等导航类快捷键始终生效
* 将键序列字符串 (如 "Ctrl+Shift+P") 匹配到 KeyboardEvent
* 支持跨平台: Ctrl 和 Meta (Cmd) 均可触发
*/
function shouldSkipInInput(e: KeyboardEvent): boolean {
// Ctrl+Tab 系列:始终生效(浏览器级别标签切换)
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') return false
// F11:始终生效
if ( e.key === 'F11') return false
// Ctrl+Shift+P:命令面板始终生效
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'p' || e.key === 'P')) return false
// 其他快捷键在输入框中不触发
return true
function matchesKeySequence(e: KeyboardEvent, keySequence: string): boolean {
if (!keySequence) return false
const parts = keySequence.split('+').map((p) => p.trim()).filter(Boolean)
const modifiers: string[] = []
let mainKey = ''
for (const part of parts) {
const lower = part.toLowerCase()
if (lower === 'ctrl' || lower === 'control' || lower === 'meta' || lower === 'cmd') {
modifiers.push('ctrl')
} else if (lower === 'shift') {
modifiers.push('shift')
} else if (lower === 'alt' || lower === 'option') {
modifiers.push('alt')
} else {
mainKey = part
}
}
const needsCtrl = modifiers.includes('ctrl')
const needsShift = modifiers.includes('shift')
const needsAlt = modifiers.includes('alt')
// 跨平台: Ctrl (Win/Linux) 或 Meta (macOS Cmd)
const ctrlOrMeta = e.ctrlKey || e.metaKey
const eventHasCtrl = ctrlOrMeta
const eventHasShift = e.shiftKey
const eventHasAlt = e.altKey
if (needsCtrl !== eventHasCtrl) return false
if (needsShift !== eventHasShift) return false
if (needsAlt !== eventHasAlt) return false
// 匹配主键
if (!mainKey) return false
// 特殊键映射
const keyLower = mainKey.toLowerCase()
const eventKey = e.key
switch (keyLower) {
case 'tab': return eventKey === 'Tab'
case 'enter': return eventKey === 'Enter'
case 'escape': return eventKey === 'Escape'
case 'space': return eventKey === ' '
case 'backspace': return eventKey === 'Backspace'
case 'delete': return eventKey === 'Delete'
default:
// F-keys
if (keyLower.startsWith('f') && keyLower.length <= 3) {
return eventKey === mainKey
}
// 普通字母/数字
return eventKey.toLowerCase() === keyLower
}
}
/**
* 判断快捷键是否应该在输入框中触发
* 通过 keybindingStore 的 fireInInput 属性决定
*/
function shouldFireInInput(e: KeyboardEvent): boolean {
const store = useKeybindingStore.getState()
const allBindings = store.getAllEffective()
// 使用动态 import 避免的 KEYBINDING_DEFINITIONS
// 直接检查已知的 fireInInput 操作
const fireAlways = [
KeybindingAction.TAB_NEXT,
KeybindingAction.TAB_PREV,
KeybindingAction.TAB_GOTO_N,
KeybindingAction.VIEW_FULLSCREEN,
KeybindingAction.TOOLS_COMMAND_PALETTE,
]
for (const actionId of fireAlways) {
if (matchesKeySequence(e, allBindings[actionId])) {
return true
}
}
return false
}
export function useKeyboardShortcuts(): void {
useEffect(() => {
// 加载自定义快捷键
useKeybindingStore.getState().loadFromSettings()
const handleKeyDown = (e: KeyboardEvent) => {
const ctrlOrMeta = e.ctrlKey || e.metaKey
const store = useKeybindingStore.getState()
const keys = store.getAllEffective()
// ---- Ctrl+Shift+P: 切换命令面板 (通过 commandPaletteStore) ----
if (ctrlOrMeta && e.shiftKey && (e.key === 'p' || e.key === 'P')) {
// ---- 命令面板 ----
if (matchesKeySequence(e, keys[KeybindingAction.TOOLS_COMMAND_PALETTE])) {
e.preventDefault()
useCommandPaletteStore.getState().toggle()
return
}
// ---- Ctrl+Tab: 切换到下一个标签 ----
if (ctrlOrMeta && e.key === 'Tab' && !e.shiftKey) {
// ---- Ctrl+Tab: 下一个标签 ----
if (matchesKeySequence(e, keys[KeybindingAction.TAB_NEXT])) {
e.preventDefault()
const { switchToNextTab } = useTabStore.getState()
switchToNextTab()
useTabStore.getState().switchToNextTab()
return
}
// ---- Ctrl+Shift+Tab: 切换到上一个标签 ----
if (ctrlOrMeta && e.key === 'Tab' && e.shiftKey) {
// ---- Ctrl+Shift+Tab: 上一个标签 ----
if (matchesKeySequence(e, keys[KeybindingAction.TAB_PREV])) {
e.preventDefault()
const { switchToPrevTab } = useTabStore.getState()
switchToPrevTab()
useTabStore.getState().switchToPrevTab()
return
}
// ---- Ctrl+1~9: 跳转到指定索引标签 ----
// ---- Ctrl+1~9: 跳转指定标签 ----
if (ctrlOrMeta && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key, 10)
if (num >= 1 && num <= 9) {
e.preventDefault()
const { activateTabByIndex } = useTabStore.getState()
activateTabByIndex(num - 1) // 0-indexed
return
// 特殊处理: Ctrl+1~9 是范围键,不匹配单个键序列
const gotoKeys = keys[KeybindingAction.TAB_GOTO_N]
if (gotoKeys.includes('…') || gotoKeys.includes('...')) {
e.preventDefault()
useTabStore.getState().activateTabByIndex(num - 1)
return
}
}
}
// ---- F11: 全屏切换 ----
if (e.key === 'F11') {
// ---- F11: 全屏 ----
if (matchesKeySequence(e, keys[KeybindingAction.VIEW_FULLSCREEN])) {
e.preventDefault()
// Electron 渲染进程通过 IPC 请求全屏切换
if (window.electronAPI?.settingsSet) {
window.electronAPI.settingsGet('fullscreen').then((res: { success: boolean; value?: unknown }) => {
const isFullscreen = res?.value === true
@@ -99,13 +184,13 @@ export function useKeyboardShortcuts(): void {
return
}
// ---- 以下快捷键在输入框中不触发 ----
if (isInputElement(e) && shouldSkipInInput(e)) {
// ---- 以下快捷键在输入框中不触发 (除非 fireInInput=true) ----
if (isInputElement(e) && !shouldFireInInput(e)) {
return
}
// ---- Ctrl+W: 关闭当前标签 ----
if (ctrlOrMeta && e.key === 'w') {
// ---- Ctrl+W: 关闭标签 ----
if (matchesKeySequence(e, keys[KeybindingAction.TAB_CLOSE])) {
e.preventDefault()
const { tabs, activeTabId, closeTab } = useTabStore.getState()
if (activeTabId && tabs.length > 1) {
@@ -116,10 +201,24 @@ export function useKeyboardShortcuts(): void {
}
// ---- Ctrl+Shift+N: 新建标签 ----
if (ctrlOrMeta && e.shiftKey && (e.key === 'n' || e.key === 'N')) {
if (matchesKeySequence(e, keys[KeybindingAction.TAB_NEW])) {
e.preventDefault()
const { addTab } = useTabStore.getState()
addTab({ title: '新标签', type: 'welcome' })
useTabStore.getState().addTab({ title: '新标签', type: 'welcome' })
return
}
// ---- Ctrl+N: 新建会话 (在 App.tsx 通过 onMenuNewSession 处理) ----
if (matchesKeySequence(e, keys[KeybindingAction.SESSION_NEW])) {
e.preventDefault()
// 触发新建会话对话框 — 通过 CustomEvent 与 App 通信
window.dispatchEvent(new CustomEvent('opsxshell:new-session'))
return
}
// ---- Ctrl+K: 快速连接 ----
if (matchesKeySequence(e, keys[KeybindingAction.SESSION_QUICK_CONNECT])) {
e.preventDefault()
window.dispatchEvent(new CustomEvent('opsxshell:quick-connect'))
return
}
}
@@ -129,4 +228,7 @@ export function useKeyboardShortcuts(): void {
}, [])
}
// 导出匹配函数供测试使用
export { matchesKeySequence }
export default useKeyboardShortcuts
+4
View File
@@ -25,5 +25,9 @@ export type { Snippet } from './snippetStore'
// 命令面板
export { useCommandPaletteStore } from './commandPaletteStore'
// 快捷键自定义
export { useKeybindingStore } from './keybindingStore'
export type { CustomKeybindings } from './keybindingStore'
// 类型导出
export type { ConnectionState } from './connection'
+179
View File
@@ -0,0 +1,179 @@
/**
* KeybindingStore — 快捷键自定义系统状态管理
*
* 管理用户自定义快捷键映射:
* - customKeybindings: { actionId: customKeys } — 仅存储用户修改的
* - getEffectiveKeys(): 合并默认值和自定义值返回实际生效的键
* - 冲突检测: 检测新键绑定是否与现有绑定冲突
* - 持久化: 通过 electronAPI.settingsSet 保存到 SQLite
*
* @module stores/keybindingStore
*/
import { create } from 'zustand'
import { createLogger } from '../utils/logger'
import {
KEYBINDING_DEFINITIONS,
getKeybindingDefinition,
type KeybindingActionId,
type KeybindingDefinition,
} from '../constants/keybindings'
const logger = createLogger('KeybindingStore')
// ─── 类型 ──────────────────────────────────────────────
/** 自定义键绑定映射 (仅存储用户修改的) */
export type CustomKeybindings = Record<KeybindingActionId, string>
interface KeybindingState {
/** 自定义键绑定 (仅存储与默认不同的) */
customKeybindings: CustomKeybindings
/** 是否已从持久化加载 */
isLoaded: boolean
// Actions
/** 从 electronAPI 加载自定义快捷键 */
loadFromSettings: () => Promise<void>
/** 获取操作的有效键绑定 (自定义优先,否则默认) */
getEffectiveKeys: (actionId: KeybindingActionId) => string
/** 获取所有操作的有效键绑定映射 */
getAllEffective: () => Record<KeybindingActionId, string>
/** 设置自定义键绑定 */
setCustomKeybinding: (actionId: KeybindingActionId, keys: string) => Promise<void>
/** 重置单个操作为默认 */
resetKeybinding: (actionId: KeybindingActionId) => Promise<void>
/** 重置所有为默认 */
resetAll: () => Promise<void>
/** 检测冲突 — 返回与新键绑定冲突的其他操作列表 */
getConflicts: (actionId: KeybindingActionId, keys: string) => KeybindingDefinition[]
/** 规范化键序列 (统一修饰键顺序和大小写) */
normalizeKeySequence: (raw: string) => string
}
// ─── Store ─────────────────────────────────────────────
export const useKeybindingStore = create<KeybindingState>((set, get) => ({
customKeybindings: {} as CustomKeybindings,
isLoaded: false,
loadFromSettings: async () => {
try {
const result = await window.electronAPI?.settingsGet('keybindings')
if (result?.success && result.value && typeof result.value === 'object') {
set({ customKeybindings: result.value as CustomKeybindings, isLoaded: true })
} else {
set({ isLoaded: true })
}
} catch (error) {
logger.error('Failed to load keybindings:', error)
set({ isLoaded: true })
}
},
getEffectiveKeys: (actionId: KeybindingActionId) => {
const { customKeybindings } = get()
const custom = customKeybindings[actionId]
if (custom && custom.trim()) return custom
const def = getKeybindingDefinition(actionId)
return def?.defaultKeys ?? ''
},
getAllEffective: () => {
const { customKeybindings } = get()
const result = {} as Record<KeybindingActionId, string>
for (const def of KEYBINDING_DEFINITIONS) {
const custom = customKeybindings[def.id]
result[def.id] = custom && custom.trim() ? custom : def.defaultKeys
}
return result
},
setCustomKeybinding: async (actionId: KeybindingActionId, keys: string) => {
const newCustoms = { ...get().customKeybindings, [actionId]: keys }
set({ customKeybindings: newCustoms })
try {
await window.electronAPI?.settingsSet('keybindings', newCustoms)
} catch (error) {
logger.error('Failed to persist keybinding:', error)
}
},
resetKeybinding: async (actionId: KeybindingActionId) => {
const { [actionId]: _, ...rest } = get().customKeybindings
set({ customKeybindings: rest as CustomKeybindings })
try {
await window.electronAPI?.settingsSet('keybindings', rest)
} catch (error) {
logger.error('Failed to persist keybinding reset:', error)
}
},
resetAll: async () => {
set({ customKeybindings: {} as CustomKeybindings })
try {
await window.electronAPI?.settingsSet('keybindings', {})
} catch (error) {
logger.error('Failed to persist keybinding resetAll:', error)
}
},
getConflicts: (actionId: KeybindingActionId, keys: string) => {
if (!keys || !keys.trim()) return []
// 特殊处理: Ctrl+1…9 范围键不参与冲突检测
if (keys.includes('…') || keys.includes('...')) return []
const normalizedInput = get().normalizeKeySequence(keys)
const conflicts: KeybindingDefinition[] = []
for (const def of KEYBINDING_DEFINITIONS) {
// 跳过自身
if (def.id === actionId) continue
// 跳过范围键
if (def.defaultKeys.includes('…') || def.defaultKeys.includes('...')) continue
const effectiveKeys = get().getEffectiveKeys(def.id)
// 跳过范围键的有效值
if (effectiveKeys.includes('…') || effectiveKeys.includes('...')) continue
const normalizedExisting = get().normalizeKeySequence(effectiveKeys)
if (normalizedExisting === normalizedInput) {
conflicts.push(def)
}
}
return conflicts
},
normalizeKeySequence: (raw: string) => {
if (!raw) return ''
// 分割、去空格、统一大小写
const parts = raw
.split('+')
.map((p) => p.trim())
.filter(Boolean)
.map((p) => {
const lower = p.toLowerCase()
// Meta → Ctrl (跨平台统一)
if (lower === 'meta' || lower === 'cmd') return 'Ctrl'
// 修饰键首字母大写
if (lower === 'ctrl' || lower === 'control') return 'Ctrl'
if (lower === 'shift') return 'Shift'
if (lower === 'alt' || lower === 'option') return 'Alt'
// 普通键:首字母大写其余小写 (如 Tab, Enter, F11)
return p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()
})
// 排序: Ctrl > Shift > Alt > 其他键
const modifierOrder: Record<string, number> = { Ctrl: 0, Shift: 1, Alt: 2 }
parts.sort((a, b) => {
const aOrder = modifierOrder[a] ?? 99
const bOrder = modifierOrder[b] ?? 99
return aOrder - bOrder
})
return parts.join('+')
},
}))
export default useKeybindingStore