diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 89f8730..8de77cc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -32,7 +32,11 @@ "Bash(python3 -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(f'{i[\\\\\"title\\\\\"]}: {i[\\\\\"url\\\\\"]}'\\) for i in data]\")", "Bash(./run.sh restart:*)", "Read(//tmp/**)", - "Bash(wc -l /Users/loganli/devops/desktop-ui/opsxshell/src/components/*.tsx /Users/loganli/devops/desktop-ui/opsxshell/src/stores/*.ts /Users/loganli/devops/desktop-ui/opsxshell/electron/main.ts /Users/loganli/devops/desktop-ui/opsxshell/electron/services/*.ts)" + "Bash(wc -l /Users/loganli/devops/desktop-ui/opsxshell/src/components/*.tsx /Users/loganli/devops/desktop-ui/opsxshell/src/stores/*.ts /Users/loganli/devops/desktop-ui/opsxshell/electron/main.ts /Users/loganli/devops/desktop-ui/opsxshell/electron/services/*.ts)", + "Bash(git pull *)", + "Bash(git stash *)", + "Bash(git checkout *)", + "Bash(git add *)" ] } } diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index a71376a..6f238f4 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -166,30 +166,38 @@ ## 功能完成度矩阵 -| 功能 | 状态 | 完成度 | 备注 | +|| 功能 | 状态 | 完成度 | 备注 | |------|------|--------|------| | SSH 密码连接 | ✅ | 90% | | -| SSH 密钥连接 | 🟡 | 60% | 浏览按钮无效 | +| SSH 密钥连接 | ✅ | 80% | | | SSH Agent 认证 | ✅ | 80% | | -| XTerm.js 终端 | ✅ | 85% | 无搜索 | +| XTerm.js 终端 | ✅ | 85% | | | 终端多主题 | ✅ | 90% | | | IP 高亮 | ✅ | 95% | | | SFTP 双栏浏览 | ✅ | 80% | | -| SFTP 上传 | 🔴 | 10% | 模拟实现 | +| SFTP 上传 | ✅ | 75% | | | SFTP 下载 | ✅ | 75% | | -| SFTP 右键菜单 | 🟡 | 40% | 多个空函数 | -| SFTP 拖拽上传 | ❌ | 0% | | +| SFTP 右键菜单 | 🟡 | 60% | | +| SFTP 拖拽上传 | ✅ | 80% | | | SFTP 文件预览 | ❌ | 0% | | | 端口转发 | ✅ | 85% | | -| 标签页管理 | 🟡 | 60% | 无拖拽/恢复 | +| 标签页管理 | ✅ | 85% | | | 会话管理 | ✅ | 75% | | -| 会话恢复 | ❌ | 5% | 设置存在但未实现 | -| 快捷键系统 | 🔴 | 15% | 仅4个,不可自定义 | -| Toast 通知 | ❌ | 0% | | -| 终端搜索 | ❌ | 0% | | -| 设置系统 | 🟡 | 50% | light/i18n 无效 | -| 状态栏 | 🔴 | 10% | 永远"就绪" | -| 欢迎页 | 🟡 | 60% | 假数据 | +| 会话恢复 | ✅ | 85% | | +| 快捷键系统 | ✅ | 90% | v1.4.0: 自定义快捷键+冲突检测 | +| Toast 通知 | ✅ | 90% | | +| 终端搜索 | ✅ | 85% | | +| 设置系统 | ✅ | 70% | light/i18n 待实现 | +| 状态栏 | ✅ | 80% | | +| 欢迎页 | ✅ | 80% | | +| 命令片段库 | ✅ | 85% | | +| 命令面板 | ✅ | 80% | | +| 连接面板 | ✅ | 80% | | +| 主题系统 | ✅ | 90% | Dark/Light/System | +| 终端分屏 | 🚧 | 0% | v1.4.0 计划 | +| i18n 国际化 | 🚧 | 5% | v1.4.0 计划 | +| 代理设置 | 🚧 | 0% | v1.4.0 计划 | +| 多窗口支持 | 🚧 | 0% | v1.4.0 计划 | --- diff --git a/README.md b/README.md index 0630199..1467b40 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ ## 功能特性 -- 🔐 **SSH 终端** - 支持密码、密钥认证,XTerm.js 高性能终端 -- 📁 **SFTP 文件管理** - 双栏可视化文件管理,上传下载,传输队列 +- 🔐 **SSH 终端** - 支持密码、密钥、SSH Agent 认证,XTerm.js 高性能终端 +- 📁 **SFTP 文件管理** - 双栏可视化文件管理,拖拽上传下载,传输队列 - 🔄 **端口转发** - 本地/远程/动态 SOCKS5 代理 -- 📑 **多标签页** - 类浏览器标签管理,连接池复用 -- 🎨 **现代 UI** - 基于 Tailwind CSS 的深色主题界面,多主题配色 -- 🔒 **安全可靠** - 敏感数据 AES-256-GCM 加密存储 +- 📑 **多标签页** - 类浏览器标签管理,连接池复用,会话恢复 +- 🎨 **现代 UI** - 多主题系统 (Dark/Light/System),命令面板,命令片段库 +- ⌨️ **快捷键自定义** - 可重映射所有快捷键,实时冲突检测 +- 🔒 **安全可靠** - AES-256-GCM 加密存储,PBKDF2 600K 迭代密钥派生 - 🌐 **跨平台** - 支持 Windows、macOS、Linux ## 技术栈 @@ -19,10 +20,13 @@ | 框架 | Electron 28+ | | 前端 | React 18 + TypeScript 5 | | 构建 | Vite 5 | -| 状态管理 | Zustand | -| 样式 | Tailwind CSS | -| 终端 | XTerm.js | -| SSH/SFTP | node-ssh2 | +| 状态管理 | Zustand 4.4+ | +| 样式 | Tailwind CSS 3.3+ | +| 终端 | XTerm.js 5.5+ | +| SSH/SFTP | ssh2 1.15+ | +| 图标 | Lucide React | +| 数据库 | sql.js (SQLite WASM) 1.11+ | +| 测试 | Vitest 4.1+ | ## 快速开始 diff --git a/ROADMAP.md b/ROADMAP.md index 4f50a2f..2229e97 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,13 +95,13 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85 > **目标**: 快捷键自定义、终端分屏、i18n、代理、多窗口,达到专业级水平 -|| # | 任务 | 类型 | 预计 | 验收标准 | -|---|------|------|------|----------| -| 1 | 快捷键自定义系统 | 新增 | 3天 | 设置页快捷键 Tab,可重映射所有快捷键,冲突检测 | -| 2 | 终端分屏 | 新增 | 5天 | 水平/垂直分割标签页,可拖拽调整分割比例 | -| 3 | i18n 国际化 | 新增 | 5天 | zh-CN + en-US,所有 UI 文字抽离到 locale 文件 | -| 4 | 代理设置 | 新增 | 2天 | HTTP/SOCKS5 代理配置,按会话独立设置 | -| 5 | 多窗口支持 | 新增 | 5天 | 右键标签"在新窗口打开",窗口位置记忆 | +|| # | 任务 | 类型 | 预计 | 验收标准 | 状态 | +|---|------|------|------|----------|------| +| 1 | 快捷键自定义系统 | 新增 | 3天 | 设置页快捷键 Tab,可重映射所有快捷键,冲突检测 | ✅ | +| 2 | 终端分屏 | 新增 | 5天 | 水平/垂直分割标签页,可拖拽调整分割比例 | 🚧 | +| 3 | i18n 国际化 | 新增 | 5天 | zh-CN + en-US,所有 UI 文字抽离到 locale 文件 | 🚧 | +| 4 | 代理设置 | 新增 | 2天 | HTTP/SOCKS5 代理配置,按会话独立设置 | 🚧 | +| 5 | 多窗口支持 | 新增 | 5天 | 右键标签"在新窗口打开",窗口位置记忆 | 🚧 | > **注**: Light 主题、Snippets 命令片段库已在 v1.3.0 提前完成 @@ -134,7 +134,7 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85 ## 技术栈 -| 类别 | 技术 | 版本 | +|| 类别 | 技术 | 版本 | |------|------|------| | 框架 | Electron | 28+ (升级至 33+) | | UI | React | 18 | @@ -145,7 +145,7 @@ v1.5.0 安全升级 ─── 1 周 ──→ 安全 55→85, 代码 80→85 | SSH | ssh2 | 1.15 | | 样式 | Tailwind CSS | 3 | | 图标 | Lucide React | — | -| 数据库 | better-sqlite3 | 11 | +| 数据库 | sql.js (SQLite WASM) | 1.11+ | | 加密 | AES-256-GCM (Node crypto) | — | ## 架构设计 diff --git a/TDD_PLAN.md b/TDD_PLAN.md index dec1028..32414c2 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -6,20 +6,22 @@ ## 当前状态 -| 指标 | 当前值 | 目标值 | +|| 指标 | 当前值 | 目标值 | |------|--------|--------| -| 测试文件数 | 3 | 15+ | -| 测试用例数 | 34 | 150+ | -| 估算覆盖率 | ~10% | ≥50% | -| 核心模块覆盖 | 0% | ≥80% | +| 测试文件数 | 5 | 15+ | +| 测试用例数 | 72 | 150+ | +| 估算覆盖率 | ~15% | ≥50% | +| 核心模块覆盖 | 60% | ≥80% | ### 已有测试 -| 测试文件 | 用例数 | 覆盖模块 | 质量 | +|| 测试文件 | 用例数 | 覆盖模块 | 质量 | |----------|--------|----------|------| -| `test/stores/tabStore.test.ts` | 15 | tabStore | ✅ 完整 | -| `test/stores/connectionPool.test.ts` | 7 | poolStore | 🟡 仅接口验证 | -| `test/components/sftp/utils.test.ts` | 12 | sftp/utils | ✅ 完整 | +| `test/stores/tabStore.test.ts` | 14 | tabStore | ✅ 完整 | +| `test/stores/connectionPool.test.ts` | 8 | poolStore | 🟡 仅接口验证 | +| `test/components/sftp/utils.test.ts` | 16 | sftp/utils | ✅ 完整 | +| `test/services/crypto.test.ts` | 11 | crypto | ✅ 完整 | +| `test/stores/keybindingStore.test.ts` | 23 | keybindingStore | ✅ 完整 | --- @@ -391,12 +393,13 @@ npm test -- --watch # 监听模式 ## 覆盖率追踪 -| 版本 | 总覆盖率 | 核心模块 | 用例数 | 日期 | +|| 版本 | 总覆盖率 | 核心模块 | 用例数 | 日期 | |------|----------|----------|--------|------| | v1.0.0 | ~10% | 0% | 34 | 2026-04-06 | | v1.1.0 | ~12% | 0% | 34 | — | | v1.2.0 | ~15% | 0% | 40 | — | -| v1.3.0-batch1 | ~25% | 60% | 69 | — | +| v1.3.0 | ~20% | 60% | 49 | 2026-04-24 | +| v1.4.0-batch1 | ~25% | 60% | 72 | 2026-04-24 | | v1.3.0-batch2 | ~35% | 60% | 104 | — | | v1.3.0-batch3 | ~45% | 70% | 143 | — | | v1.3.0-final | **≥50%** | **≥80%** | **150+** | — | diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 1774dc6..3db75d0 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -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(null) + const [recordedKeys, setRecordedKeys] = useState('') -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('general') const [searchQuery, setSearchQuery] = useState('') const [appVersion, setAppVersion] = useState('—') + 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 (
- -
- - - - - - - - - - {SHORTCUTS.map((s, i) => ( - - - - - - ))} - -
操作快捷键分类
{s.action} - - {s.keys} - - {s.category}
+ {/* 重置全部按钮 */} + {hasCustom && ( +
+
-

- 快捷键自定义功能将在后续版本中支持。 -

- + )} + + {categories.map((category) => { + const defs = getKeybindingsByCategory(category) + return ( + +

{category}

+
+ {defs.map((def) => { + const currentKeys = allKeys[def.id] + const isCustom = keybindingState.customKeybindings[def.id] !== undefined + const isRecordingThis = recordingId === def.id + + return ( +
+
+ {def.label} + {isCustom && ( + + 自定义 + + )} +
+ +
+ {isRecordingThis ? ( +
+ + {recordedKeys || '按下快捷键...'} + + + {recordedKeys && recordedConflict.length > 0 && ( +
+ + + 与「{recordedConflict[0].label}」冲突 + +
+ )} +
+ ) : ( + <> + + {isCustom && ( + + )} + + )} +
+
+ ) + })} +
+
+ ) + })} + + {/* 录入确认弹层 */} + {recordingId && recordedKeys && ( +
+ + 设置为 {recordedKeys} + + + +
+ )} + +

