From e0b97f736d9e5ab86ec740b771c1028bc8b36ff7 Mon Sep 17 00:00:00 2001 From: arkleselect Date: Mon, 28 Sep 2026 12:21:11 +0800 Subject: [PATCH 1/3] feat(macos): add selectable keymap presets --- .../2026-08-15-macos-keymap-customization.md | 24 +++-- docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- .../KeyboardShortcutFeatureModel.swift | 13 ++- .../Models/Keymap/LitheCommandCatalog.swift | 68 ++++++++++++ .../Lithe/Models/Settings/AppSettings.swift | 66 ++++++++++-- .../App/KeyboardShortcutSettingsView.swift | 15 +++ .../LitheTests/KeyboardShortcutTests.swift | 100 ++++++++++++++++++ shared/platform-feature-matrix.json | 2 +- 9 files changed, 272 insertions(+), 20 deletions(-) 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..531ba81ec 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,23 @@ Command、Control 或 Option 之一,功能键、方向键等特殊按键可以 - 未知命令、非法绑定和重复绑定被忽略; - 整个载荷无法解码时回退到全部默认值。 +预设只映射 Lithe 已有且与 IntelliJ Community 源码能对应的操作,未映射的 +命令沿用 Lithe 默认绑定。切换预设时先切换预设身份,再发布该预设的覆盖值, +使设置页、菜单和原生监听器同时看到新绑定;录制中的操作在切换前取消。 +应用级恢复默认清除所有预设的用户覆盖并返回 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 +68,9 @@ Search Everywhere 保留双击 Shift 和 `⇧⌘O` 两个默认入口。应用 明确报告冲突并拒绝保存。 - **立即设计 Windows 与 macOS 共用 keymap 契约**:两端事件模型和原生 编辑行为不同,当前收益不足以抵消跨平台兼容面,暂不扩展范围。 -- **加入 IDEA keymap 导入、预设、导出或云同步**:这些能力会扩大稳定性 - 和数据兼容范围,当前需求不包含它们。 +- **直接导入 IDEA 全量 keymap 文件**:Lithe 没有 IDEA 的全部命令和动作 + 语义,全量导入会产生无法执行的配置;只映射现有对应操作。自定义 keymap + 文件导入、导出和云同步仍不在本次范围。 ## 后果 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 1c6c29e25..3ed74ef3e 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -70,7 +70,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,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 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 e55e4495b..86965bf08 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -205,7 +205,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,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 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..47c99f2a8 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -8,6 +8,74 @@ 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] ?? 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]), + "stop-run": shortcut("f2"), + "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]) + ] + + // Adapt the matching actions from Eclipse (Mac OS X), which inherits the macOS keymap. + private static let eclipseBindings: [String: [KeyboardShortcutBinding]] = [ + "run": shortcut("f11", [.command, .shift]), + "debug": shortcut("f11", [.command]), + "debug-step-over": shortcut("f6"), + "debug-step-into": shortcut("f5"), + "debug-step-out": shortcut("f7"), + "toggle-breakpoint": shortcut("b", [.command, .shift]), + "find-next": shortcut("k", [.command]), + "find-previous": shortcut("k", [.command, .shift]), + "find-usages": shortcut("g", [.command, .shift]), + "go-to-definition": shortcut("f3"), + "search-in-project": shortcut("h", [.control]) + ] +} + // 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 594d3b5f8..102514a79 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..b64ff75cf 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -21,6 +21,20 @@ 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 toggleBreakpointUsesTheIDEADefaultShortcut() throws { let command = try #require(LitheCommandCatalog.command(id: "toggle-breakpoint")) @@ -121,6 +135,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 +270,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 +317,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 f8308ed12..06627e03b 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1786,7 +1786,7 @@ "verificationStatus": "pending" }, "owner": "Settings", - "verification": "从设置 → 快捷键打开编辑器,按命令名称、ID 和按键搜索,修改跳转到实现的快捷键、制造冲突并恢复默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。", + "verification": "macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。Windows 保持现有快捷键编辑、导出和导入验证。", "notes": "Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。" }, { From a13bc419692daebf685f6fcba88e017c217a5e8e Mon Sep 17 00:00:00 2001 From: arkleselect Date: Mon, 28 Sep 2026 17:16:53 +0800 Subject: [PATCH 2/3] fix(macOS): resolve Eclipse keymap inheritance and Classic Stop binding --- .../2026-08-15-macos-keymap-customization.md | 10 +++- docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- .../Models/Keymap/LitheCommandCatalog.swift | 56 +++++++++++++++++-- .../LitheTests/KeyboardShortcutTests.swift | 28 ++++++++++ shared/platform-feature-matrix.json | 2 +- 6 files changed, 91 insertions(+), 9 deletions(-) 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 531ba81ec..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 @@ -37,9 +37,15 @@ Command、Control 或 Option 之一,功能键、方向键等特殊按键可以 - 未知命令、非法绑定和重复绑定被忽略; - 整个载荷无法解码时回退到全部默认值。 -预设只映射 Lithe 已有且与 IntelliJ Community 源码能对应的操作,未映射的 -命令沿用 Lithe 默认绑定。切换预设时先切换预设身份,再发布该预设的覆盖值, +预设只映射 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) diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 3ed74ef3e..e981e962a 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -70,7 +70,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,macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 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 +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 86965bf08..d17f39790 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -205,7 +205,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 | macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。Windows 保持现有快捷键编辑、导出和导入验证。 | 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/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 47c99f2a8..3eecf3591 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -25,7 +25,9 @@ enum KeyboardShortcutPreset: String, CaseIterable, Sendable { switch self { case .macOS: command.defaultBindings case .ideaClassic: Self.ideaClassicBindings[command.id] ?? command.defaultBindings - case .eclipse: Self.eclipseBindings[command.id] ?? command.defaultBindings + case .eclipse: Self.eclipseBindings[command.id] + ?? Self.eclipseParentBindings[command.id] + ?? command.defaultBindings } } @@ -39,7 +41,9 @@ enum KeyboardShortcutPreset: String, CaseIterable, Sendable { "save": shortcut("s", [.control]), "run": shortcut("f10", [.shift]), "debug": shortcut("f9", [.shift]), - "stop-run": shortcut("f2"), + // 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)], @@ -60,19 +64,63 @@ enum KeyboardShortcutPreset: String, CaseIterable, Sendable { "toggle-debug": shortcut("5", [.option]) ] - // Adapt the matching actions from Eclipse (Mac OS X), which inherits the macOS keymap. + // 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"), - "search-in-project": shortcut("h", [.control]) + "go-to-implementation": [], + "search-in-project": shortcut("h", [.control]), + "replace-in-project": [] ] } diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index b64ff75cf..97a46edf9 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -35,6 +35,34 @@ struct KeyboardShortcutTests { } } + @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")) diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 06627e03b..be26f6ac6 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1786,7 +1786,7 @@ "verificationStatus": "pending" }, "owner": "Settings", - "verification": "macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等对应操作在界面和实际监听器中的绑定;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认。Windows 保持现有快捷键编辑、导出和导入验证。", + "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、原生文件对话框和快捷键实际执行验收。" }, { From a0d96ad2cef9befcf9c8a768baeb57b3bc2e9e9c Mon Sep 17 00:00:00 2001 From: arkleselect Date: Mon, 28 Sep 2026 18:45:56 +0800 Subject: [PATCH 3/3] fix(ci): allow Rust Core bridge job to finish on macOS --- .github/workflows/ci-macos.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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