diff --git a/.agents/notes/implemented/feature/2026-08-15-macos-keymap-customization.md b/.agents/notes/implemented/feature/2026-08-15-macos-keymap-customization.md
index 023e0edff..5b182f631 100644
--- a/.agents/notes/implemented/feature/2026-08-15-macos-keymap-customization.md
+++ b/.agents/notes/implemented/feature/2026-08-15-macos-keymap-customization.md
@@ -4,7 +4,7 @@
## 先说结论
-快捷键只有一个集中目录,菜单、搜索、设置和实际分发都从这个目录生成。持久化使用稳定的命令 ID,显示文字或分组变化不能随意改变 ID;普通输入按键也不能被误识别成全局命令。
+快捷键只有一个集中命令目录,菜单、搜索、设置和实际分发都从这个目录生成。用户可以在 macOS、IntelliJ IDEA Classic 和 Eclipse 三套预设间切换;每套预设的自定义修改分别保存。持久化使用稳定的命令 ID,显示文字或分组变化不能随意改变 ID;普通输入按键也不能被误识别成全局命令。
## 问题
@@ -27,7 +27,8 @@ ID、本地化标题、分组和默认绑定;菜单、Search Everywhere、设
`⌘`、`⌥` 等字符串只在渲染时生成。普通字符必须带有
Command、Control 或 Option 之一,功能键、方向键等特殊按键可以单独使用。
-`AppSettings` 在版本化的 `keyboardShortcutOverrides` 载荷中保存用户覆盖:
+`AppSettings` 保存当前预设和按预设分开的版本化用户覆盖。旧版单套
+`keyboardShortcutOverrides` 载荷只在首次迁移时归入 macOS 预设:
- 没有命令条目时使用目录默认值;
- 非空列表替换该命令的全部绑定;
@@ -36,15 +37,29 @@ Command、Control 或 Option 之一,功能键、方向键等特殊按键可以
- 未知命令、非法绑定和重复绑定被忽略;
- 整个载荷无法解码时回退到全部默认值。
+预设只映射 Lithe 已有且与 IntelliJ Community 源码能对应的操作,真正没有
+对应上游动作的 Lithe 命令才沿用 Lithe 默认绑定。Eclipse 预设先解析
+`$default`、`Mac OS X 10.5+`、`Eclipse (Mac OS X)` 的继承顺序;子层
+没有声明动作时继承父层,声明空动作时清除父层绑定。例如 Eclipse 的
+`Find` 为空,不能回退成 Lithe 的 `⌘F`。切换预设时先切换预设身份,再发布该预设的覆盖值,
+使设置页、菜单和原生监听器同时看到新绑定;录制中的操作在切换前取消。
+IDEA 的 `Stop` 是按上下文停止运行或调试的单一动作;Lithe 目前仍有独立的
+`stop-run` 和 `stop-debug`,因此 `Control-F2` 暂时只绑定 `stop-run`。
+要同时覆盖调试场景,需要先提供能识别当前会话的统一 Stop 命令。
+应用级恢复默认清除所有预设的用户覆盖并返回 macOS,Keymap 页的恢复默认
+只清除当前预设的覆盖。官方映射依据是 IntelliJ Community 的
+[`$default.xml`](https://github.com/JetBrains/intellij-community/blob/master/platform/platform-resources/src/keymaps/%24default.xml)
+和 [`Eclipse (Mac OS X).xml`](https://github.com/JetBrains/intellij-community/blob/master/plugins/keymaps/eclipse-keymap/resources/keymaps/Eclipse%20%28Mac%20OS%20X%29.xml);
+Eclipse 文件继承的 macOS 映射见 [`Mac OS X 10.5+.xml`](https://github.com/JetBrains/intellij-community/blob/master/platform/platform-resources/src/keymaps/Mac%20OS%20X%2010.5%2B.xml)。
+
`KeyboardShortcutFeatureModel` 负责合成有效值、录制状态、冲突检测、单项
恢复和全部恢复。相同绑定发生冲突时指出占用它的命令并拒绝保存,不自动
覆盖已有命令。`ShortcutSessionCoordinator` 将有效注册同步给 macOS
监听器;录制快捷键时暂停命令分发,非活动会话和已关闭会话不执行排队的
旧命令。
-Search Everywhere 保留双击 Shift 和 `⇧⌘O` 两个默认入口。应用级
-“恢复全部默认”会同时清除快捷键覆盖,Keymap 页面自己的“全部恢复默认”
-只清除快捷键覆盖。该功能只属于 macOS,不把 Windows keymap 或新的跨平台
+Search Everywhere 的 macOS 预设保留双击 Shift 和 `⇧⌘O` 两个默认入口。
+该功能只属于 macOS,不把 Windows keymap 或新的跨平台
快捷键契约纳入本决策。
## 考虑过的备选方案
@@ -59,8 +74,9 @@ Search Everywhere 保留双击 Shift 和 `⇧⌘O` 两个默认入口。应用
明确报告冲突并拒绝保存。
- **立即设计 Windows 与 macOS 共用 keymap 契约**:两端事件模型和原生
编辑行为不同,当前收益不足以抵消跨平台兼容面,暂不扩展范围。
-- **加入 IDEA keymap 导入、预设、导出或云同步**:这些能力会扩大稳定性
- 和数据兼容范围,当前需求不包含它们。
+- **直接导入 IDEA 全量 keymap 文件**:Lithe 没有 IDEA 的全部命令和动作
+ 语义,全量导入会产生无法执行的配置;只映射现有对应操作。自定义 keymap
+ 文件导入、导出和云同步仍不在本次范围。
## 后果
diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml
index eaa5b0dfd..c424a3123 100644
--- a/.github/workflows/ci-macos.yml
+++ b/.github/workflows/ci-macos.yml
@@ -219,7 +219,7 @@ jobs:
needs: changes
if: needs.changes.outputs.rust_core == 'true'
runs-on: macos-26
- timeout-minutes: 20
+ timeout-minutes: 30
steps:
- name: Check out source
diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv
index 46f8d0a81..2470ef4cd 100644
--- a/docs/development/platform-parity-matrix.csv
+++ b/docs/development/platform-parity-matrix.csv
@@ -74,7 +74,7 @@ references-pane,编辑器,代码结构,引用结果面板与引用跳转,已实
diagnostics-workspace,可观测性,诊断与日志,工作区诊断与问题面板,已实现,待验证,已实现,待验证,Diagnostics,制造多个来源的错误和警告,验证筛选、排序、定位和清除。,,macos/Sources/Lithe/Application/Features/DiagnosticsFeatureModel.swift; macos/Sources/Lithe/Views/Language/JavaProblemsView.swift,windows/tauri/src/features/diagnostics; windows/tauri/src/features/logging; windows/tauri/src/ui/dialog.tsx
diagnostics-export,可观测性,诊断与日志,诊断包导出与脱敏,已实现,待验证,已实现,待验证,Diagnostics,导出诊断包,确认敏感路径、凭据和大文件被正确脱敏或限制;检查预览说明和文件列表在确认弹窗内完整显示、长内容正常换行且不溢出。,,macos/Sources/Lithe/Views/Diagnostics/DiagnosticsExportSheet.swift; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/diagnostics
application-settings,设置,应用配置,应用、项目与运行设置,已实现,待验证,已实现,待验证,Settings,修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift,windows/tauri/src/features/settings
-keymap-customization,设置,应用配置,快捷键查看与自定义,已实现,待验证,已实现,待验证,Settings,从设置 → 快捷键打开编辑器,按命令名称、ID 和按键搜索,修改跳转到实现的快捷键、制造冲突并恢复默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。,Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。,macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift; macos/Sources/Lithe/Models/Keymap,windows/tauri/src/features/keymaps; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx; windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx
+keymap-customization,设置,应用配置,快捷键查看与自定义,已实现,待验证,已实现,待验证,Settings,macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8、继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。Windows 保持现有快捷键编辑、导出和导入验证。,Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。,macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift; macos/Sources/Lithe/Models/Keymap,windows/tauri/src/features/keymaps; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx; windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx
plugin-extension-management,扩展,插件与主题,插件、语言服务与主题管理,已实现,待验证,已实现,待验证,Extensions,启用、禁用和恢复插件/主题,确认启动加载、失败隔离和设置持久化。,,macos/Sources/Lithe/Views/App/PluginManagementView.swift; macos/Sources/Lithe/Platform/MacOS/Plugins,windows/tauri/src/extensions; windows/tauri/src/features/settings
onboarding-welcome,启动与窗口,首次启动,欢迎页、项目入口与首次启动引导,已实现,待验证,已实现,待验证,Onboarding,清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导。,,macos/Sources/Lithe/Views/App/WelcomeView.swift; macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/onboarding; windows/tauri/src/features/bootstrap
command-palette,工作台,命令与布局,命令面板与全局动作,已实现,待验证,已实现,待验证,Workbench,搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。,,macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift; macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift,windows/tauri/src/features/command-palette
diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md
index 2e1c35bc8..017e443c7 100644
--- a/docs/development/platform-parity-matrix.md
+++ b/docs/development/platform-parity-matrix.md
@@ -212,7 +212,7 @@
| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 |
| --- | --- | --- | --- | --- | --- | --- |
| 应用配置 | **应用、项目与运行设置**
application-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings` | Settings | 修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。 | |
-| 应用配置 | **快捷键查看与自定义**
keymap-customization | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift`、`macos/Sources/Lithe/Models/Keymap` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/keymaps`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx`、`windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx` | Settings | 从设置 → 快捷键打开编辑器,按命令名称、ID 和按键搜索,修改跳转到实现的快捷键、制造冲突并恢复默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。 | Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。 |
+| 应用配置 | **快捷键查看与自定义**
keymap-customization | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift`、`macos/Sources/Lithe/Models/Keymap` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/keymaps`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx`、`windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx` | Settings | macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8、继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。Windows 保持现有快捷键编辑、导出和导入验证。 | Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。 |
diff --git a/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift b/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift
index d0f6fc1bf..40976814d 100644
--- a/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift
+++ b/macos/Sources/Lithe/Application/Features/KeyboardShortcutFeatureModel.swift
@@ -35,6 +35,14 @@ final class KeyboardShortcutFeatureModel: ObservableObject {
LitheCommandCatalog.commands
}
+ var selectedPreset: KeyboardShortcutPreset {
+ settings.keyboardShortcutPreset
+ }
+
+ func selectPreset(_ preset: KeyboardShortcutPreset) {
+ settings.selectKeyboardShortcutPreset(preset)
+ }
+
var registrations: [KeyboardShortcutRegistration] {
registrations(for: settings.keyboardShortcutOverrides)
}
@@ -45,7 +53,7 @@ final class KeyboardShortcutFeatureModel: ObservableObject {
commands.map { command in
KeyboardShortcutRegistration(
commandID: command.id,
- bindings: overrides[command.id] ?? command.defaultBindings
+ bindings: overrides[command.id] ?? settings.keyboardShortcutPreset.bindings(for: command)
)
}
}
@@ -85,7 +93,8 @@ final class KeyboardShortcutFeatureModel: ObservableObject {
if let override = settings.keyboardShortcutOverrides[commandID] {
return override
}
- return LitheCommandCatalog.command(id: commandID)?.defaultBindings ?? []
+ guard let command = LitheCommandCatalog.command(id: commandID) else { return [] }
+ return settings.keyboardShortcutPreset.bindings(for: command)
}
func displayText(for commandID: String) -> String? {
diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift
index 1de0c71c2..3eecf3591 100644
--- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift
+++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift
@@ -8,6 +8,122 @@ struct LitheCommandDefinition: Identifiable, Equatable, Sendable {
let defaultBindings: [KeyboardShortcutBinding]
}
+enum KeyboardShortcutPreset: String, CaseIterable, Sendable {
+ case macOS = "macOS"
+ case ideaClassic = "idea-classic"
+ case eclipse = "eclipse"
+
+ var title: String {
+ switch self {
+ case .macOS: "macOS"
+ case .ideaClassic: "IntelliJ IDEA Classic"
+ case .eclipse: "Eclipse"
+ }
+ }
+
+ func bindings(for command: LitheCommandDefinition) -> [KeyboardShortcutBinding] {
+ switch self {
+ case .macOS: command.defaultBindings
+ case .ideaClassic: Self.ideaClassicBindings[command.id] ?? command.defaultBindings
+ case .eclipse: Self.eclipseBindings[command.id]
+ ?? Self.eclipseParentBindings[command.id]
+ ?? command.defaultBindings
+ }
+ }
+
+ private static func shortcut(_ key: String, _ modifiers: KeyboardShortcutModifiers = []) -> [KeyboardShortcutBinding] {
+ [.keyPress(key: key, modifiers: modifiers)]
+ }
+
+ // Adapt the matching Lithe actions from IntelliJ Community's $default keymap.
+ private static let ideaClassicBindings: [String: [KeyboardShortcutBinding]] = [
+ "settings": shortcut("s", [.control, .option]),
+ "save": shortcut("s", [.control]),
+ "run": shortcut("f10", [.shift]),
+ "debug": shortcut("f9", [.shift]),
+ // ponytail: JetBrains has one context-aware Stop action. Lithe binds Stop Run only
+ // until a shared Stop command can choose the active run or debug session.
+ "stop-run": shortcut("f2", [.control]),
+ "toggle-breakpoint": shortcut("f8", [.control]),
+ "view-breakpoints": shortcut("f8", [.control, .shift]),
+ "search-everywhere": [.doubleTap(.shift)],
+ "navigate-back": shortcut("left", [.control, .option]),
+ "navigate-forward": shortcut("right", [.control, .option]),
+ "find-in-file": shortcut("f", [.control]),
+ "find-next": shortcut("f3"),
+ "find-previous": shortcut("f3", [.shift]),
+ "replace-in-file": shortcut("r", [.control]),
+ "go-to-line": shortcut("g", [.control]),
+ "go-to-definition": shortcut("b", [.control]),
+ "go-to-implementation": shortcut("b", [.control, .option]),
+ "find-usages": shortcut("f7", [.option]),
+ "search-in-project": shortcut("f", [.control, .shift]),
+ "replace-in-project": shortcut("r", [.control, .shift]),
+ "toggle-terminal": shortcut("f12", [.option]),
+ "toggle-run": shortcut("4", [.option]),
+ "toggle-debug": shortcut("5", [.option])
+ ]
+
+ // Resolved matching actions from Mac OS X 10.5+, including its $default parent.
+ // Only Lithe-only commands fall back to Lithe defaults.
+ private static let eclipseParentBindings: [String: [KeyboardShortcutBinding]] = [
+ "settings": shortcut(",", [.command]),
+ "save": shortcut("s", [.control]),
+ "run": shortcut("r", [.control]),
+ "debug": shortcut("d", [.control]),
+ "stop-run": shortcut("f2", [.control]),
+ "debug-resume": shortcut("f9"),
+ "debug-step-over": shortcut("f8"),
+ "debug-step-into": shortcut("f7"),
+ "debug-step-out": shortcut("f8", [.shift]),
+ "toggle-breakpoint": shortcut("f8", [.control]),
+ "view-breakpoints": shortcut("f8", [.control, .shift]),
+ "search-everywhere": [.doubleTap(.shift)],
+ "navigate-back": shortcut("[", [.command]),
+ "navigate-forward": shortcut("]", [.command]),
+ "find-in-file": shortcut("f", [.command]),
+ "find-next": [
+ .keyPress(key: "f3", modifiers: []),
+ .keyPress(key: "l", modifiers: [.control])
+ ],
+ "find-previous": [
+ .keyPress(key: "f3", modifiers: [.shift]),
+ .keyPress(key: "l", modifiers: [.control, .shift])
+ ],
+ "replace-in-file": shortcut("r", [.control]),
+ "go-to-line": shortcut("l", [.command]),
+ "go-to-definition": shortcut("b", [.command]),
+ "go-to-implementation": shortcut("b", [.control, .option]),
+ "find-usages": shortcut("f7", [.option]),
+ "search-in-project": shortcut("f", [.command, .shift]),
+ "replace-in-project": shortcut("r", [.command, .shift]),
+ "toggle-terminal": shortcut("f12", [.option]),
+ "toggle-problems": shortcut("6", [.command]),
+ "toggle-run": shortcut("4", [.command]),
+ "toggle-debug": shortcut("5", [.command])
+ ]
+
+ // Eclipse (Mac OS X).xml overrides its parent; an empty action clears the inherited binding.
+ private static let eclipseBindings: [String: [KeyboardShortcutBinding]] = [
+ "run": shortcut("f11", [.command, .shift]),
+ "debug": shortcut("f11", [.command]),
+ "debug-resume": shortcut("f8"),
+ "debug-step-over": shortcut("f6"),
+ "debug-step-into": shortcut("f5"),
+ "debug-step-out": shortcut("f7"),
+ "toggle-breakpoint": shortcut("b", [.command, .shift]),
+ "find-in-file": [],
+ "find-next": shortcut("k", [.command]),
+ "find-previous": shortcut("k", [.command, .shift]),
+ "replace-in-file": [],
+ "find-usages": shortcut("g", [.command, .shift]),
+ "go-to-definition": shortcut("f3"),
+ "go-to-implementation": [],
+ "search-in-project": shortcut("h", [.control]),
+ "replace-in-project": []
+ ]
+}
+
// Note: macOS 快捷键的集中目录、覆盖与冲突规则见 .agents/notes/implemented/feature/2026-08-15-macos-keymap-customization.md
enum LitheCommandCatalog {
static let commands: [LitheCommandDefinition] = validated([
diff --git a/macos/Sources/Lithe/Models/Settings/AppSettings.swift b/macos/Sources/Lithe/Models/Settings/AppSettings.swift
index 2ded17cbf..92fc82154 100644
--- a/macos/Sources/Lithe/Models/Settings/AppSettings.swift
+++ b/macos/Sources/Lithe/Models/Settings/AppSettings.swift
@@ -37,6 +37,8 @@ final class AppSettings: ObservableObject {
static let agentArguments = "settings.agentArguments"
static let agentConfigurations = "settings.agentConfigurations"
static let keyboardShortcutOverrides = "settings.keyboardShortcutOverrides"
+ static let keyboardShortcutPreset = "settings.keyboardShortcutPreset"
+ static let keyboardShortcutPresets = "settings.keyboardShortcutPresets"
static let customLogDirectory = "settings.customLogDirectory"
static let workbenchBackground = "settings.workbenchBackground"
static let javaBuildFailurePolicies = "settings.javaBuildFailurePolicies"
@@ -49,6 +51,13 @@ final class AppSettings: ObservableObject {
let commands: [String: [KeyboardShortcutBinding]]
}
+ private struct KeyboardShortcutPresetsPayload: Codable {
+ static let currentVersion = 1
+
+ let version: Int
+ let presets: [String: [String: [KeyboardShortcutBinding]]]
+ }
+
private let defaults: any KeyValueStore
private let logDirectoryProvider: any LogDirectoryProviding
@@ -138,6 +147,8 @@ final class AppSettings: ObservableObject {
}
}
@Published private(set) var keyboardShortcutOverrides: [String: [KeyboardShortcutBinding]]
+ @Published private(set) var keyboardShortcutPreset: KeyboardShortcutPreset
+ private var keyboardShortcutOverridesByPreset: [String: [String: [KeyboardShortcutBinding]]]
@Published private(set) var customLogDirectory: URL?
@Published private(set) var workbenchBackground: WorkbenchBackgroundConfiguration
@Published var workbenchBackgroundOpacity: Double {
@@ -199,7 +210,13 @@ final class AppSettings: ObservableObject {
projectOpenBehavior = ProjectOpenBehavior(
rawValue: defaults.string(forKey: Key.projectOpenBehavior) ?? ""
) ?? .ask
- keyboardShortcutOverrides = Self.loadKeyboardShortcutOverrides(from: defaults)
+ let selectedPreset = KeyboardShortcutPreset(
+ rawValue: defaults.string(forKey: Key.keyboardShortcutPreset) ?? ""
+ ) ?? .macOS
+ let presetOverrides = Self.loadKeyboardShortcutPresets(from: defaults)
+ keyboardShortcutPreset = selectedPreset
+ keyboardShortcutOverridesByPreset = presetOverrides
+ keyboardShortcutOverrides = presetOverrides[selectedPreset.rawValue] ?? [:]
customLogDirectory = defaults.string(forKey: Key.customLogDirectory).flatMap { path in
guard !path.isEmpty else { return nil }
return URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL
@@ -355,14 +372,25 @@ final class AppSettings: ObservableObject {
setCustomLogDirectory(nil)
clearWorkbenchBackground()
workbenchBackgroundOpacity = 0.22
+ keyboardShortcutOverridesByPreset = [:]
+ keyboardShortcutPreset = .macOS
+ defaults.set(KeyboardShortcutPreset.macOS.rawValue, forKey: Key.keyboardShortcutPreset)
setKeyboardShortcutOverrides([:])
javaBuildFailurePolicies = [:]
defaults.set(nil, forKey: Key.javaBuildFailurePolicies)
}
func setKeyboardShortcutOverrides(_ value: [String: [KeyboardShortcutBinding]]) {
+ keyboardShortcutOverridesByPreset[keyboardShortcutPreset.rawValue] = value
keyboardShortcutOverrides = value
- saveKeyboardShortcutOverrides()
+ saveKeyboardShortcutPresets()
+ }
+
+ func selectKeyboardShortcutPreset(_ preset: KeyboardShortcutPreset) {
+ guard keyboardShortcutPreset != preset else { return }
+ keyboardShortcutPreset = preset
+ defaults.set(preset.rawValue, forKey: Key.keyboardShortcutPreset)
+ keyboardShortcutOverrides = keyboardShortcutOverridesByPreset[preset.rawValue] ?? [:]
}
/// Providers an agent speaking `apiProtocol` can use.
@@ -511,13 +539,29 @@ final class AppSettings: ObservableObject {
defaults.set(data, forKey: Key.commitMessageAI)
}
- private func saveKeyboardShortcutOverrides() {
- let payload = KeyboardShortcutOverridesPayload(
- version: KeyboardShortcutOverridesPayload.currentVersion,
- commands: keyboardShortcutOverrides
+ private func saveKeyboardShortcutPresets() {
+ let payload = KeyboardShortcutPresetsPayload(
+ version: KeyboardShortcutPresetsPayload.currentVersion,
+ presets: keyboardShortcutOverridesByPreset
)
guard let data = try? JSONEncoder().encode(payload) else { return }
- defaults.set(data, forKey: Key.keyboardShortcutOverrides)
+ defaults.set(data, forKey: Key.keyboardShortcutPresets)
+ defaults.set(nil, forKey: Key.keyboardShortcutOverrides)
+ }
+
+ private static func loadKeyboardShortcutPresets(
+ from defaults: any KeyValueStore
+ ) -> [String: [String: [KeyboardShortcutBinding]]] {
+ guard let data = defaults.data(forKey: Key.keyboardShortcutPresets) else {
+ return [KeyboardShortcutPreset.macOS.rawValue: loadKeyboardShortcutOverrides(from: defaults)]
+ }
+ guard let payload = try? JSONDecoder().decode(KeyboardShortcutPresetsPayload.self, from: data),
+ payload.version == KeyboardShortcutPresetsPayload.currentVersion else {
+ return [:]
+ }
+ return payload.presets
+ .filter { KeyboardShortcutPreset(rawValue: $0.key) != nil }
+ .mapValues(validatedKeyboardShortcutOverrides)
}
private static func loadKeyboardShortcutOverrides(
@@ -529,8 +573,14 @@ final class AppSettings: ObservableObject {
return [:]
}
+ return validatedKeyboardShortcutOverrides(payload.commands)
+ }
+
+ private static func validatedKeyboardShortcutOverrides(
+ _ commands: [String: [KeyboardShortcutBinding]]
+ ) -> [String: [KeyboardShortcutBinding]] {
let knownCommandIDs = Set(LitheCommandCatalog.commands.map(\.id))
- return payload.commands.filter { commandID, bindings in
+ return commands.filter { commandID, bindings in
knownCommandIDs.contains(commandID)
&& bindings.allSatisfy(\.isAssignable)
&& Set(bindings).count == bindings.count
diff --git a/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift b/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift
index 760738d9b..08dfc0902 100644
--- a/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift
+++ b/macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift
@@ -66,6 +66,20 @@ struct KeyboardShortcutSettingsView: View {
.lithePointer()
}
+ LitheSettingsSelect(
+ selection: Binding(
+ get: { feature.selectedPreset },
+ set: { preset in
+ cancelEditing()
+ feature.selectPreset(preset)
+ }
+ ),
+ options: KeyboardShortcutPreset.allCases,
+ width: 200,
+ accessibilityLabel: "Keymap",
+ title: { $0.title }
+ )
+
LitheSettingsSearchField("Search actions or shortcuts", text: $query) { _ in
editingTarget = nil
validationIssue = nil
@@ -228,6 +242,7 @@ struct KeyboardShortcutSettingsView: View {
}
private func cancelEditing() {
+ feature.endRecording()
editingTarget = nil
validationIssue = nil
}
diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift
index cd38d6cd1..97a46edf9 100644
--- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift
+++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift
@@ -21,6 +21,48 @@ struct KeyboardShortcutTests {
}
}
+ @Test
+ func presetsContainAssignableConflictFreeBindings() {
+ for preset in KeyboardShortcutPreset.allCases {
+ var owners: [KeyboardShortcutBinding: String] = [:]
+ for command in LitheCommandCatalog.commands {
+ for binding in preset.bindings(for: command) {
+ #expect(binding.isAssignable, "Invalid \(preset.title) binding for \(command.id)")
+ #expect(owners[binding] == nil, "\(preset.title): \(command.id) conflicts with \(owners[binding] ?? "")")
+ owners[binding] = command.id
+ }
+ }
+ }
+ }
+
+ @Test
+ func eclipseResolvesJetBrainsInheritanceAndExplicitlyClearedActions() throws {
+ func bindings(_ id: String) throws -> [KeyboardShortcutBinding] {
+ KeyboardShortcutPreset.eclipse.bindings(for: try #require(LitheCommandCatalog.command(id: id)))
+ }
+
+ #expect(try bindings("save") == [.keyPress(key: "s", modifiers: [.control])]) // $default
+ #expect(try bindings("settings") == [.keyPress(key: ",", modifiers: [.command])]) // Mac parent
+ #expect(try bindings("toggle-run") == [.keyPress(key: "4", modifiers: [.command])])
+ #expect(try bindings("debug-resume") == [.keyPress(key: "f8", modifiers: [])]) // Eclipse override
+ for id in ["find-in-file", "replace-in-file", "go-to-implementation", "replace-in-project"] {
+ #expect(try bindings(id).isEmpty, "Eclipse explicitly clears \(id)")
+ }
+ #expect(try bindings("open-project") == [.keyPress(key: "o", modifiers: [.command])]) // Lithe-only
+
+ let feature = KeyboardShortcutFeatureModel(settings: AppSettings(store: KeyboardShortcutTestStore()))
+ feature.selectPreset(.eclipse)
+ #expect(feature.displayText(for: "find-in-file") == nil)
+ #expect(feature.registrations.first { $0.commandID == "find-in-file" }?.bindings == [])
+ }
+
+ @Test
+ func ideaClassicStopUsesControlF2() throws {
+ let stop = try #require(LitheCommandCatalog.command(id: "stop-run"))
+ #expect(KeyboardShortcutPreset.ideaClassic.bindings(for: stop)
+ == [.keyPress(key: "f2", modifiers: [.control])])
+ }
+
@Test
func toggleBreakpointUsesTheIDEADefaultShortcut() throws {
let command = try #require(LitheCommandCatalog.command(id: "toggle-breakpoint"))
@@ -121,6 +163,55 @@ struct KeyboardShortcutTests {
#expect(settings.keyboardShortcutOverrides.isEmpty)
}
+ @Test
+ func switchingPresetsKeepsOverridesSeparateAndPersistsSelection() throws {
+ let store = KeyboardShortcutTestStore()
+ let settings = AppSettings(store: store)
+ let feature = KeyboardShortcutFeatureModel(settings: settings)
+ let macBinding = KeyboardShortcutBinding.keyPress(key: "k", modifiers: [.command, .option])
+ let classicBinding = KeyboardShortcutBinding.keyPress(key: "p", modifiers: [.control, .option])
+
+ try feature.replaceBindings(for: "run", with: [macBinding])
+ feature.selectPreset(.ideaClassic)
+ #expect(feature.effectiveBindings(for: "run") == [.keyPress(key: "f10", modifiers: [.shift])])
+ try feature.replaceBindings(for: "run", with: [classicBinding])
+ feature.selectPreset(.macOS)
+ #expect(feature.effectiveBindings(for: "run") == [macBinding])
+ feature.selectPreset(.eclipse)
+ #expect(feature.effectiveBindings(for: "run") == [.keyPress(key: "f11", modifiers: [.command, .shift])])
+
+ let reloaded = KeyboardShortcutFeatureModel(settings: AppSettings(store: store))
+ #expect(reloaded.selectedPreset == .eclipse)
+ reloaded.selectPreset(.ideaClassic)
+ #expect(reloaded.effectiveBindings(for: "run") == [classicBinding])
+ reloaded.selectPreset(.macOS)
+ #expect(reloaded.effectiveBindings(for: "run") == [macBinding])
+ }
+
+ @Test
+ func legacyOverridesMigrateToTheMacOSPreset() throws {
+ struct LegacyPayload: Encodable {
+ let version = 1
+ let commands: [String: [KeyboardShortcutBinding]]
+ }
+ let store = KeyboardShortcutTestStore()
+ let binding = KeyboardShortcutBinding.keyPress(key: "k", modifiers: [.command, .option])
+ store.set(
+ try JSONEncoder().encode(LegacyPayload(commands: ["run": [binding]])),
+ forKey: "settings.keyboardShortcutOverrides"
+ )
+
+ let feature = KeyboardShortcutFeatureModel(settings: AppSettings(store: store))
+ #expect(feature.selectedPreset == .macOS)
+ #expect(feature.effectiveBindings(for: "run") == [binding])
+ feature.selectPreset(.eclipse)
+ #expect(feature.effectiveBindings(for: "run") != [binding])
+ feature.selectPreset(.macOS)
+ #expect(feature.effectiveBindings(for: "run") == [binding])
+ try feature.replaceBindings(for: "run", with: [binding])
+ #expect(store.data(forKey: "settings.keyboardShortcutOverrides") == nil)
+ }
+
@Test
func conflictReportsTheOwningCommandAndDoesNotPersist() throws {
let settings = AppSettings(store: KeyboardShortcutTestStore())
@@ -207,6 +298,23 @@ struct KeyboardShortcutTests {
== LitheCommandCatalog.command(id: "run")?.defaultBindings
)
}
+
+ @Test
+ func applicationRestoreDefaultsClearsEveryPreset() throws {
+ let settings = AppSettings(store: KeyboardShortcutTestStore())
+ let feature = KeyboardShortcutFeatureModel(settings: settings)
+ let binding = KeyboardShortcutBinding.keyPress(key: "k", modifiers: [.command, .option])
+ try feature.replaceBindings(for: "run", with: [binding])
+ feature.selectPreset(.ideaClassic)
+ try feature.replaceBindings(for: "run", with: [binding])
+
+ settings.restoreDefaults()
+
+ #expect(feature.selectedPreset == .macOS)
+ #expect(settings.keyboardShortcutOverrides.isEmpty)
+ feature.selectPreset(.ideaClassic)
+ #expect(feature.effectiveBindings(for: "run") == [.keyPress(key: "f10", modifiers: [.shift])])
+ }
}
@Suite("Shortcut session coordination")
@@ -237,6 +345,26 @@ struct ShortcutSessionCoordinatorTests {
#expect(!factory.detector.isSuspended)
}
+ @Test
+ func presetSwitchUpdatesTheActiveDetectorImmediately() {
+ let settings = AppSettings(store: KeyboardShortcutTestStore())
+ let feature = KeyboardShortcutFeatureModel(settings: settings)
+ let factory = RecordingShortcutDetectorFactory()
+ var publications = 0
+ let coordinator = ShortcutSessionCoordinator(
+ settings: settings, feature: feature, factory: factory,
+ onRegistrationsChanged: { publications += 1 }, onCommand: { _ in }
+ )
+ defer { coordinator.shutdown() }
+
+ feature.selectPreset(.ideaClassic)
+
+ #expect(feature.selectedPreset == .ideaClassic)
+ #expect(factory.detector.registrations.first { $0.commandID == "run" }?.bindings
+ == [.keyPress(key: "f10", modifiers: [.shift])])
+ #expect(publications == 2)
+ }
+
@Test
func inactiveRecordingAndShutdownSessionsRejectQueuedCommands() {
let settings = AppSettings(store: KeyboardShortcutTestStore())
diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json
index 18b534c04..6feb3bdb4 100644
--- a/shared/platform-feature-matrix.json
+++ b/shared/platform-feature-matrix.json
@@ -1902,7 +1902,7 @@
"verificationStatus": "pending"
},
"owner": "Settings",
- "verification": "从设置 → 快捷键打开编辑器,按命令名称、ID 和按键搜索,修改跳转到实现的快捷键、制造冲突并恢复默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。",
+ "verification": "macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8、继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。Windows 保持现有快捷键编辑、导出和导入验证。",
"notes": "Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。"
},
{