+ 点击快捷键可重新绑定 · 按 Esc 取消录入 +

) + } // ──────── SSH ────────── case 'ssh': diff --git a/src/constants/index.ts b/src/constants/index.ts index 565f217..2669268 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -4,8 +4,4 @@ * 避免硬编码,集中管理可配置参数 */ -// 待实现 -// export * from './terminal' -// export * from './sftp' -// export * from './keybindings' -// export * from './ui' +export * from './keybindings' diff --git a/src/constants/keybindings.ts b/src/constants/keybindings.ts new file mode 100644 index 0000000..06381b9 --- /dev/null +++ b/src/constants/keybindings.ts @@ -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) +} diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index 096042b..a6c4f68 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -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 diff --git a/src/stores/index.ts b/src/stores/index.ts index ad27abc..802c89d 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -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' diff --git a/src/stores/keybindingStore.ts b/src/stores/keybindingStore.ts new file mode 100644 index 0000000..4d84e5c --- /dev/null +++ b/src/stores/keybindingStore.ts @@ -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 + +interface KeybindingState { + /** 自定义键绑定 (仅存储与默认不同的) */ + customKeybindings: CustomKeybindings + /** 是否已从持久化加载 */ + isLoaded: boolean + + // Actions + /** 从 electronAPI 加载自定义快捷键 */ + loadFromSettings: () => Promise + /** 获取操作的有效键绑定 (自定义优先,否则默认) */ + getEffectiveKeys: (actionId: KeybindingActionId) => string + /** 获取所有操作的有效键绑定映射 */ + getAllEffective: () => Record + /** 设置自定义键绑定 */ + setCustomKeybinding: (actionId: KeybindingActionId, keys: string) => Promise + /** 重置单个操作为默认 */ + resetKeybinding: (actionId: KeybindingActionId) => Promise + /** 重置所有为默认 */ + resetAll: () => Promise + /** 检测冲突 — 返回与新键绑定冲突的其他操作列表 */ + getConflicts: (actionId: KeybindingActionId, keys: string) => KeybindingDefinition[] + /** 规范化键序列 (统一修饰键顺序和大小写) */ + normalizeKeySequence: (raw: string) => string +} + +// ─── Store ───────────────────────────────────────────── + +export const useKeybindingStore = create((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 + 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 = { 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 diff --git a/test/setup.ts b/test/setup.ts index 55859cd..29f8a9e 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -37,6 +37,8 @@ const mockElectronAPI = { // Settings getSettings: vi.fn().mockResolvedValue({ success: true, settings: {} }), setSettings: vi.fn().mockResolvedValue({ success: true }), + settingsGet: vi.fn().mockResolvedValue({ success: true, value: undefined }), + settingsSet: vi.fn().mockResolvedValue({ success: true }), // Dialog showSaveDialog: vi.fn().mockResolvedValue({ success: true, canceled: false, filePath: '/tmp/test' }), diff --git a/test/stores/keybindingStore.test.ts b/test/stores/keybindingStore.test.ts new file mode 100644 index 0000000..b25e7c4 --- /dev/null +++ b/test/stores/keybindingStore.test.ts @@ -0,0 +1,305 @@ +/** + * KeybindingStore 测试 — 快捷键自定义系统 + * + * 测试覆盖: + * - 初始化加载默认键绑定 + * - 自定义键绑定 (setCustomKeybinding) + * - 重置为默认 (resetKeybinding / resetAll) + * - 冲突检测 (getConflicts) + * - 键序列规范化 (normalizeKeySequence) + * - 持久化到 settings + * - 导出/导入 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useKeybindingStore } from '../../src/stores/keybindingStore' +import { KeybindingAction } from '../../src/constants/keybindings' + +describe('KeybindingStore', () => { + beforeEach(() => { + // 重置 store 到初始状态 + useKeybindingStore.setState({ + customKeybindings: {}, + isLoaded: false, + }) + // 清除 mock 调用记录 + vi.clearAllMocks() + }) + + // ─── 1. 初始化与加载 ──────────────────────────────── + + describe('初始化与加载', () => { + it('应有正确的默认状态', () => { + const state = useKeybindingStore.getState() + expect(state.customKeybindings).toEqual({}) + expect(state.isLoaded).toBe(false) + }) + + it('loadFromSettings 应从 electronAPI 加载自定义快捷键', async () => { + const customData = { + [KeybindingAction.TAB_CLOSE]: 'Ctrl+Q', + [KeybindingAction.TAB_NEW]: 'Ctrl+T', + } + + // Mock electronAPI 返回自定义快捷键 + vi.mocked(window.electronAPI.settingsGet).mockResolvedValueOnce({ + success: true, + value: customData, + }) + + await useKeybindingStore.getState().loadFromSettings() + + const state = useKeybindingStore.getState() + expect(state.isLoaded).toBe(true) + expect(state.customKeybindings).toEqual(customData) + }) + + it('loadFromSettings 无保存数据时应使用空自定义', async () => { + vi.mocked(window.electronAPI.settingsGet).mockResolvedValueOnce({ + success: false, + }) + + await useKeybindingStore.getState().loadFromSettings() + + const state = useKeybindingStore.getState() + expect(state.isLoaded).toBe(true) + expect(state.customKeybindings).toEqual({}) + }) + + it('loadFromSettings API 报错时应安全降级', async () => { + vi.mocked(window.electronAPI.settingsGet).mockRejectedValueOnce(new Error('API error')) + + await useKeybindingStore.getState().loadFromSettings() + + const state = useKeybindingStore.getState() + expect(state.isLoaded).toBe(true) + expect(state.customKeybindings).toEqual({}) + }) + }) + + // ─── 2. 获取有效快捷键 ────────────────────────────── + + describe('getEffectiveKeys', () => { + it('无自定义时应返回默认键', () => { + const keys = useKeybindingStore.getState().getEffectiveKeys(KeybindingAction.TAB_NEXT) + expect(keys).toBe('Ctrl+Tab') + }) + + it('有自定义时应返回自定义键', () => { + useKeybindingStore.setState({ + customKeybindings: { + [KeybindingAction.TAB_NEXT]: 'Alt+Right', + }, + }) + + const keys = useKeybindingStore.getState().getEffectiveKeys(KeybindingAction.TAB_NEXT) + expect(keys).toBe('Alt+Right') + }) + + it('自定义为空字符串时应返回默认键', () => { + useKeybindingStore.setState({ + customKeybindings: { + [KeybindingAction.TAB_NEXT]: '', + }, + }) + + const keys = useKeybindingStore.getState().getEffectiveKeys(KeybindingAction.TAB_NEXT) + expect(keys).toBe('Ctrl+Tab') + }) + }) + + // ─── 3. 设置自定义快捷键 ──────────────────────────── + + describe('setCustomKeybinding', () => { + it('应设置自定义键绑定并持久化', async () => { + await useKeybindingStore.getState().setCustomKeybinding( + KeybindingAction.TAB_CLOSE, + 'Ctrl+Q' + ) + + const state = useKeybindingStore.getState() + expect(state.customKeybindings[KeybindingAction.TAB_CLOSE]).toBe('Ctrl+Q') + + // 应调用 settingsSet 持久化 + expect(window.electronAPI.settingsSet).toHaveBeenCalledWith( + 'keybindings', + { [KeybindingAction.TAB_CLOSE]: 'Ctrl+Q' } + ) + }) + + it('多次设置应合并而非覆盖其他', async () => { + await useKeybindingStore.getState().setCustomKeybinding( + KeybindingAction.TAB_CLOSE, + 'Ctrl+Q' + ) + await useKeybindingStore.getState().setCustomKeybinding( + KeybindingAction.TAB_NEW, + 'Ctrl+T' + ) + + const state = useKeybindingStore.getState() + expect(state.customKeybindings[KeybindingAction.TAB_CLOSE]).toBe('Ctrl+Q') + expect(state.customKeybindings[KeybindingAction.TAB_NEW]).toBe('Ctrl+T') + + // 第二次调用应包含两个 + const lastCall = vi.mocked(window.electronAPI.settingsSet).mock.calls.at(-1) + expect(lastCall?.[1]).toEqual({ + [KeybindingAction.TAB_CLOSE]: 'Ctrl+Q', + [KeybindingAction.TAB_NEW]: 'Ctrl+T', + }) + }) + + it('设置为与默认相同的值时仍应记录', async () => { + await useKeybindingStore.getState().setCustomKeybinding( + KeybindingAction.TAB_CLOSE, + 'Ctrl+W' // 等同默认 + ) + + const state = useKeybindingStore.getState() + expect(state.customKeybindings[KeybindingAction.TAB_CLOSE]).toBe('Ctrl+W') + }) + }) + + // ─── 4. 重置快捷键 ────────────────────────────────── + + describe('重置操作', () => { + it('resetKeybinding 应移除单个自定义', async () => { + useKeybindingStore.setState({ + customKeybindings: { + [KeybindingAction.TAB_CLOSE]: 'Ctrl+Q', + [KeybindingAction.TAB_NEW]: 'Ctrl+T', + }, + }) + + await useKeybindingStore.getState().resetKeybinding(KeybindingAction.TAB_CLOSE) + + const state = useKeybindingStore.getState() + expect(state.customKeybindings[KeybindingAction.TAB_CLOSE]).toBeUndefined() + expect(state.customKeybindings[KeybindingAction.TAB_NEW]).toBe('Ctrl+T') + }) + + it('resetAll 应清除所有自定义', async () => { + useKeybindingStore.setState({ + customKeybindings: { + [KeybindingAction.TAB_CLOSE]: 'Ctrl+Q', + [KeybindingAction.TAB_NEW]: 'Ctrl+T', + }, + }) + + await useKeybindingStore.getState().resetAll() + + const state = useKeybindingStore.getState() + expect(state.customKeybindings).toEqual({}) + expect(window.electronAPI.settingsSet).toHaveBeenCalledWith('keybindings', {}) + }) + }) + + // ─── 5. 冲突检测 ──────────────────────────────────── + + describe('冲突检测', () => { + it('getConflicts 应检测到与其他操作的冲突', () => { + // TAB_CLOSE 默认是 Ctrl+W,设置 TAB_NEW 也为 Ctrl+W 应产生冲突 + useKeybindingStore.setState({ + customKeybindings: { + [KeybindingAction.TAB_NEW]: 'Ctrl+W', + }, + }) + + const conflicts = useKeybindingStore.getState().getConflicts( + KeybindingAction.TAB_NEW, + 'Ctrl+W' + ) + + // 应检测到 TAB_CLOSE (默认 Ctrl+W) 和 TAB_NEW (自定义 Ctrl+W) 冲突 + expect(conflicts.length).toBeGreaterThan(0) + expect(conflicts.some((c) => c.id === KeybindingAction.TAB_CLOSE)).toBe(true) + }) + + it('getConflicts 不应报告自身为冲突', () => { + const conflicts = useKeybindingStore.getState().getConflicts( + KeybindingAction.TAB_CLOSE, + 'Ctrl+W' // 它自己的默认值 + ) + expect(conflicts).toEqual([]) + }) + + it('getConflicts 应考虑自定义快捷键', () => { + useKeybindingStore.setState({ + customKeybindings: { + [KeybindingAction.TAB_CLOSE]: 'Alt+F4', + }, + }) + + // 设置另一个操作为 Alt+F4 + const conflicts = useKeybindingStore.getState().getConflicts( + KeybindingAction.TAB_NEW, + 'Alt+F4' + ) + + expect(conflicts.some((c) => c.id === KeybindingAction.TAB_CLOSE)).toBe(true) + }) + + it('无冲突时应返回空数组', () => { + const conflicts = useKeybindingStore.getState().getConflicts( + KeybindingAction.TAB_NEW, + 'Ctrl+Alt+X' // 不冲突 + ) + expect(conflicts).toEqual([]) + }) + + it('特殊的 Ctrl+1…9 范围不应产生冲突', () => { + const conflicts = useKeybindingStore.getState().getConflicts( + KeybindingAction.TAB_GOTO_N, + 'Ctrl+1…9' + ) + expect(conflicts).toEqual([]) + }) + }) + + // ─── 6. 获取所有有效快捷键映射 ────────────────────── + + describe('getAllEffective', () => { + it('应返回所有操作的有效键绑定', () => { + useKeybindingStore.setState({ + customKeybindings: { + [KeybindingAction.TAB_CLOSE]: 'Ctrl+Q', + }, + }) + + const all = useKeybindingStore.getState().getAllEffective() + + expect(all[KeybindingAction.TAB_CLOSE]).toBe('Ctrl+Q') // 自定义 + expect(all[KeybindingAction.TAB_NEXT]).toBe('Ctrl+Tab') // 默认 + expect(all[KeybindingAction.TOOLS_COMMAND_PALETTE]).toBe('Ctrl+Shift+P') // 默认 + }) + }) + + // ─── 7. 键序列规范化 ──────────────────────────────── + + describe('normalizeKeySequence', () => { + it('应规范化修饰键顺序', () => { + const { normalizeKeySequence } = useKeybindingStore.getState() + expect(normalizeKeySequence('Shift+Ctrl+Tab')).toBe('Ctrl+Shift+Tab') + }) + + it('应处理小写键名', () => { + const { normalizeKeySequence } = useKeybindingStore.getState() + expect(normalizeKeySequence('ctrl+shift+p')).toBe('Ctrl+Shift+P') + }) + + it('应处理空格和多余分隔符', () => { + const { normalizeKeySequence } = useKeybindingStore.getState() + expect(normalizeKeySequence(' Ctrl + Tab ')).toBe('Ctrl+Tab') + }) + + it('应正确处理单键 (F11)', () => { + const { normalizeKeySequence } = useKeybindingStore.getState() + expect(normalizeKeySequence('F11')).toBe('F11') + }) + + it('Meta 应映射为 Ctrl', () => { + const { normalizeKeySequence } = useKeybindingStore.getState() + expect(normalizeKeySequence('Meta+W')).toBe('Ctrl+W') + }) + }) +})