From 8d12ef06d44acb2ce04af96f5c2f3b1d0092ad71 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Tue, 3 Nov 2026 12:51:47 -0800 Subject: [PATCH 1/4] Improve IDE shortcut import and terminal handling --- ...26-11-03-macos-terminal-toggle-shortcut.md | 38 + ...-10-31-macos-ide-editor-settings-import.md | 29 + .gitignore | 1 + .../Official/EditorKeymapImport/Cargo.lock | 3743 +++++++++++++++++ .../Official/EditorKeymapImport/Cargo.toml | 15 + .../mac/Official/EditorKeymapImport/NOTICE.md | 12 + .../EditorKeymapImport/assemble-package.sh | 39 + .../licenses/Apache-2.0.txt | 203 + .../licenses/jsonc-parser-MIT.txt | 21 + .../licenses/quick-xml-MIT.txt | 23 + .../Official/EditorKeymapImport/plugin.json | 38 + .../Official/EditorKeymapImport/src/idea.rs | 347 ++ .../Official/EditorKeymapImport/src/lib.rs | 3 + .../Official/EditorKeymapImport/src/main.rs | 62 + .../EditorKeymapImport/src/shortcut.rs | 90 + .../Official/EditorKeymapImport/src/vscode.rs | 165 + .../Official/EditorKeymapImport/tests/idea.rs | 99 + .../EditorKeymapImport/tests/manifest.rs | 25 + .../EditorKeymapImport/tests/session.rs | 177 + .../EditorKeymapImport/tests/vscode.rs | 175 + Plugins/marketplace/official-plugins.json | 20 + docs/ci-builds.md | 13 + macos/Resources/en.lproj/Localizable.strings | 12 + .../zh-Hans.lproj/Localizable.strings | 12 + .../Application/Composition/AppServices.swift | 6 +- .../EditorSettingsImportFeatureModel.swift | 68 +- .../Features/PluginMarketplace.swift | 2 +- .../Lithe/Core/Rust/RustCoreBridge.swift | 27 + .../Rust/RustDebugShortcutOperations.swift | 16 + .../Rust/RustKeymapImportOperations.swift | 133 + .../RustLanguageServerRuntimeAdapter.swift | 25 + .../Models/AppModel/AppModel+Debugging.swift | 12 + .../AppModel/AppModel+FeatureState.swift | 2 +- .../Models/Keymap/LitheCommandCatalog.swift | 3 +- macos/Sources/Lithe/Models/LitheAction.swift | 1 + .../Settings/EditorSettingsImport.swift | 4 +- .../MacOS/MacEditorSettingsImportReader.swift | 55 +- .../MacOS/MacImportedKeybindings.swift | 133 - .../Platform/MacOS/MacServiceContainer.swift | 27 +- .../MacEditorKeymapImportService.swift | 58 + .../Plugins/MacKeymapImportHostService.swift | 58 + .../Plugins/MacKeymapImportRecognition.swift | 45 + .../Plugins/MacKeymapImportSnapshot.swift | 259 ++ .../Plugins/MacKeymapImportSnapshots.swift | 44 + .../Plugins/MacPluginBackendSessions.swift | 153 +- .../MacOS/Plugins/MacPluginManager.swift | 2 +- .../MacOS/Plugins/MacPluginPackageStore.swift | 8 +- .../MacOS/UI/MacShortcutDetector.swift | 28 +- .../Views/App/EditorSettingsImportView.swift | 15 +- .../Views/App/PluginManagementView.swift | 4 +- .../Views/App/PluginMarketplaceView.swift | 4 + .../LanguageServerRuntimeContracts.swift | 42 + .../Runtime/LanguageServerSession.swift | 58 +- .../Catalog/BuiltInModuleCatalog.swift | 19 + .../LitheModuleAPI/Plugins/PluginTypes.swift | 1 + .../EditorSettingsImportTests.swift | 199 +- .../LitheTests/KeyboardShortcutTests.swift | 5 +- .../MacEditorKeymapImportServiceTests.swift | 60 + .../MacKeymapImportRecognitionTests.swift | 36 + .../MacKeymapImportSnapshotTests.swift | 80 + .../MacKeymapImportSnapshotsTests.swift | 75 + .../MacPluginBackendSessionsTests.swift | 22 + .../PluginBundledPublisherTrustTests.swift | 32 + .../RustDebugShortcutOperationsTests.swift | 12 + .../RustKeymapImportOperationsTests.swift | 69 + .../RustLanguageServerLaunchTests.swift | 172 +- .../TerminalShortcutInputPolicyTests.swift | 57 + rust/lithe-core/src/debug/mod.rs | 2 + rust/lithe-core/src/debug/shortcut.rs | 58 + rust/lithe-core/src/lsp/interface/client.rs | 29 +- rust/lithe-core/src/lsp/interface/engine.rs | 373 +- .../lsp/interface/engine_real_jdt_tests.rs | 1 + .../src/lsp/interface/keymap_provider.rs | 106 + rust/lithe-core/src/lsp/interface/mod.rs | 1 + .../src/lsp/interface/plugin_function.rs | 104 +- rust/lithe-core/src/lsp/interface/types.rs | 8 + rust/lithe-core/src/plugins/backend.rs | 8 +- rust/lithe-core/src/plugins/capabilities.rs | 3 + rust/lithe-core/src/plugins/declarative.rs | 93 +- rust/lithe-core/src/plugins/keymap_import.rs | 673 +++ rust/lithe-core/src/plugins/mod.rs | 1 + rust/lithe-core/src/protocol/command.rs | 16 + rust/lithe-core/src/runtime/dispatcher.rs | 36 + .../resources/reuse-worktree-resources.mjs | 3 +- .../test-reuse-worktree-resources.mjs | 24 +- scripts/resources/verify-download-cache.mjs | 1 + scripts/resources/worktree-resources.json | 13 + shared/contracts/plugins/v1/README.md | 67 + .../keymap-import-provider.plugin.json | 18 + .../plugins/v1/plugin-manifest.schema.json | 42 + shared/contracts/rust-core-api.md | 84 + shared/fixtures/keymap/import-plan-v1.json | 67 + .../fixtures/keymap/import-provider-v1.json | 86 + .../keymap/provider-negotiation-v1.json | 14 + shared/fixtures/keymap/source-plans-v1.json | 18 + shared/i18n/locales/en-US.json | 6 + shared/i18n/locales/zh-CN.json | 6 + shared/i18n/messages.json | 58 + .../features/editor-ide-settings-import.json | 9 +- .../features/terminal-interaction.json | 4 +- 100 files changed, 9135 insertions(+), 390 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-11-03-macos-terminal-toggle-shortcut.md create mode 100644 Plugins/mac/Official/EditorKeymapImport/Cargo.lock create mode 100644 Plugins/mac/Official/EditorKeymapImport/Cargo.toml create mode 100644 Plugins/mac/Official/EditorKeymapImport/NOTICE.md create mode 100755 Plugins/mac/Official/EditorKeymapImport/assemble-package.sh create mode 100644 Plugins/mac/Official/EditorKeymapImport/licenses/Apache-2.0.txt create mode 100644 Plugins/mac/Official/EditorKeymapImport/licenses/jsonc-parser-MIT.txt create mode 100644 Plugins/mac/Official/EditorKeymapImport/licenses/quick-xml-MIT.txt create mode 100644 Plugins/mac/Official/EditorKeymapImport/plugin.json create mode 100644 Plugins/mac/Official/EditorKeymapImport/src/idea.rs create mode 100644 Plugins/mac/Official/EditorKeymapImport/src/lib.rs create mode 100644 Plugins/mac/Official/EditorKeymapImport/src/main.rs create mode 100644 Plugins/mac/Official/EditorKeymapImport/src/shortcut.rs create mode 100644 Plugins/mac/Official/EditorKeymapImport/src/vscode.rs create mode 100644 Plugins/mac/Official/EditorKeymapImport/tests/idea.rs create mode 100644 Plugins/mac/Official/EditorKeymapImport/tests/manifest.rs create mode 100644 Plugins/mac/Official/EditorKeymapImport/tests/session.rs create mode 100644 Plugins/mac/Official/EditorKeymapImport/tests/vscode.rs create mode 100644 macos/Sources/Lithe/Core/Rust/RustDebugShortcutOperations.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustKeymapImportOperations.swift delete mode 100644 macos/Sources/Lithe/Platform/MacOS/MacImportedKeybindings.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacEditorKeymapImportService.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportHostService.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportRecognition.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportSnapshot.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportSnapshots.swift create mode 100644 macos/Tests/LitheTests/MacEditorKeymapImportServiceTests.swift create mode 100644 macos/Tests/LitheTests/MacKeymapImportRecognitionTests.swift create mode 100644 macos/Tests/LitheTests/MacKeymapImportSnapshotTests.swift create mode 100644 macos/Tests/LitheTests/MacKeymapImportSnapshotsTests.swift create mode 100644 macos/Tests/LitheTests/RustDebugShortcutOperationsTests.swift create mode 100644 macos/Tests/LitheTests/RustKeymapImportOperationsTests.swift create mode 100644 rust/lithe-core/src/debug/shortcut.rs create mode 100644 rust/lithe-core/src/lsp/interface/keymap_provider.rs create mode 100644 rust/lithe-core/src/plugins/keymap_import.rs create mode 100644 shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json create mode 100644 shared/fixtures/keymap/import-plan-v1.json create mode 100644 shared/fixtures/keymap/import-provider-v1.json create mode 100644 shared/fixtures/keymap/provider-negotiation-v1.json create mode 100644 shared/fixtures/keymap/source-plans-v1.json diff --git a/.agents/notes/implemented/bug-fix/2026-11-03-macos-terminal-toggle-shortcut.md b/.agents/notes/implemented/bug-fix/2026-11-03-macos-terminal-toggle-shortcut.md new file mode 100644 index 0000000000..6ab2ebfdc0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-03-macos-terminal-toggle-shortcut.md @@ -0,0 +1,38 @@ +# Agent 笔记:终端聚焦时保留切换终端快捷键 + +状态:已实现 + +## 先说结论 + +终端有焦点时,用户配置的“切换终端”快捷键仍须关闭终端面板;其他纯 Control 组合键继续交给 shell。先根据当前有效键位识别命令,再决定是否让终端处理,不能按来源 IDE 或某个固定按键写特例。 + +## 问题 + +VS Code 的默认终端切换键是 Control 加反引号。导入后它能从编辑器打开终端,却不能从已聚焦的终端关闭,因为 #1360 为保护 shell 将所有纯 Control 组合键交给了终端。该保护仍然需要:Control-D 是结束输入,Control-R 常用于历史搜索。 + +## 决策 + +macOS 事件监听器复用当前有效注册,先识别命令;终端持有焦点且修饰键只有 Control 时,仅让绑定到 `toggle-terminal` 的键交给应用,其余按键交回终端。用户主动把终端切换设为 Control-D 时,该显式绑定会优先于终端输入;无绑定时不截获。输入法标记文字和 HTML 预览原有优先级保持不变。 + +正确例子:导入的 Control 加反引号能打开和关闭终端,Control-D 仍能结束未显式将它设为切换键的 shell。不要在监听器中对 VS Code 或反引号硬编码,也不要让所有已注册的 Control 组合键抢走 shell 输入。 + +## 考虑过的备选方案 + +- 所有 Control 组合键都交给应用:会恢复 #1360 的 shell 输入回归。 +- 仅放行 Control 加反引号:会忽略用户自定义的终端切换键,且把来源 IDE 键位写死在事件监听器。 + +## 后果 + +用户显式指定的终端切换键在终端内有效,其他应用快捷键仍不抢终端 Control 输入。显式选择与 shell 功能相同的键时,该键会用于切换终端,这是可配置快捷键的直接结果。 + +## 验证 + +- `TerminalShortcutInputPolicyTests` 使用真实 AppKit 事件监听器验证切换、Control-D、Control-R 和普通应用组合键;输入法及 HTML 预览回归与其共同运行。 +- 隔离的 macOS Release 包导入 VS Code 键位后,从编辑器按 Control 加反引号打开终端,终端有焦点时再次按下成功关闭;重开后 Control-D 结束测试 shell。包内文件清单和 SHA-256 在流程前后保持一致。 +- `./scripts/macos/verify/verify-runtime-bundle-immutability.sh` 和 `./scripts/agent-notes/verify-agent-notes.sh`。 + +## 适用范围 + +- `macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift` +- `macos/Tests/LitheTests/TerminalShortcutInputPolicyTests.swift` +- `shared/platform-feature-matrix/features/terminal-interaction.json` diff --git a/.agents/notes/implemented/feature/2026-10-31-macos-ide-editor-settings-import.md b/.agents/notes/implemented/feature/2026-10-31-macos-ide-editor-settings-import.md index 57e12a9dc6..3ace7f2d10 100644 --- a/.agents/notes/implemented/feature/2026-10-31-macos-ide-editor-settings-import.md +++ b/.agents/notes/implemented/feature/2026-10-31-macos-ide-editor-settings-import.md @@ -186,3 +186,32 @@ VS Code/Cursor 用户规则按来源顺序处理按键占用。后续规则无 `titledPanelDetachesDuringDragAndCleansUp` 覆盖内容区点击不解除、标题栏解除、拖动期间不重新居中、松手恢复、移动结束兜底,以及拖动期间关闭弹窗或父窗口。独立的生产 presenter 原生检查在深浅色下共八个场景通过;这些生命周期检查不代表 macOS 27 实机帧率验收。 移动通知回归通过注入的按键状态读取闭包明确覆盖“按住时不挂回、释放后挂回”;生产默认仍读取 `NSEvent.pressedMouseButtons`。测试不再依赖用户当前是否按住实体鼠标,桌面隔离无需修改。 + +## 常用导航和查找快捷键 + +IDEA 的旧 `Mac OS X` 方案用 `⌘G` 跳转到行,而 Lithe 默认用它查找下一个结果。只导入前者会让整批导入因冲突失败,因此同时导入来源方案的查找前后项;新旧方案分别使用各自的参考键位。自定义 IDEA 动作也支持映射已有的查找、替换、定义和引用命令。VS Code 的参考默认值补充文件内查找和替换、跳转定义与查找引用,并支持这些命令的无条件用户覆盖。 + +参考 [OpenIDEA 的平台预设](https://github.com/l0ng-ai/openidea/blob/main/src/platform/keymaps/vscode-macos.ts) 按动作同时处理相关快捷键,来源键值以 [VS Code 官方参考](https://code.visualstudio.com/docs/reference/default-keybindings) 和既有固定版本的 JetBrains Community 键位为准。Lithe 仍合并到用户选中的预设,不复制上游整个预设;条件规则及连续键不会被提升为全局命令,真实冲突仍拒绝写入,避免静默覆盖用户设置。回归检查导入、实际注册、重开和撤销;真实产品与其他键盘布局仍需单独验收。 + +2026-11-03 打包应用验收:隔离开发版实际读取本机 IDEA 2025.3 和 VS Code 配置,只导入快捷键;重启后分别验证 IDEA `⌘L` 跳行、`⌘G` 查找下一个,以及 VS Code `⌃G` 跳行、`⌥⌘F` 替换、`⇧⌘F` 项目搜索。隔离配置覆盖旧 `Mac OS X` 方案,确认 `⌘G` 跳行和 `⌃L` 查找下一个;另一隔离 VS Code Profile 验证自定义 `⌥⌘G`。Java 测试文件中,导入的 `F12` 从调用跳到定义,`⇧F12` 展示引用。Control 加反引号 能打开终端,但终端有焦点时再按不会关闭,焦点移到搜索框后可关闭:纯 Control 键当前让给终端输入,不能宣称这个切换流程完全通过。IDEA 和 VS Code 的其他版本、条件规则、连续键及非美式键盘布局仍待验证。 + +上述终端切换问题的修复和后续实机复验见 [终端聚焦时保留切换终端快捷键](../bug-fix/2026-11-03-macos-terminal-toggle-shortcut.md)。 + + +## 导入快捷键与目标默认键冲突 + +来源导入键和目标未修改的默认键重叠时,Core 根据目标当前注册与自定义标记规划让位,只移除被占用的默认按键,保留同一动作的其他按键。用户自定义键位、来源内两个动作争用同一键时报告冲突并阻止整批写入;来源中同时重设两个动作时允许交换。macOS 导入事务在既有源版本和目标基线检查之后应用计划,并保留撤销能力。计划失败或存在不可表达的来源键时拒绝导入,不丢弃自定义绑定。 + +定向回归覆盖 F9 让出默认调试继续键、用户自定义键保护、冲突无部分应用及撤销。该测试使用构造的来源结果;IDEA/VS Code 插件解析及修改后的打包应用验收尚未完成,不据此声称实际来源中的 F9 已可导入。 + +## 快捷键来源插件与受限快照 + +2026-11-03 起,前述主程序内维护 IDEA 与 VS Code 快捷键解析的做法由此节替代:官方 `editor-keymap-import` 插件拥有来源格式、命令映射与参考默认键;macOS 只在用户选中来源后按声明做元数据识别,通过插件专属、短期有效的快照提供有限输入,不向插件暴露任意路径。Core 验证插件声明和返回动作、规划默认键让位与冲突;不支持的原生键、重复来源提供者或读取异常不会部分应用。来源数据和摘要在应用前重新核对,取消、替换插件或完成请求后撤销快照;仅服务导入的后台进程在最后一次请求结束时停止。默认发现不启动插件。 + +插件构建产物和 Cargo Git 检出归本工作树 `.artifacts/`,随应用打包的插件可执行文件和许可在构建期固定并签名;运行时只读取插件包,源配置只读,短期快照只在内存及打开的只读描述符中存在,不向发行包写入,也不改变代码签名或 Sparkle 增量更新基线。跨工作树复用规则见 `scripts/resources/worktree-resources.json`。旧的原生快捷键解析器不再参与导入;有条件规则和连续键不会被推广为全局按键,来源移除规则会遮蔽相关参考键。IDEA 已知父方案和 VS Code 的常用动作有插件及真实 Core 会话测试,但插件版打包应用的人工按键验收仍需完成。 + +VS Code 的 F5 是结合状态的启动或继续调试动作:Core 在未启动、已暂停、运行中和请求未完成等状态之间选择操作,平台执行时再次由实际调试会话保护;F9 断点与 Lithe 未更改的默认调试继续键冲突时,由通用导入规划只让出该默认键。 + +2026-11-03 插件版 Release 包复验:主机固定官方非语言插件的签名策略,随包副本只在宿主签名封装内可用,外部副本仍需发布者签名。本机 VS Code 与 IDEA 来源均显示并成功导入键位,VS Code 的 F9 断点、F12 定义、Shift-F12 引用、Control 加反引号终端开关和 Control-D shell 输入、F5 启动并在断点暂停后继续,以及 IDEA 的 Command-L 跳行、Command-F8 断点、Command-G 查找下一个均经实际操作;自定义 VS Code 键位与源文件变化后拒绝旧预览、重新扫描及撤销也经实际操作。包文件哈希和签名在验收前后不变。覆盖范围是本机所装版本和隔离测试配置,不代表任意 IDE 扩展、键盘布局或 Windows 产品已经验收。 + +VS Code 移除规则按上游解析器的两阶段行为处理:只移除指定命令匹配的参考默认键,不清除其他命令的同键绑定或用户新增绑定;再依来源顺序处理单键覆盖及不能转成全局按键的条件/连续键。该修正有先失败后通过的插件回归,插件版打包应用仍需再次复验该边界。 diff --git a/.gitignore b/.gitignore index 0ad31b4d7e..918228b48c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .build/ rust/target/ rust/*/target/ +Plugins/mac/Official/EditorKeymapImport/target/ /.worktrees/ .swiftpm/ .idea/ diff --git a/Plugins/mac/Official/EditorKeymapImport/Cargo.lock b/Plugins/mac/Official/EditorKeymapImport/Cargo.lock new file mode 100644 index 0000000000..1181f22fa6 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/Cargo.lock @@ -0,0 +1,3743 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "agent-client-protocol" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f780597b2c88ab83d02509bcc3e86366751f86fa12114005e35c55ed691763" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash 2.1.3", + "rustix 1.1.6", + "schemars 1.2.3", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4893bfb11e0e193aa5a64611a126ee87567acb1879556519c902722899229de" +dependencies = [ + "quote", + "syn 3.0.9", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d595a79e1665d02c91c7dd1bedd118501e75cc0a3d5b1651b5150ec4c5c1e0d" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.3", + "serde", + "serde_json", + "serde_with", + "strum", + "tracing", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "ammonia" +version = "4.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f45dfa685e1ec33b1bf1a3b93b8047cbdbef41b6040deebbe6bea7720a45574" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "url", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.6", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.6", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "comrak" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d5910408554659ed848ff469e67ec83b30f179e72cec286cfdae64d1616f466" +dependencies = [ + "caseless", + "emojis", + "entities", + "finl_unicode", + "jetscii", + "phf 0.13.1", + "phf_codegen 0.13.1", + "rustc-hash 2.1.3", + "smallvec", + "typed-arena", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59" +dependencies = [ + "dtoa-short", + "itoa", + "smallvec", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "emojis" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4d5d50b0b58df5173d8ff1192b4d1422ceae5d981b30d4b6f8ed1d673a2bc4" +dependencies = [ + "phf 0.13.1", +] + +[[package]] +name = "entities" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130" +dependencies = [ + "log", + "markup5ever", + "memchr", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jetscii" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + +[[package]] +name = "jiff" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ac703058abac89e5c39b0b879dce2b6b14a4ba36600c7e7e3d0c60a6883ef3c" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee43309eb53bafb8c36d35d6e444769c8f8e6206a9fbf7546f5db9d7d4a0e93" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dc32470c67eb7d0e9ccb74303fa925313dc04cecb1aa048beaa6f391e36a93d" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonc-parser" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ecded084f9b9a718d39668a42657c32253663d1f6afb3fbb9134f0305f73f48" +dependencies = [ + "serde", +] + +[[package]] +name = "kill_tree" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3879339076ac4da142cc852d91693462927cbc99773b5ea422e4834e68c4ff2" +dependencies = [ + "bindgen", + "nix", + "tokio", + "tracing", + "windows", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lithe-agent-host" +version = "0.1.0" +dependencies = [ + "agent-client-protocol", + "bytes", + "flate2", + "fs2", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "kill_tree", + "libc", + "reqwest", + "rmcp", + "semver", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "sse-stream", + "tokio", + "tokio-util", + "tungstenite", + "url", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "lithe-core" +version = "0.1.0" +dependencies = [ + "ammonia", + "base64 0.22.1", + "chrono", + "comrak", + "lithe-agent-host", + "lithe-git-host", + "lithe-ide-host", + "lithe-remote", + "quick-xml", + "rand 0.8.8", + "regex", + "reqwest", + "rsa", + "serde", + "serde_json", + "serde_yaml_ng", + "sha1", + "sha2", + "shell-words", + "toml", + "toml_edit", + "tree-sitter", + "tree-sitter-java", + "unicode-normalization", + "url", + "zip", +] + +[[package]] +name = "lithe-editor-keymap-import" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "jsonc-parser", + "lithe-core", + "lithe-plugin-sdk", + "quick-xml", + "serde_json", +] + +[[package]] +name = "lithe-git-host" +version = "0.1.0" +dependencies = [ + "libc", + "rand 0.8.8", + "serde", + "serde_json", + "windows-sys 0.61.2", +] + +[[package]] +name = "lithe-ide-host" +version = "0.1.0" +dependencies = [ + "axum", + "fs2", + "serde", + "serde_json", + "sha2", + "tokio", + "uuid", +] + +[[package]] +name = "lithe-plugin-sdk" +version = "0.1.0" +source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs.git?rev=6e9bd1df0fd0f62fc653b10855f0353382d20354#6e9bd1df0fd0f62fc653b10855f0353382d20354" +dependencies = [ + "base64 0.22.1", + "serde_json", +] + +[[package]] +name = "lithe-remote" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_shared 0.14.0", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" +dependencies = [ + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "indexmap 2.14.2", + "pin-project-lite", + "serde", + "serde_json", + "sse-stream", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.9", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap 2.14.2", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "string_cache" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.14.0", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "log", + "rand 0.9.5", + "thiserror", + "utf-8", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada" +dependencies = [ + "phf 0.14.0", + "phf_codegen 0.14.0", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "flate2", + "indexmap 2.14.2", + "memchr", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Plugins/mac/Official/EditorKeymapImport/Cargo.toml b/Plugins/mac/Official/EditorKeymapImport/Cargo.toml new file mode 100644 index 0000000000..7bf12fa762 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lithe-editor-keymap-import" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[dependencies] +lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs.git", rev = "6e9bd1df0fd0f62fc653b10855f0353382d20354" } +serde_json = "1" +jsonc-parser = { version = "0.34", features = ["serde"] } +quick-xml = "0.37" + +[dev-dependencies] +lithe-core = { path = "../../../../rust/lithe-core" } +base64 = "0.22" diff --git a/Plugins/mac/Official/EditorKeymapImport/NOTICE.md b/Plugins/mac/Official/EditorKeymapImport/NOTICE.md new file mode 100644 index 0000000000..1e4f5c1826 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/NOTICE.md @@ -0,0 +1,12 @@ +# Editor Shortcut Import + +The provider uses the official Lithe Rust plugin SDK (Apache-2.0), +`jsonc-parser` 0.34.0 by David Sherret (MIT), and `quick-xml` 0.37.5 +(MIT). Their license texts are included in `licenses/`. Other Rust runtime +dependencies are available under Apache-2.0 or MIT; their exact versions and +sources are pinned in `Cargo.lock`. + +The IDEA default mappings are based on JetBrains IntelliJ Community's +Apache-2.0 keymaps at revision `e12d185a335d02f3831c12f2af64130b3ace46db` +and [JetBrains' macOS reference](https://www.jetbrains.com/help/idea/reference-keymap-mac-default.html). +VS Code defaults follow [Microsoft's macOS reference](https://code.visualstudio.com/shortcuts/keyboard-shortcuts-macos.pdf). diff --git a/Plugins/mac/Official/EditorKeymapImport/assemble-package.sh b/Plugins/mac/Official/EditorKeymapImport/assemble-package.sh new file mode 100755 index 0000000000..5cbae32451 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/assemble-package.sh @@ -0,0 +1,39 @@ +#!/bin/zsh +set -euo pipefail + +# Builds the fixed provider sources for this app's target architectures. +# Cargo outputs are mutable worktree-local build state; the copied executable +# and licenses are sealed into the plugin package before the app is signed. +PLUGIN_DIR="${0:A:h}" +ROOT_DIR="${PLUGIN_DIR:h:h:h:h}" +OUTPUT_DIR="" +ARCH="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) OUTPUT_DIR="$2"; shift 2 ;; + --arch) ARCH="$2"; shift 2 ;; + *) print -u2 -- "Usage: $0 --output package-directory --arch arm64|x86_64|universal"; exit 2 ;; + esac +done +[[ -n "$OUTPUT_DIR" ]] || { print -u2 -- "--output is required"; exit 2; } +case "$ARCH" in + arm64) targets=(aarch64-apple-darwin) ;; + x86_64) targets=(x86_64-apple-darwin) ;; + universal) targets=(aarch64-apple-darwin x86_64-apple-darwin) ;; + *) print -u2 -- "Unsupported architecture: $ARCH"; exit 2 ;; +esac +target_dir="$ROOT_DIR/.artifacts/editor-keymap-provider-target" +binaries=() +for target in "${targets[@]}"; do + CARGO_HOME="$ROOT_DIR/.artifacts/cargo-home" cargo build --locked --release --manifest-path "$PLUGIN_DIR/Cargo.toml" --target "$target" --target-dir "$target_dir" + binaries+=("$target_dir/$target/release/lithe-editor-keymap-import") +done +mkdir -p "$OUTPUT_DIR/bin" +if (( ${#binaries} == 1 )); then + cp "$binaries[1]" "$OUTPUT_DIR/bin/editor-keymap-import" +else + lipo -create "${binaries[@]}" -output "$OUTPUT_DIR/bin/editor-keymap-import" +fi +chmod 755 "$OUTPUT_DIR/bin/editor-keymap-import" +cp "$PLUGIN_DIR/NOTICE.md" "$OUTPUT_DIR/NOTICE.md" +cp -R "$PLUGIN_DIR/licenses" "$OUTPUT_DIR/licenses" diff --git a/Plugins/mac/Official/EditorKeymapImport/licenses/Apache-2.0.txt b/Plugins/mac/Official/EditorKeymapImport/licenses/Apache-2.0.txt new file mode 100644 index 0000000000..69bef1d807 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/licenses/Apache-2.0.txt @@ -0,0 +1,203 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or + Object form, that is based on (or derived from) the Work and + for which the editorial revisions, annotations, elaborations, + or other modifications represent, as a whole, an original work + of authorship. For the purposes of this License, Derivative Works + shall not include works that remain separable from, or merely link + (or bind by name) to the interfaces of, the Work and Derivative Works + thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of + its distribution, then any Derivative Works that You + distribute must include a readable copy of the attribution + notices contained within such NOTICE file, excluding those + notices that do not pertain to any part of the Derivative + Works, in at least one of the following places: within a + NOTICE text file distributed as part of the Derivative Works; + within the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing + the origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as + a result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[ ]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Plugins/mac/Official/EditorKeymapImport/licenses/jsonc-parser-MIT.txt b/Plugins/mac/Official/EditorKeymapImport/licenses/jsonc-parser-MIT.txt new file mode 100644 index 0000000000..3457cf1341 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/licenses/jsonc-parser-MIT.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 David Sherret + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Plugins/mac/Official/EditorKeymapImport/licenses/quick-xml-MIT.txt b/Plugins/mac/Official/EditorKeymapImport/licenses/quick-xml-MIT.txt new file mode 100644 index 0000000000..16a30bcb74 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/licenses/quick-xml-MIT.txt @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2016 Johann Tuffe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Plugins/mac/Official/EditorKeymapImport/plugin.json b/Plugins/mac/Official/EditorKeymapImport/plugin.json new file mode 100644 index 0000000000..66797c3200 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/plugin.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.plugin.editor-keymap-import", + "displayName": "Editor Shortcut Import", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": {"backend": 2, "serverProcess": 1, "packageTrust": 1, "keymapImportProviders": 1}, + "vendor": { + "id": "dev.lithe", + "displayName": "Lithe", + "signatureRequirement": "publisherPackage", + "bundledSignatureRequirement": "sameTeamAsHost" + }, + "permissions": ["backend.launch", "keymaps.provide", "keymaps.readSelectedSources"], + "keymapImportProviders": [ + { + "id": "vscode", + "displayName": "Visual Studio Code", + "recognition": [ + {"kind": "file", "path": "settings.json"}, {"kind": "file", "path": "keybindings.json"}, + {"kind": "root", "base": "userConfig", "components": ["Code", "User"]}, + {"kind": "root", "base": "userConfig", "components": ["Code", "User", "profiles", "*"]}, + {"kind": "root", "base": "userConfig", "components": ["Code - Insiders", "User"]}, + {"kind": "root", "base": "userConfig", "components": ["Code - Insiders", "User", "profiles", "*"]}, + {"kind": "root", "base": "userConfig", "components": ["Cursor", "User"]}, + {"kind": "root", "base": "userConfig", "components": ["Cursor", "User", "profiles", "*"]} + ], + "inputs": [{"kind": "file", "path": "keybindings.json"}] + }, + { + "id": "idea", + "displayName": "IntelliJ IDEA", + "recognition": [{"kind": "directory", "path": "options"}], + "inputs": [{"kind": "file", "path": "options/keymap.xml"}, {"kind": "directory", "path": "keymaps", "extension": "xml"}] + } + ], + "backend": {"launch": {"kind": "packaged", "path": "bin/editor-keymap-import"}, "arguments": []} +} diff --git a/Plugins/mac/Official/EditorKeymapImport/src/idea.rs b/Plugins/mac/Official/EditorKeymapImport/src/idea.rs new file mode 100644 index 0000000000..934bc6daa6 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/src/idea.rs @@ -0,0 +1,347 @@ +//! JetBrains IDEA keymap selection and bounded parent-scheme resolution. +use crate::shortcut::{idea_keypress, Key}; +use quick_xml::{ + events::{BytesStart, Event}, + Reader, +}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Default)] +struct Scheme { + name: String, + parent: Option, + actions: BTreeMap>, + claimed: BTreeSet, +} +struct Action { + target: Option<&'static str>, + keys: Vec, + rejected: bool, +} + +fn attr( + event: &BytesStart<'_>, + key: &[u8], + reader: &Reader<&[u8]>, +) -> Result, String> { + for attr in event.attributes() { + let attr = attr.map_err(|_| "invalid XML attributes")?; + if attr.key.as_ref() == key { + return attr + .decode_and_unescape_value(reader.decoder()) + .map(|s| Some(s.into_owned())) + .map_err(|_| "invalid XML value".into()); + } + } + Ok(None) +} + +fn action_id(id: &str) -> Option<&'static str> { + Some(match id { + "ShowSettings" => "settings", + "Back" => "navigate-back", + "Forward" => "navigate-forward", + "FindInPath" => "search-in-project", + "ReplaceInPath" => "replace-in-project", + "GotoLine" => "go-to-line", + "Find" => "find-in-file", + "FindNext" => "find-next", + "FindPrevious" => "find-previous", + "Replace" => "replace-in-file", + "GotoDeclaration" => "go-to-definition", + "GotoImplementation" => "go-to-implementation", + "FindUsages" => "find-usages", + "ToggleLineBreakpoint" => "toggle-breakpoint", + "ViewBreakpoints" => "view-breakpoints", + "GotoAction" => "find-action", + "ActivateTerminalToolWindow" => "toggle-terminal", + "Run" => "run", + "Debug" => "debug", + "Resume" => "debug-resume", + "StepOver" => "debug-step-over", + "StepInto" => "debug-step-into", + "StepOut" => "debug-step-out", + _ => return None, + }) +} + +fn finish(action: Action, scheme: &mut Scheme) -> Result<(), String> { + if let Some(target) = action.target { + let keys = if action.rejected { + Vec::new() + } else { + action.keys + }; + if scheme.actions.insert(target.into(), keys).is_some() { + return Err("duplicate action".into()); + } + } + Ok(()) +} + +fn parse_scheme(text: &str) -> Result { + if text.len() > 1_048_576 { + return Err("source too large".into()); + } + let mut reader = Reader::from_str(text); + reader.config_mut().trim_text(true); + reader.config_mut().expand_empty_elements = true; + let mut depth = 0usize; + let mut root_seen = false; + let mut scheme = Scheme::default(); + let mut current: Option = None; + let mut action_count = 0usize; + loop { + match reader.read_event().map_err(|_| "invalid XML")? { + Event::DocType(_) => return Err("DOCTYPE is not allowed".into()), + Event::Start(event) => { + depth += 1; + if depth > 64 { + return Err("XML nesting limit".into()); + } + let name = event.name(); + let tag = name.as_ref(); + if depth == 1 { + if root_seen || tag != b"keymap" { + return Err("invalid keymap root".into()); + } + root_seen = true; + scheme.name = attr(&event, b"name", &reader)? + .filter(|name| !name.is_empty() && name.len() <= 1024) + .ok_or("missing scheme name")?; + scheme.parent = attr(&event, b"parent", &reader)?; + } else if depth == 2 && tag == b"action" { + action_count += 1; + if action_count > 4096 || current.is_some() { + return Err("too many actions".into()); + } + let id = attr(&event, b"id", &reader)?.unwrap_or_default(); + current = Some(Action { + target: action_id(&id), + keys: Vec::new(), + rejected: false, + }); + } else if depth == 3 && tag == b"keyboard-shortcut" { + if let Some(action) = current.as_mut() { + let first = attr(&event, b"first-keystroke", &reader)? + .and_then(|key| idea_keypress(&key)); + if let Some(key) = first.clone() { + scheme.claimed.insert(key); + } + if attr(&event, b"second-keystroke", &reader)?.is_some() || first.is_none() + { + action.rejected = true; + } else if let Some(key) = first { + if !action.keys.contains(&key) { + action.keys.push(key); + } + if action.keys.len() > 8 { + return Err("too many shortcuts".into()); + } + } + } + } + } + Event::End(event) => { + if depth == 2 && event.name().as_ref() == b"action" { + if let Some(action) = current.take() { + finish(action, &mut scheme)?; + } + } + depth = depth.checked_sub(1).ok_or("invalid XML nesting")?; + } + Event::Eof => break, + _ => {} + } + } + if !root_seen || depth != 0 { + return Err("incomplete keymap".into()); + } + Ok(scheme) +} + +fn active_scheme(text: &str) -> Result, String> { + if text.len() > 1_048_576 { + return Err("source too large".into()); + } + let mut reader = Reader::from_str(text); + reader.config_mut().expand_empty_elements = true; + let mut stack: Vec> = Vec::new(); + let mut component = false; + let mut active = None; + let mut root_seen = false; + loop { + match reader.read_event().map_err(|_| "invalid XML")? { + Event::DocType(_) => return Err("DOCTYPE is not allowed".into()), + Event::Start(event) => { + let name = event.name().as_ref().to_vec(); + if stack.len() >= 64 { + return Err("XML nesting limit".into()); + } + if stack.is_empty() { + if root_seen || name != b"application" { + return Err("invalid options root".into()); + } + root_seen = true; + } + if name == b"component" && stack.len() == 1 { + component = attr(&event, b"name", &reader)?.as_deref() == Some("KeymapManager"); + } + if name == b"active_keymap" && stack.len() == 2 && component { + if active.is_some() { + return Err("duplicate active scheme".into()); + } + active = Some( + attr(&event, b"name", &reader)? + .filter(|name| !name.is_empty() && name.len() <= 1024) + .ok_or("missing active scheme")?, + ); + } + stack.push(name); + } + Event::End(event) => { + if event.name().as_ref() == b"component" { + component = false; + } + stack.pop().ok_or("invalid XML nesting")?; + } + Event::Eof => break, + _ => {} + } + } + if !root_seen || !stack.is_empty() { + return Err("incomplete options".into()); + } + Ok(active) +} + +fn defaults(name: &str) -> BTreeMap> { + let pairs: &[(&str, &[&str])] = match name { + "$default" => &[ + ("debug", &["shift F9"]), + ("debug-resume", &["F9"]), + ("debug-step-into", &["F7"]), + ("debug-step-out", &["shift F8"]), + ("debug-step-over", &["F8"]), + ("find-next", &["F3", "control L"]), + ("find-previous", &["shift F3", "control shift L"]), + ("go-to-line", &["control G"]), + ("navigate-back", &["control alt LEFT"]), + ("navigate-forward", &["control alt RIGHT"]), + ("replace-in-project", &["control shift R"]), + ("run", &["shift F10"]), + ("search-in-project", &["control shift F"]), + ("settings", &["control alt S"]), + ("toggle-terminal", &["alt F12"]), + ], + "Mac OS X" => &[ + ("debug", &["shift F9"]), + ("debug-resume", &["F9"]), + ("debug-step-into", &["F7"]), + ("debug-step-out", &["shift F8"]), + ("debug-step-over", &["F8"]), + ("find-next", &["F3", "control L"]), + ("find-previous", &["shift F3", "control shift L"]), + ("go-to-line", &["meta G"]), + ("navigate-back", &["meta alt LEFT"]), + ("navigate-forward", &["meta alt RIGHT"]), + ("replace-in-project", &["control shift R"]), + ("run", &["shift F10"]), + ("search-in-project", &["control shift F"]), + ("settings", &["meta COMMA"]), + ("toggle-terminal", &["alt F12"]), + ], + "Mac OS X 10.5+" => &[ + ("debug", &["control D"]), + ("debug-resume", &["meta alt R", "F9"]), + ("debug-step-into", &["F7"]), + ("debug-step-out", &["shift F8"]), + ("debug-step-over", &["F8"]), + ("find-next", &["meta G"]), + ("find-previous", &["meta shift G"]), + ("go-to-line", &["meta L"]), + ("navigate-back", &["meta OPEN_BRACKET", "meta alt LEFT"]), + ( + "navigate-forward", + &["meta CLOSE_BRACKET", "meta alt RIGHT"], + ), + ("replace-in-project", &["meta shift R"]), + ("run", &["control R"]), + ("search-in-project", &["meta shift F"]), + ("settings", &["meta COMMA"]), + ("toggle-terminal", &["alt F12"]), + // JetBrains' published macOS keymap for the existing Lithe actions. + ("find-in-file", &["meta F"]), + ("replace-in-file", &["meta R"]), + ("go-to-definition", &["meta B"]), + ("go-to-implementation", &["meta alt B"]), + ("find-usages", &["alt F7"]), + ("find-action", &["meta shift A"]), + ("toggle-breakpoint", &["meta F8"]), + ("view-breakpoints", &["meta shift F8"]), + ], + _ => &[], + }; + pairs + .iter() + .map(|(id, keys)| { + ( + (*id).to_string(), + keys.iter().filter_map(|key| idea_keypress(key)).collect(), + ) + }) + .collect() +} + +fn resolve( + name: &str, + schemes: &BTreeMap, + visited: &mut BTreeSet, +) -> Result>, String> { + if !visited.insert(name.to_string()) || visited.len() > 64 { + return Err("keymap parent cycle".into()); + } + let Some(scheme) = schemes.get(name) else { + return Ok(defaults(name)); + }; + let mut result = match &scheme.parent { + Some(parent) => resolve(parent, schemes, visited)?, + None => BTreeMap::new(), + }; + for bindings in result.values_mut() { + bindings.retain(|key| !scheme.claimed.contains(key)); + } + for (id, keys) in &scheme.actions { + result.insert(id.clone(), keys.clone()); + } + Ok(result) +} + +/// Resolves a selected scheme without following undeclared paths or XML entities. +pub fn import(files: &BTreeMap) -> Result { + if files.len() > 128 { + return Err("too many sources".into()); + } + let active = files + .get("options/keymap.xml") + .map(|text| active_scheme(text)) + .transpose()? + .flatten() + .unwrap_or_else(|| "Mac OS X 10.5+".into()); + let mut schemes = BTreeMap::new(); + for (path, text) in files + .iter() + .filter(|(path, _)| path.starts_with("keymaps/") && path.ends_with(".xml")) + { + let scheme = parse_scheme(text).map_err(|_| format!("invalid keymap source: {path}"))?; + if schemes.insert(scheme.name.clone(), scheme).is_some() { + return Err("duplicate keymap name".into()); + } + } + let entries = resolve(&active, &schemes, &mut BTreeSet::new())?; + let bindings: Vec<_> = entries.into_iter().map(|(id, keys)| json!({"commandID":id,"keys": + keys.into_iter().map(|key| json!({"key":key.key,"modifiers":key.modifiers})).collect::>() + })).collect(); + Ok(json!({"bindings":bindings})) +} diff --git a/Plugins/mac/Official/EditorKeymapImport/src/lib.rs b/Plugins/mac/Official/EditorKeymapImport/src/lib.rs new file mode 100644 index 0000000000..577b08afc7 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/src/lib.rs @@ -0,0 +1,3 @@ +pub mod idea; +mod shortcut; +pub mod vscode; diff --git a/Plugins/mac/Official/EditorKeymapImport/src/main.rs b/Plugins/mac/Official/EditorKeymapImport/src/main.rs new file mode 100644 index 0000000000..744be0898b --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/src/main.rs @@ -0,0 +1,62 @@ +//! On-demand editor shortcut providers. All source access uses the host snapshot. +use lithe_plugin_sdk::{Backend, Context, FunctionError, KeymapImportRequest}; +use std::collections::BTreeMap; + +fn read_sources( + snapshot: &KeymapImportRequest, + context: &Context, +) -> Result, FunctionError> { + let files = snapshot.list_inputs(context.host())?; + let mut result = BTreeMap::new(); + for file in files { + context.check_cancelled()?; + let mut data = Vec::with_capacity(file.byte_count as usize); + while data.len() < file.byte_count as usize { + context.check_cancelled()?; + let remaining = file.byte_count as usize - data.len(); + let chunk = snapshot.read_chunk( + context.host(), + &file.id, + data.len() as u64, + remaining.min(131_072) as u32, + )?; + if chunk.is_empty() { + return Err(FunctionError::internal("Incomplete source")); + } + data.extend_from_slice(&chunk); + } + let text = String::from_utf8(data) + .map_err(|_| FunctionError::internal("Invalid source encoding"))?; + if result + .insert(file.path, text.trim_start_matches('\u{feff}').to_string()) + .is_some() + { + return Err(FunctionError::internal("Duplicate source")); + } + } + context.check_cancelled()?; + Ok(result) +} + +fn main() { + let mut backend = Backend::new("editor-keymap-import"); + backend + .keymap_import_provider("vscode", |snapshot, context| { + let files = read_sources(&snapshot, context)?; + let source = files + .get("keybindings.json") + .map(String::as_str) + .unwrap_or("[]"); + lithe_editor_keymap_import::vscode::import(source) + .map_err(|_| FunctionError::internal("Invalid VS Code keybindings")) + }) + .expect("valid VS Code provider"); + backend + .keymap_import_provider("idea", |snapshot, context| { + let files = read_sources(&snapshot, context)?; + lithe_editor_keymap_import::idea::import(&files) + .map_err(|_| FunctionError::internal("Invalid IDEA keymap")) + }) + .expect("valid IDEA provider"); + std::process::exit(backend.listen()); +} diff --git a/Plugins/mac/Official/EditorKeymapImport/src/shortcut.rs b/Plugins/mac/Official/EditorKeymapImport/src/shortcut.rs new file mode 100644 index 0000000000..01240525df --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/src/shortcut.rs @@ -0,0 +1,90 @@ +//! Shared shortcut spelling normalization for the editor source plugins. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub(crate) struct Key { + pub(crate) key: String, + pub(crate) modifiers: Vec<&'static str>, +} + +pub(crate) fn keypress(input: &str) -> Option { + let value = input.to_ascii_lowercase(); + let parts: Vec<_> = value.split('+').collect(); + let last = *parts.last()?; + if last.is_empty() || last == "delete" { + return None; + } + let mut present = [false; 4]; + for part in &parts[..parts.len() - 1] { + let index = match *part { + "ctrl" | "control" => 0, + "alt" | "option" => 1, + "shift" => 2, + "cmd" | "meta" | "command" => 3, + _ => return None, + }; + if present[index] { + return None; + } + present[index] = true; + } + let name = match last { + "enter" => "return", + "backspace" => "backspace", + "escape" | "esc" => return None, + other => other, + }; + let function = name + .strip_prefix('f') + .and_then(|n| n.parse::().ok()) + .is_some_and(|n| (1..=20).contains(&n) && name == format!("f{n}")); + let printable = name.len() == 1 && name.as_bytes()[0].is_ascii_graphic(); + if !function + && !printable + && ![ + "up", + "down", + "left", + "right", + "return", + "tab", + "space", + "backspace", + ] + .contains(&name) + { + return None; + } + if (printable || name == "space") && !(present[0] || present[1] || present[3]) { + return None; + } + let modifiers = ["control", "alt", "shift", "meta"] + .into_iter() + .enumerate() + .filter_map(|(index, modifier)| present[index].then_some(modifier)) + .collect(); + Some(Key { + key: name.to_string(), + modifiers, + }) +} + +pub(crate) fn idea_keypress(input: &str) -> Option { + let mut parts: Vec<_> = input + .split_whitespace() + .map(str::to_ascii_lowercase) + .collect(); + let key = parts.last_mut()?; + *key = match key.as_str() { + "open_bracket" => "[", + "close_bracket" => "]", + "back_slash" => "\\", + "back_space" => "backspace", + "comma" => ",", + "period" => ".", + "slash" => "/", + "minus" => "-", + "equals" => "=", + other => other, + } + .to_string(); + keypress(&parts.join("+")) +} diff --git a/Plugins/mac/Official/EditorKeymapImport/src/vscode.rs b/Plugins/mac/Official/EditorKeymapImport/src/vscode.rs new file mode 100644 index 0000000000..eef0c45bcd --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/src/vscode.rs @@ -0,0 +1,165 @@ +//! VS Code's JSONC user keybindings and its documented macOS defaults. +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +use crate::shortcut::{keypress, Key}; + +fn command(id: &str) -> Option<&'static str> { + Some(match id { + "workbench.action.files.save" => "save", + "workbench.action.openSettings" => "settings", + "workbench.action.navigateBack" => "navigate-back", + "workbench.action.navigateForward" => "navigate-forward", + "workbench.action.findInFiles" => "search-in-project", + "workbench.action.replaceInFiles" => "replace-in-project", + "actions.find" => "find-in-file", + "editor.action.startFindReplaceAction" => "replace-in-file", + "editor.action.nextMatchFindAction" => "find-next", + "editor.action.previousMatchFindAction" => "find-previous", + "editor.action.revealDefinition" => "go-to-definition", + "editor.action.goToReferences" => "find-usages", + "editor.action.goToImplementation" => "go-to-implementation", + "editor.debug.action.toggleBreakpoint" => "toggle-breakpoint", + "workbench.action.showCommands" => "find-action", + "workbench.action.gotoLine" => "go-to-line", + "workbench.action.terminal.toggleTerminal" => "toggle-terminal", + "workbench.action.debug.start" => "debug", + "workbench.action.debug.run" => "run", + "workbench.action.debug.continue" => "debug-resume", + "workbench.action.debug.stop" => "stop-debug", + "workbench.action.debug.stepOver" => "debug-step-over", + "workbench.action.debug.stepInto" => "debug-step-into", + "workbench.action.debug.stepOut" => "debug-step-out", + _ => return None, + }) +} + +/// Returns supported, globally applicable rules; conditional and chord rules mask claimed keys. +pub fn import(source: &str) -> Result { + if source.len() > 1_048_576 { + return Err("source too large".into()); + } + let options = jsonc_parser::ParseOptions { + allow_comments: true, + allow_trailing_commas: true, + allow_loose_object_property_names: false, + allow_missing_commas: false, + allow_single_quoted_strings: false, + allow_hexadecimal_numbers: false, + allow_unary_plus_numbers: false, + allow_bare_decimal_point_numbers: false, + allow_non_finite_numbers: false, + allow_extended_string_escapes: false, + }; + let value: Value = + jsonc_parser::parse_to_serde_value(source, &options).map_err(|_| "invalid JSONC")?; + let rows = value + .as_array() + .filter(|items| items.len() <= 4096) + .ok_or("invalid keybinding list")?; + let defaults = [ + ("save", "cmd+s"), + ("settings", "cmd+,"), + ("navigate-back", "ctrl+-"), + ("navigate-forward", "ctrl+shift+-"), + ("search-in-project", "cmd+shift+f"), + ("replace-in-project", "cmd+shift+h"), + ("go-to-line", "ctrl+g"), + ("toggle-terminal", "ctrl+`"), + ("find-in-file", "cmd+f"), + ("replace-in-file", "cmd+alt+f"), + ("go-to-definition", "f12"), + ("find-usages", "shift+f12"), + ("toggle-breakpoint", "f9"), + ("find-next", "cmd+g"), + ("find-previous", "cmd+shift+g"), + ("go-to-implementation", "cmd+f12"), + ("find-action", "cmd+shift+p"), + ("find-action", "f1"), + ("debug-start-or-continue", "f5"), + ("debug-step-over", "f10"), + ("debug-step-into", "f11"), + ("debug-step-out", "shift+f11"), + ("stop-debug", "shift+f5"), + ]; + let mut result: BTreeMap<&str, Vec> = BTreeMap::new(); + for (id, key) in defaults { + result + .entry(id) + .or_default() + .push(keypress(key).expect("valid reference key")); + } + // VS Code applies removal rules to matching defaults by command before + // considering user overrides; a removal never claims an unrelated key. + for row in rows { + let object = row.as_object().ok_or("invalid keybinding row")?; + let Some(raw_command) = object + .get("command") + .and_then(Value::as_str) + .and_then(|name| name.strip_prefix('-')) + else { + continue; + }; + let key = match object.get("key") { + None => None, + Some(Value::String(value)) => match keypress(value) { + Some(key) => Some(key), + None => continue, + }, + Some(_) => continue, + }; + if let Some(bindings) = command(raw_command).and_then(|id| result.get_mut(id)) { + if let Some(key) = &key { + bindings.retain(|existing| existing != key); + } else { + bindings.clear(); + } + } + // F5 represents the combined start/continue behavior, so removing + // either source command removes that reference binding as well. + if matches!( + raw_command, + "workbench.action.debug.start" | "workbench.action.debug.continue" + ) { + if let Some(bindings) = result.get_mut("debug-start-or-continue") { + if let Some(key) = &key { + bindings.retain(|existing| existing != key); + } else { + bindings.clear(); + } + } + } + } + for row in rows { + let object = row.as_object().ok_or("invalid keybinding row")?; + let name = object.get("command").and_then(Value::as_str); + if name.is_some_and(|value| value.starts_with('-')) { + continue; + } + let raw_key = object.get("key").and_then(Value::as_str); + if let Some(first) = raw_key + .and_then(|value| value.split_whitespace().next()) + .and_then(keypress) + { + for bindings in result.values_mut() { + bindings.retain(|key| *key != first); + } + } + let Some(id) = name.and_then(command) else { + continue; + }; + let binding = raw_key.and_then(keypress); + if object.contains_key("when") || object.contains_key("args") || binding.is_none() { + continue; + } + let bindings = result.entry(id).or_default(); + if bindings.len() >= 8 { + return Err("too many keys for action".into()); + } + bindings.insert(0, binding.unwrap()); + } + let bindings: Vec<_> = result.into_iter().map(|(id, keys)| json!({"commandID":id, "keys": + keys.into_iter().map(|key| json!({"key":key.key, "modifiers":key.modifiers})).collect::>() + })).collect(); + Ok(json!({"bindings":bindings})) +} diff --git a/Plugins/mac/Official/EditorKeymapImport/tests/idea.rs b/Plugins/mac/Official/EditorKeymapImport/tests/idea.rs new file mode 100644 index 0000000000..9b5b786908 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/tests/idea.rs @@ -0,0 +1,99 @@ +use lithe_editor_keymap_import::idea::import; +use serde_json::json; +use std::collections::BTreeMap; + +fn files(items: &[(&str, &str)]) -> BTreeMap { + items + .iter() + .map(|(path, content)| (path.to_string(), content.to_string())) + .collect() +} +#[test] +fn inherits_default_scheme_and_imports_custom_actions() { + let source = files(&[ + ("options/keymap.xml", ""), + ("keymaps/mine.xml", ""), + ]); + let result = import(&source).unwrap(); + let bindings = result["bindings"].as_array().unwrap(); + assert!(bindings.contains( + &json!({"commandID":"toggle-breakpoint", "keys":[{"key":"b", "modifiers":["alt","meta"]}]}) + )); + assert!(bindings.contains( + &json!({"commandID":"find-action", "keys":[{"key":"a", "modifiers":["shift","meta"]}]}) + )); + assert!(bindings + .contains(&json!({"commandID":"go-to-line", "keys":[{"key":"l", "modifiers":["meta"]}]}))); +} +#[test] +fn rejects_external_entities_duplicate_names_and_cycles() { + assert!(import(&files(&[( + "options/keymap.xml", + "]>" + )])) + .is_err()); + let active = ("options/keymap.xml", ""); + assert!(import(&files(&[ + active, + ("keymaps/a.xml", ""), + ("keymaps/b.xml", "") + ])) + .is_err()); + assert!(import(&files(&[ + active, + ("keymaps/a.xml", ""), + ("keymaps/b.xml", "") + ])) + .is_err()); + assert!(import(&files(&[("options/keymap.xml", "")])).is_err()); + assert!(import(&files(&[( + "options/keymap.xml", + "" + )])) + .is_err()); +} + +#[test] +fn macos_defaults_cover_navigation_and_breakpoint_actions() { + let result = import(&files(&[])).unwrap(); + let entries = result["bindings"].as_array().unwrap(); + for (id, key, modifiers) in [ + ("toggle-breakpoint", "f8", vec!["meta"]), + ("view-breakpoints", "f8", vec!["shift", "meta"]), + ("find-action", "a", vec!["shift", "meta"]), + ("go-to-implementation", "b", vec!["alt", "meta"]), + ("find-in-file", "f", vec!["meta"]), + ("find-usages", "f7", vec!["alt"]), + ] { + assert!( + entries.contains(&json!({"commandID":id,"keys":[{"key":key,"modifiers":modifiers}]})), + "missing {id}" + ); + } +} + +#[test] +fn child_schemes_mask_claimed_parent_keys_and_do_not_promote_chords() { + let source = files(&[ + ("options/keymap.xml", ""), + ("keymaps/mine.xml", ""), + ]); + let result = import(&source).unwrap(); + let entries = result["bindings"].as_array().unwrap(); + assert!(entries.contains(&json!({"commandID":"go-to-line", "keys":[]}))); + assert!(entries.contains(&json!({"commandID":"find-next", "keys":[]}))); + assert!(!entries + .iter() + .flat_map(|entry| entry["keys"].as_array().unwrap()) + .any(|key| key["key"] == "s")); +} + +#[test] +fn classic_scheme_keeps_its_own_line_and_find_bindings() { + let source = files(&[("options/keymap.xml", "")]); + let result = import(&source).unwrap(); + let entries = result["bindings"].as_array().unwrap(); + assert!(entries + .contains(&json!({"commandID":"go-to-line", "keys":[{"key":"g", "modifiers":["meta"]}]}))); + assert!(entries.contains(&json!({"commandID":"find-next", "keys":[{"key":"f3", "modifiers":[]}, {"key":"l", "modifiers":["control"]}]}))); +} diff --git a/Plugins/mac/Official/EditorKeymapImport/tests/manifest.rs b/Plugins/mac/Official/EditorKeymapImport/tests/manifest.rs new file mode 100644 index 0000000000..f48bcd2590 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/tests/manifest.rs @@ -0,0 +1,25 @@ +//! The production package uses only the generic Core provider contract. +use lithe_core::execute_json; +use serde_json::{json, Value}; + +#[test] +fn official_manifest_is_accepted_and_plans_each_source() { + let manifest: Value = serde_json::from_str(include_str!("../plugin.json")).unwrap(); + let request = json!({"id":"manifest", "command":"plugins.declarative.validate", "payload": { + "manifest":manifest, "hostCapabilities": { + "backend":[1,2], "serverProcess":[1], "packageTrust":[1], "keymapImportProviders":[1] + } + }}); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["status"], "ok", "{response}"); + for provider in ["vscode", "idea"] { + let request = json!({"id":"plan", "command":"keymap.planImportSources", "payload": { + "manifest":manifest, "providerID":provider + }}); + let plan: Value = serde_json::from_str(&execute_json(&request.to_string())).unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert!(!plan["data"]["recognition"].as_array().unwrap().is_empty()); + assert!(!plan["data"]["inputs"].as_array().unwrap().is_empty()); + } +} diff --git a/Plugins/mac/Official/EditorKeymapImport/tests/session.rs b/Plugins/mac/Official/EditorKeymapImport/tests/session.rs new file mode 100644 index 0000000000..d65ceb8678 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/tests/session.rs @@ -0,0 +1,177 @@ +//! Drives the actual provider executable through Lithe Core, with bounded host replies. +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use lithe_core::execute_json; +use serde_json::{json, Value}; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, Instant}, +}; + +static NEXT: AtomicU64 = AtomicU64::new(0); +fn core(command: &str, payload: Value) -> Value { + let reply: Value = serde_json::from_str(&execute_json( + &json!({"id":"test", "command":command,"payload":payload}).to_string(), + )) + .unwrap(); + assert_eq!(reply["ok"], true, "{command}: {reply}"); + reply["data"].clone() +} +struct Session { + id: String, + root: PathBuf, + events: Vec, +} +impl Drop for Session { + fn drop(&mut self) { + let _ = execute_json( + &json!({"id":"cleanup", "command":"lsp.stopServer", "payload":{"sessionId":self.id}}) + .to_string(), + ); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + let result: Value = serde_json::from_str(&execute_json(&json!({"id":"cleanup", "command":"lsp.destroyServer", "payload":{"sessionId":self.id}}).to_string())).unwrap(); + if result["ok"] == true { + break; + } + let _ = execute_json(&json!({"id":"cleanup", "command":"lsp.waitEvents", "payload":{"sessionId":self.id,"timeoutMilliseconds":100}}).to_string()); + } + let _ = fs::remove_dir_all(&self.root); + } +} +impl Session { + fn new(provider: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "keymap-provider-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&root).unwrap(); + let root = root.canonicalize().unwrap(); + let started = core( + "lsp.startServer", + json!({"providerId":"pluginBackend:test", "pluginId":"org.example.keymap", + "executablePath":env!("CARGO_BIN_EXE_lithe-editor-keymap-import"), "arguments":[], + "rootUri":format!("file://{}/",root.display()), "workingDirectory":root, + "hostServices":["lithe/host/keymapImport/listInputs", "lithe/host/keymapImport/readChunk"], + "keymapImportProviders":[provider], "initializeTimeoutMilliseconds":5000,"requestTimeoutMilliseconds":5000,"shutdownTimeoutMilliseconds":200}), + ); + let mut session = Self { + id: started["sessionId"].as_str().unwrap().into(), + root, + events: vec![], + }; + session.wait(|event| event["type"] == "keymapImportProviders"); + session.wait(|event| event["type"] == "stateChanged" && event["state"] == "ready"); + session + } + fn wait(&mut self, predicate: impl Fn(&Value) -> bool) -> Value { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(i) = self.events.iter().position(&predicate) { + return self.events.remove(i); + } + assert!( + Instant::now() < deadline, + "timed out; events: {:?}", + self.events + ); + let batch = core( + "lsp.waitEvents", + json!({"sessionId":self.id, "timeoutMilliseconds":100}), + ); + self.events + .extend(batch["events"].as_array().unwrap().iter().cloned()); + } + } + fn respond(&self, request: &Value, result: Value) { + core( + "lsp.hostResponse", + json!({"sessionId":self.id,"hostRequestID":request["result"]["hostRequestID"],"result":result}), + ); + } + fn run(&mut self, provider: &str, path: &str, contents: &str) -> Value { + core( + "lsp.keymapImportRequest", + json!({"sessionId":self.id,"operationId":"import", "providerID":provider,"snapshotID":"test-token","timeoutMilliseconds":5000}), + ); + let list = self.wait(|event| { + event["type"] == "hostRequest" + && event["method"] == "lithe/host/keymapImport/listInputs" + }); + self.respond( + &list, + json!({"files":[{"id":"file-0","path":path,"byteCount":contents.len()}]}), + ); + let read = self.wait(|event| { + event["type"] == "hostRequest" && event["method"] == "lithe/host/keymapImport/readChunk" + }); + self.respond(&read, json!({"data":STANDARD.encode(contents)})); + let done = self + .wait(|event| event["type"] == "requestCompleted" && event["operationId"] == "import"); + let reply = done["result"]["value"]["response"]["result"].clone(); + let allowed = [ + "save", + "settings", + "navigate-back", + "navigate-forward", + "search-in-project", + "replace-in-project", + "find-in-file", + "replace-in-file", + "find-next", + "find-previous", + "go-to-definition", + "go-to-implementation", + "find-usages", + "toggle-breakpoint", + "view-breakpoints", + "find-action", + "go-to-line", + "toggle-terminal", + "debug", + "debug-start-or-continue", + "run", + "debug-resume", + "debug-step-over", + "debug-step-into", + "debug-step-out", + "stop-debug", + ]; + core( + "keymap.decodeImport", + json!({"commandIDs":allowed,"reply":reply}), + ) + } +} + +#[test] +fn vscode_source_runs_through_real_core_and_provider() { + let mut session = Session::new("vscode"); + let result = session.run( + "vscode", + "keybindings.json", + r#"[{"key":"cmd+alt+n","command":"editor.action.nextMatchFindAction"}]"#, + ); + let actions = result["bindings"].as_array().unwrap(); + assert!(actions + .iter() + .any(|entry| entry["commandID"] == "toggle-breakpoint" && entry["keys"][0]["key"] == "f9")); + assert!(actions + .iter() + .any(|entry| entry["commandID"] == "find-next" && entry["keys"][0]["key"] == "n")); +} + +#[test] +fn idea_source_runs_through_real_core_and_provider() { + let mut session = Session::new("idea"); + let result = session.run("idea", "options/keymap.xml", ""); + let actions = result["bindings"].as_array().unwrap(); + assert!(actions + .iter() + .any(|entry| entry["commandID"] == "find-action" && entry["keys"][0]["key"] == "a")); + assert!(actions + .iter() + .any(|entry| entry["commandID"] == "toggle-breakpoint" && entry["keys"][0]["key"] == "f8")); +} diff --git a/Plugins/mac/Official/EditorKeymapImport/tests/vscode.rs b/Plugins/mac/Official/EditorKeymapImport/tests/vscode.rs new file mode 100644 index 0000000000..208aa40d11 --- /dev/null +++ b/Plugins/mac/Official/EditorKeymapImport/tests/vscode.rs @@ -0,0 +1,175 @@ +use lithe_editor_keymap_import::vscode::import; +use serde_json::json; + +#[test] +fn imports_jsonc_user_actions_and_real_default_breakpoint() { + let result = import( + r#"[ + // User override for a common navigation command. + {"key":"cmd+alt+n","command":"editor.action.nextMatchFindAction"}, + {"key":"cmd+alt+b","command":"editor.debug.action.toggleBreakpoint"}, + ]"#, + ) + .unwrap(); + assert!(result["bindings"].as_array().unwrap().contains(&json!({"commandID":"find-next", "keys":[{"key":"n","modifiers":["alt","meta"]},{"key":"g","modifiers":["meta"]}]}))); + assert!(result["bindings"].as_array().unwrap().contains(&json!({"commandID":"toggle-breakpoint", "keys":[{"key":"b","modifiers":["alt","meta"]},{"key":"f9","modifiers":[]}]}))); + let defaults = import("[]").unwrap(); + assert!(defaults["bindings"] + .as_array() + .unwrap() + .contains(&json!({"commandID":"toggle-breakpoint", "keys":[{"key":"f9","modifiers":[]}]}))); + assert!(!defaults["bindings"] + .as_array() + .unwrap() + .iter() + .any(|row| row["commandID"] == "debug-resume")); +} + +#[test] +fn conditional_chords_and_unknown_owners_never_become_global() { + let result = import( + r#"[ + {"key":"cmd+f","command":"extension.other","when":"editorFocus"}, + {"key":"ctrl+` ctrl+c","command":"extension.other"}, + {"key":"cmd+alt+x","command":"editor.action.goToImplementation","when":"editorFocus"} + ]"#, + ) + .unwrap(); + let entries = result["bindings"].as_array().unwrap(); + assert!(entries + .iter() + .any(|row| row["commandID"] == "find-in-file" && row["keys"] == json!([]))); + assert!(entries + .iter() + .any(|row| row["commandID"] == "toggle-terminal" && row["keys"] == json!([]))); + assert!(entries.contains( + &json!({"commandID":"go-to-implementation", "keys":[{"key":"f12", "modifiers":["meta"]}]}) + )); +} + +#[test] +fn rejects_json5_extensions_and_malformed_rules() { + assert!(import("[{key:'cmd+x',command:'actions.find'}]").is_err()); + assert!(import("{} ").is_err()); +} + +#[test] +fn user_additions_keep_other_effective_keys_and_removals_only_remove_the_named_key() { + let result = import( + r#"[ + {"key":"cmd+alt+b","command":"editor.debug.action.toggleBreakpoint"}, + {"key":"ctrl+alt+b","command":"editor.debug.action.toggleBreakpoint"}, + {"key":"f9","command":"-editor.debug.action.toggleBreakpoint"} + ]"#, + ) + .unwrap(); + let binding = result["bindings"] + .as_array() + .unwrap() + .iter() + .find(|row| row["commandID"] == "toggle-breakpoint") + .unwrap(); + assert_eq!( + binding["keys"], + json!([ + {"key":"b","modifiers":["control","alt"]}, + {"key":"b","modifiers":["alt","meta"]} + ]) + ); +} + +#[test] +fn removal_only_targets_the_named_default_and_never_user_bindings() { + let result = import( + r#"[ + {"key":"cmd+s","command":"-workbench.action.debug.start"}, + {"key":"cmd+alt+b","command":"editor.debug.action.toggleBreakpoint"}, + {"key":"cmd+alt+b","command":"-editor.debug.action.toggleBreakpoint"}, + {"key":"f9","command":"-editor.debug.action.toggleBreakpoint"} + ]"#, + ) + .unwrap(); + let entries = result["bindings"].as_array().unwrap(); + assert!( + entries.contains(&json!({"commandID":"save", "keys":[{"key":"s","modifiers":["meta"]}]})) + ); + assert!(entries.contains( + &json!({"commandID":"toggle-breakpoint", "keys":[{"key":"b","modifiers":["alt","meta"]}]}) + )); + let no_key = import(r#"[{"command":"-workbench.action.showCommands"}]"#).unwrap(); + assert!(no_key["bindings"] + .as_array() + .unwrap() + .contains(&json!({"commandID":"find-action", "keys":[]}))); +} + +#[test] +fn default_common_commands_match_the_published_macos_reference() { + let result = import("[]").unwrap(); + let entries = result["bindings"].as_array().unwrap(); + for (id, keys) in [ + ("find-next", json!([{"key":"g","modifiers":["meta"]}])), + ( + "find-previous", + json!([{"key":"g","modifiers":["shift","meta"]}]), + ), + ( + "go-to-implementation", + json!([{"key":"f12","modifiers":["meta"]}]), + ), + ( + "find-action", + json!([{"key":"p","modifiers":["shift","meta"]},{"key":"f1","modifiers":[]}]), + ), + ("debug-step-over", json!([{"key":"f10","modifiers":[]}])), + ( + "debug-start-or-continue", + json!([{"key":"f5","modifiers":[]}]), + ), + ("debug-step-into", json!([{"key":"f11","modifiers":[]}])), + ( + "debug-step-out", + json!([{"key":"f11","modifiers":["shift"]}]), + ), + ("stop-debug", json!([{"key":"f5","modifiers":["shift"]}])), + ] { + assert!( + entries.contains(&json!({"commandID":id,"keys":keys})), + "missing {id}" + ); + } +} + +#[test] +fn later_rules_shadow_earlier_claims_without_promoting_chords() { + let result = import( + r#"[ + {"key":"cmd+alt+b", "command":"workbench.action.navigateBack"}, + {"key":"cmd+alt+b ctrl+c", "command":"extension.other"}, + {"key":"cmd+alt+b", "command":"workbench.action.navigateForward"} + ]"#, + ) + .unwrap(); + let entries = result["bindings"].as_array().unwrap(); + let back = entries + .iter() + .find(|entry| entry["commandID"] == "navigate-back") + .unwrap(); + assert!(!back["keys"] + .as_array() + .unwrap() + .iter() + .any(|key| key["key"] == "b")); + let forward = entries + .iter() + .find(|entry| entry["commandID"] == "navigate-forward") + .unwrap(); + assert_eq!( + forward["keys"][0], + json!({"key":"b", "modifiers":["alt","meta"]}) + ); + assert!(!entries + .iter() + .flat_map(|entry| entry["keys"].as_array().unwrap()) + .any(|key| key["key"] == "c")); +} diff --git a/Plugins/marketplace/official-plugins.json b/Plugins/marketplace/official-plugins.json index 241f08f8ab..600c991231 100644 --- a/Plugins/marketplace/official-plugins.json +++ b/Plugins/marketplace/official-plugins.json @@ -21,6 +21,26 @@ "tags": ["java", "language"], "license": "Multiple; see bundled third-party licenses" } + }, + { + "id": "dev.lithe.plugin.editor-keymap-import", + "releaseEnabled": false, + "bundledUntilPublishedVersion": "1.0.0", + "tagPrefix": "plugin-editor-keymap-import-v", + "packageDirectory": "Plugins/mac/Official/EditorKeymapImport", + "inputPaths": ["scripts/plugins/build-official-plugins.sh"], + "assets": [ + {"assetName": "Lithe-Editor-Keymap-Import-{version}-darwin-arm64.zip", "platforms": ["darwin-arm64"], "arch": "arm64"}, + {"assetName": "Lithe-Editor-Keymap-Import-{version}-darwin-x64.zip", "platforms": ["darwin-x64"], "arch": "x86_64"} + ], + "index": { + "name": "Editor Shortcut Import", + "author": "Lithe", + "repository": "https://github.com/1lck/Lithe-IDEA", + "description": "Import supported shortcut bindings from other editors.", + "tags": ["editor", "keymap"], + "license": "Multiple; see bundled third-party licenses" + } } ] } diff --git a/docs/ci-builds.md b/docs/ci-builds.md index a9d8221ba2..ec62b04493 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -375,6 +375,19 @@ Swift 单元和插件 CI 通道通过 Swatinem/rust-cache 复用裁剪后的 Rus ### 独立工作树的本地编译 +编辑器快捷键导入插件的源码和锁文件随 Git 获取;构建时从锁定的 SDK Git 提交及 +Cargo registry 获取依赖,按当前 rustc、平台和目标架构在本工作树的 +`.artifacts/editor-keymap-provider-target/` 重新编译。此目录登记为 +`editor-keymap-provider-build`,没有可跨工作树复用的封存身份,所有复用阶段均拒绝复制; +直接运行插件 Cargo 测试生成的 `Plugins/mac/Official/EditorKeymapImport/target/` 也是 +工作树独占的可变状态。 +本地装配验证副本 `.artifacts/keymap-plugin-assembly/` 同样不复用,验证后由当前任务清理。 +构建使用本工作树的 `.artifacts/cargo-home/`;registry crate 下载缓存可按包含插件 +`Cargo.lock` 的既有 Cargo 校验路由复用,Git checkout 则不跨工作树复制,Cargo 从固定 +SDK 提交重新获取。构建阶段将可执行文件和许可证复制到插件包, +再随 app 签名;运行时只读该包,来源快照留在进程内并读取用户选择的目录,不能影响 +代码签名或 Sparkle delta 基线。 + CI 的 `Swatinem/rust-cache` 只复用 Cargo 依赖编译输出,按 OS、架构、rustc、 Cargo 清单/锁文件、编译环境和 target 布局区分;Swift/plugin 共享一份 macOS Rust 缓存,Windows Core 分片只有一个写者,host 另用一份,JDT 只读 Core 缓存。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index c578af78dd..61ddb14500 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1175,6 +1175,7 @@ "Debug support is not available for %@" = "Debug support is not available for %@"; "Debug target" = "Debug target"; "Debug: Resume" = "Debug: Resume"; +"Debug: Start or Continue" = "Debug: Start or Continue"; "Debug: Step Into" = "Debug: Step Into"; "Debug: Step Out" = "Debug: Step Out"; "Debug: Step Over" = "Debug: Step Over"; @@ -2401,6 +2402,7 @@ "More information" = "More information"; "More paths changed than are shown." = "More paths changed than are shown."; "More session settings" = "More session settings"; +"More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again." = "More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again."; "Move" = "Move"; "Move %lld items to Trash?" = "Move %lld items to Trash?"; "Move Earlier" = "Move Earlier"; @@ -2710,6 +2712,7 @@ "Octal" = "Octal"; "Off" = "Off"; "Off for this project" = "Off for this project"; +"Offer shortcut mappings for import" = "Offer shortcut mappings for import"; "Official" = "Official"; "Official MCP Registry" = "Official MCP Registry"; "Official plugin %@ declares a signature policy that does not match the host trust policy." = "Official plugin %@ declares a signature policy that does not match the host trust policy."; @@ -3135,6 +3138,7 @@ "Read Memory at Address" = "Read Memory at Address"; "Read Memory at Value" = "Read Memory at Value"; "Read again" = "Read again"; +"Read declared shortcut files from the editor you select for import" = "Read declared shortcut files from the editor you select for import"; "Read result" = "Read result"; "Read, write and search files only in the directories you allow. Requires Node.js 24 or later." = "Read, write and search files only in the directories you allow. Requires Node.js 24 or later."; "Read-only" = "Read-only"; @@ -3710,6 +3714,7 @@ "Shelve and Continue" = "Shelve and Continue"; "Shelving saves these changes in Lithe, runs the operation, then restores them. If conflicts stop the operation, the shelf stays saved until you finish it." = "Shelving saves these changes in Lithe, runs the operation, then restores them. If conflicts stop the operation, the shelf stays saved until you finish it."; "Ships with Lithe" = "Ships with Lithe"; +"Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again." = "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again."; "Shortcut is already assigned to this command" = "Shortcut is already assigned to this command"; "Shortcut needs Command, Control, or Option" = "Shortcut needs Command, Control, or Option"; "Show" = "Show"; @@ -3904,6 +3909,7 @@ "Start a local Java debug session to view performance." = "Start a local Java debug session to view performance."; "Start a new conversation after saving to load the updated instructions. Project instructions still apply." = "Start a new conversation after saving to load the updated instructions. Project instructions still apply."; "Start debugging" = "Start debugging"; +"Start debugging or continue the paused session" = "Start debugging or continue the paused session"; "Start debugging the current Java file" = "Start debugging the current Java file"; "Start from" = "Start from"; "Start in new conversation" = "Start in new conversation"; @@ -6202,6 +6208,7 @@ "native.debugResume" = "Debug: Resume"; "native.debugSelectedTest" = "Debug selected test"; "native.debugSessions" = "Debug sessions"; +"native.debugStartOrContinue" = "Debug: Start or Continue"; "native.debugStepInto" = "Debug: Step Into"; "native.debugStepOut" = "Debug: Step Out"; "native.debugStepOver" = "Debug: Step Over"; @@ -7124,6 +7131,10 @@ "native.kernel" = "Kernel"; "native.keychainErrorLld" = "Keychain error %1$@: %2$@"; "native.keymap" = "Keymap…"; +"native.keymapImportAmbiguous" = "More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again."; +"native.keymapImportProviderPermission" = "Offer shortcut mappings for import"; +"native.keymapImportRuntimeUnavailable" = "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again."; +"native.keymapImportSourceReadPermission" = "Read declared shortcut files from the editor you select for import"; "native.koreanCharacters" = "Korean characters"; "native.kotlinSupport" = "Kotlin Support"; "native.labels" = "Labels"; @@ -8881,6 +8892,7 @@ "native.startANewConversationAfterSavingToLoadTheUpdatedInstructionsProjectInstructionsStillApply" = "Start a new conversation after saving to load the updated instructions. Project instructions still apply."; "native.startDebugging" = "Start Debugging"; "native.startDebugging.variant2" = "Start debugging"; +"native.startDebuggingOrContinuePausedSession" = "Start debugging or continue the paused session"; "native.startDebuggingTheCurrentJavaFile" = "Start debugging the current Java file"; "native.startFrom" = "Start from"; "native.startInNewConversation" = "Start in new conversation"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 03e81aa98f..80663b96d0 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1175,6 +1175,7 @@ "Debug support is not available for %@" = "%@ 暂时没有可用的调试支持"; "Debug target" = "调试目标"; "Debug: Resume" = "调试:继续"; +"Debug: Start or Continue" = "调试:启动或继续"; "Debug: Step Into" = "调试:步入"; "Debug: Step Out" = "调试:步出"; "Debug: Step Over" = "调试:步过"; @@ -2401,6 +2402,7 @@ "More information" = "查看说明"; "More paths changed than are shown." = "更改的路径多于此处显示的数量。"; "More session settings" = "更多会话设置"; +"More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again." = "多个插件都可以从此来源导入快捷键。请停用重复的提供方后重新扫描。"; "Move" = "移动"; "Move %lld items to Trash?" = "将 %lld 个项目移到废纸篓?"; "Move Earlier" = "向前移动"; @@ -2710,6 +2712,7 @@ "Octal" = "八进制"; "Off" = "关闭"; "Off for this project" = "已在此项目关闭"; +"Offer shortcut mappings for import" = "提供可导入的快捷键映射"; "Official" = "官方"; "Official MCP Registry" = "官方 MCP Registry"; "Official plugin %@ declares a signature policy that does not match the host trust policy." = "官方插件 %@ 声明的签名策略与宿主信任策略不一致。"; @@ -3135,6 +3138,7 @@ "Read Memory at Address" = "读取此地址的内存"; "Read Memory at Value" = "读取此值指向的内存"; "Read again" = "重新读取"; +"Read declared shortcut files from the editor you select for import" = "读取所选编辑器中声明的快捷键配置文件以供导入"; "Read result" = "读取结果"; "Read, write and search files only in the directories you allow. Requires Node.js 24 or later." = "读写、搜索你允许的目录中的文件。需要 Node.js 24 或更高版本。"; "Read-only" = "只读"; @@ -3710,6 +3714,7 @@ "Shelve and Continue" = "保存为 Shelf 并继续"; "Shelving saves these changes in Lithe, runs the operation, then restores them. If conflicts stop the operation, the shelf stays saved until you finish it." = "保存为 Shelf 会把这些更改保存在 Lithe 中,执行操作后再恢复。如果操作因冲突中断,Shelf 会一直保留,直到你完成该操作。"; "Ships with Lithe" = "随 Lithe 发行"; +"Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again." = "快捷键导入不可用。请更新 Lithe 和编辑器导入插件后重试。"; "Shortcut is already assigned to this command" = "该命令已使用此快捷键"; "Shortcut needs Command, Control, or Option" = "快捷键需要包含 Command、Control 或 Option"; "Show" = "显示"; @@ -3904,6 +3909,7 @@ "Start a local Java debug session to view performance." = "启动本地 Java 调试会话以查看性能。"; "Start a new conversation after saving to load the updated instructions. Project instructions still apply." = "保存后请新建对话以加载更新后的指令。项目指令仍然有效。"; "Start debugging" = "开始调试"; +"Start debugging or continue the paused session" = "启动调试或继续已暂停的会话"; "Start debugging the current Java file" = "开始调试当前 Java 文件"; "Start from" = "起始引用"; "Start in new conversation" = "在新对话中开始"; @@ -6202,6 +6208,7 @@ "native.debugResume" = "调试:继续"; "native.debugSelectedTest" = "调试所选测试"; "native.debugSessions" = "调试会话"; +"native.debugStartOrContinue" = "调试:启动或继续"; "native.debugStepInto" = "调试:步入"; "native.debugStepOut" = "调试:步出"; "native.debugStepOver" = "调试:步过"; @@ -7124,6 +7131,10 @@ "native.kernel" = "内核"; "native.keychainErrorLld" = "钥匙串错误 %1$@:%2$@"; "native.keymap" = "快捷键…"; +"native.keymapImportAmbiguous" = "多个插件都可以从此来源导入快捷键。请停用重复的提供方后重新扫描。"; +"native.keymapImportProviderPermission" = "提供可导入的快捷键映射"; +"native.keymapImportRuntimeUnavailable" = "快捷键导入不可用。请更新 Lithe 和编辑器导入插件后重试。"; +"native.keymapImportSourceReadPermission" = "读取所选编辑器中声明的快捷键配置文件以供导入"; "native.koreanCharacters" = "韩文字符"; "native.kotlinSupport" = "Kotlin 支持"; "native.labels" = "标签"; @@ -8881,6 +8892,7 @@ "native.startANewConversationAfterSavingToLoadTheUpdatedInstructionsProjectInstructionsStillApply" = "保存后请新建对话以加载更新后的指令。项目指令仍然有效。"; "native.startDebugging" = "开始调试"; "native.startDebugging.variant2" = "开始调试"; +"native.startDebuggingOrContinuePausedSession" = "启动调试或继续已暂停的会话"; "native.startDebuggingTheCurrentJavaFile" = "开始调试当前 Java 文件"; "native.startFrom" = "起始引用"; "native.startInNewConversation" = "在新对话中开始"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index c8055d321b..ec7e891772 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -15,11 +15,15 @@ final class AppServices { /// Process log sink supplied by the composition root; modules receive the same instance. var logSink: (any LogSink)? var editorSettingsImportScan: ((URL?) async throws -> [EditorSettingsImportSource])? + var editorSettingsImportKeymapPlan: (([KeymapImportCurrent], [KeymapImportBinding]) throws -> KeymapImportBindingPlan)? + var editorSettingsImportLoadKeymapOnSelection = false + var debugStartOrContinue: ((Bool, Bool, Bool) throws -> DebugStartOrContinueAction)? func makeEditorSettingsImport(settings: AppSettings) -> EditorSettingsImportFeatureModel { EditorSettingsImportFeatureModel(settings: settings, chooseDirectory: { [platformUI] in platformUI.chooseDirectory(title: settings.language.localizedString("Choose IDE configuration directory"), prompt: settings.language.localizedString("Choose")) - }, scan: editorSettingsImportScan) + }, scan: editorSettingsImportScan, planKeymap: editorSettingsImportKeymapPlan, + loadKeymapOnSelection: editorSettingsImportLoadKeymapOnSelection) } var ideCapabilityRegistry: IdeCapabilityRegistry? diff --git a/macos/Sources/Lithe/Application/Features/EditorSettingsImportFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorSettingsImportFeatureModel.swift index 9f1577bff1..f31ac946ac 100644 --- a/macos/Sources/Lithe/Application/Features/EditorSettingsImportFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorSettingsImportFeatureModel.swift @@ -17,12 +17,18 @@ final class EditorSettingsImportFeatureModel: ObservableObject { let settings: AppSettings private let chooseSourceDirectory: () -> URL? private let scanSources: ((URL?) async throws -> [EditorSettingsImportSource])? + private let planKeymap: (([KeymapImportCurrent], [KeymapImportBinding]) throws -> KeymapImportBindingPlan)? + private let loadKeymapOnSelection: Bool init(settings: AppSettings, chooseDirectory: @escaping () -> URL?, - scan: ((URL?) async throws -> [EditorSettingsImportSource])?) { + scan: ((URL?) async throws -> [EditorSettingsImportSource])?, + planKeymap: (([KeymapImportCurrent], [KeymapImportBinding]) throws -> KeymapImportBindingPlan)? = nil, + loadKeymapOnSelection: Bool = false) { self.settings = settings startupFinished = settings.didOfferEditorSettingsImport chooseSourceDirectory = chooseDirectory scanSources = scan + self.planKeymap = planKeymap + self.loadKeymapOnSelection = loadKeymapOnSelection } var source: EditorSettingsImportSource? { sources.first { $0.id == sourceID } } @@ -39,13 +45,58 @@ final class EditorSettingsImportFeatureModel: ObservableObject { var values = source?.values ?? [:] if let incoming = values["keymap"].flatMap(EditorImportKeymap.decode), var current = baseline["keymap"].flatMap(EditorImportKeymap.decode) { + if let (plan, registrations) = try? keymapPlan(incoming: incoming, current: current), plan.conflicts.isEmpty { + for displaced in plan.displaced { + guard let registration = registrations.first(where: { $0.commandID == displaced.commandID }) else { continue } + let keys = Set(displaced.keys) + current.commands[displaced.commandID] = registration.bindings.filter { binding in + guard let key = Self.portableKey(binding) else { return true } + return !keys.contains(key) + } + } + } current.commands.merge(incoming.commands) { _, imported in imported } values["keymap"] = current.encoded } return values } + private static func portableKey(_ binding: KeyboardShortcutBinding) -> KeymapImportBinding.Key? { + guard case let .keyPress(key, modifiers) = binding else { return nil } + var result: [KeymapImportBinding.Key.Modifier] = [] + if modifiers.contains(.control) { result.append(.control) } + if modifiers.contains(.option) { result.append(.alt) } + if modifiers.contains(.shift) { result.append(.shift) } + if modifiers.contains(.command) { result.append(.meta) } + return .init(key: key == "delete" ? "backspace" : key, modifiers: result) + } + + private func keymapPlan(incoming: EditorImportKeymap, current: EditorImportKeymap) throws + -> (KeymapImportBindingPlan, [KeyboardShortcutRegistration])? { + guard let planKeymap else { return nil } + let registrations = KeyboardShortcutFeatureModel(settings: settings).registrations(for: current.commands) + let currentBindings = registrations.map { registration in + KeymapImportCurrent(commandID: registration.commandID, + customized: current.commands[registration.commandID] != nil, + keys: registration.bindings.compactMap(Self.portableKey)) + } + let incomingRows = incoming.commands.map { id, bindings in + (KeymapImportBinding(commandID: id, keys: bindings.compactMap(Self.portableKey)), bindings.count) + } + // Never silently discard a binding the shared plan cannot represent. + guard incomingRows.allSatisfy({ $0.0.keys.count == $0.1 }) else { throw KeyboardShortcutUpdateError.invalidBinding } + return (try planKeymap(currentBindings, incomingRows.map(\.0)), registrations) + } + var keymapConflict: String? { + if let incoming = source?.values["keymap"].flatMap(EditorImportKeymap.decode), + let current = baseline["keymap"].flatMap(EditorImportKeymap.decode) { + do { + if let (plan, _) = try keymapPlan(incoming: incoming, current: current), let conflict = plan.conflicts.first { + return LitheCommandCatalog.command(id: conflict.ownerID)?.title ?? conflict.ownerID + } + } catch { return "Keyboard shortcuts" } + } guard let map = importValues["keymap"].flatMap(EditorImportKeymap.decode) else { return nil } do { try KeyboardShortcutFeatureModel(settings: settings).validateOverrides(map.commands) } // log-exempt: 随输入实时重算的校验提示:导入草稿的快捷键冲突预览尚未执行写入 @@ -83,6 +134,7 @@ final class EditorSettingsImportFeatureModel: ObservableObject { defer { isBusy = false } do { let scanned = try await scan(directory) + try Task.checkCancellation() if isChoosingSettings, let directory { // Refresh this preview without losing the other choices when navigating back. sources = sources.flatMap { $0.directory == directory ? scanned : [$0] } @@ -92,8 +144,11 @@ final class EditorSettingsImportFeatureModel: ObservableObject { select(scanned.first?.id ?? "") if scanned.isEmpty { message = "No supported IDE configuration was found. You can choose a configuration directory." } return true + } catch is CancellationError { + return false } catch { - message = "The configuration could not be read. Check the selected directory and file permissions." + message = (error as? EditorSettingsImportError)?.errorDescription + ?? "The configuration could not be read. Check the selected directory and file permissions." return false } } @@ -103,10 +158,13 @@ final class EditorSettingsImportFeatureModel: ObservableObject { await scan(directory: directory) } - func chooseSource(_ id: String) { + func chooseSource(_ id: String) async { guard !isBusy, sources.contains(where: { $0.id == id }) else { return } select(id) isChoosingSettings = true + if loadKeymapOnSelection, let source, source.values["keymap"] == nil { + await scan(directory: source.directory) + } } func backToSources() { @@ -149,6 +207,7 @@ final class EditorSettingsImportFeatureModel: ObservableObject { defer { isBusy = false; isImporting = false } do { let refreshed = try await scan(source.directory) + try Task.checkCancellation() guard let current = refreshed.first(where: { $0.id == source.id }), current.revision == source.revision, current.values == source.values else { throw EditorSettingsImportError.changed } let live = settings.editorImportValues() @@ -166,11 +225,14 @@ final class EditorSettingsImportFeatureModel: ObservableObject { throw EditorSettingsImportError.fontSizeDependency } if chosen["keymap"] != nil, let conflict = keymapConflict { throw EditorSettingsImportError.shortcutConflict(conflict) } + try Task.checkCancellation() if !changes.isEmpty { try settings.importEditorSettings(changes, baseline: importBaseline) } baseline = settings.editorImportValues() selected.removeAll() importResult = (changes.count, unselected) return true + } catch is CancellationError { + return false } catch { message = (error as? EditorSettingsImportError)?.errorDescription ?? "The configuration could not be read. Check the selected directory and file permissions." return false diff --git a/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift b/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift index 6a80c20946..1191b779de 100644 --- a/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift +++ b/macos/Sources/Lithe/Application/Features/PluginMarketplace.swift @@ -593,7 +593,7 @@ final class PluginMarketplaceFeatureModel: ObservableObject { guard let snapshot = pluginManager.snapshots.first(where: { $0.id == pluginID }), snapshot.installationStatus != .removed, !snapshot.isRequired else { return false } - return snapshot.origin != .bundled || OfficialPluginCatalog.entry(for: pluginID) != nil + return snapshot.origin != .bundled || OfficialPluginCatalog.trustPolicy(for: pluginID) != nil } // MARK: Installation diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 739ce51ee5..5aaa3bf56a 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2005,6 +2005,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let hostServices: [String]? /// Manifest providers offered to a plugin backend; omitted when none. let toolchainProviders: [String]? + /// Shortcut import providers offered to a plugin backend; omitted when none. + let keymapImportProviders: [String]? let runtimeExecutablePath: String? let jdtlsLaunchResources: LspJdtlsLaunchResourcesRequest? let cacheDirectory: String? @@ -2158,6 +2160,13 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let timeoutMilliseconds: Int } + private struct LspKeymapImportRequest: Encodable { + let sessionId: String + let providerID: String + let snapshotID: String + let timeoutMilliseconds: Int + } + private struct LspVcsRequest: Encodable { let sessionId: String let vcs: String @@ -3959,6 +3968,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { workspaceConfiguration: ToolingJSONValue? = nil, hostServices: [String] = [], toolchainProviders: [String] = [], + keymapImportProviders: [String] = [], runtimeExecutableURL: URL? = nil, jdtlsLaunchResources: JDTLSLaunchResources? = nil, cacheDirectoryURL: URL? = nil, @@ -3984,6 +3994,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { workspaceConfiguration: workspaceConfiguration, hostServices: hostServices.isEmpty ? nil : hostServices, toolchainProviders: toolchainProviders.isEmpty ? nil : toolchainProviders, + keymapImportProviders: keymapImportProviders.isEmpty ? nil : keymapImportProviders, runtimeExecutablePath: runtimeExecutableURL?.standardizedFileURL.path, jdtlsLaunchResources: jdtlsLaunchResources.map { LspJdtlsLaunchResourcesRequest( @@ -4311,6 +4322,22 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } + /// Requests a negotiated import provider using only a host-owned snapshot reference. + func lspKeymapImportRequest( + sessionID: String, + providerID: String, + snapshotID: String, + timeout: TimeInterval + ) -> Result { + executeResult( + command: "lsp.keymapImportRequest", + payload: LspKeymapImportRequest( + sessionId: sessionID, providerID: providerID, snapshotID: snapshotID, + timeoutMilliseconds: max(1, Self.milliseconds(timeout)) + ) + ) + } + /// Invokes a plugin function. Core refuses it unless the session belongs /// to `pluginID` and registered the function. func lspFunctionRequest( diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugShortcutOperations.swift b/macos/Sources/Lithe/Core/Rust/RustDebugShortcutOperations.swift new file mode 100644 index 0000000000..beeeaf871a --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustDebugShortcutOperations.swift @@ -0,0 +1,16 @@ +import Foundation + +enum DebugStartOrContinueAction: String, Decodable { + case start, `continue`, none +} + +extension RustCoreBridge { + /// Uses Core's shared debugger state decision for the combined shortcut. + func debugStartOrContinue(active: Bool, paused: Bool, requestPending: Bool) throws -> DebugStartOrContinueAction { + struct Request: Encodable { let active: Bool; let paused: Bool; let requestPending: Bool } + struct Response: Decodable { let action: DebugStartOrContinueAction } + let result: Result = executeResult( + command: "debug.startOrContinue", payload: Request(active: active, paused: paused, requestPending: requestPending)) + return try result.get().action + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustKeymapImportOperations.swift b/macos/Sources/Lithe/Core/Rust/RustKeymapImportOperations.swift new file mode 100644 index 0000000000..b57dff4c41 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustKeymapImportOperations.swift @@ -0,0 +1,133 @@ +import Foundation +import LitheCoreContracts + +/// A Core-validated file selector, relative to the user's selected source. +struct KeymapImportInputSelector: Decodable, Equatable, Sendable { + enum Kind: String, Decodable, Sendable { case file, directory } + let kind: Kind + let path: String + let fileExtension: String? + + private enum CodingKeys: String, CodingKey { + case kind, path + case fileExtension = "extension" + } +} + +/// A metadata-only test of a path below the selected source directory. +enum KeymapImportRecognitionProbe: Decodable, Equatable, Sendable { + enum Base: String, Decodable, Sendable { case userConfig } + case file(path: String) + case directory(path: String) + case root(base: Base, components: [String]) + + private enum Kind: String, Decodable { case file, directory, root } + private enum CodingKeys: String, CodingKey { case kind, path, base, components } + init(from decoder: Decoder) throws { + let fields = try decoder.container(keyedBy: CodingKeys.self) + switch try fields.decode(Kind.self, forKey: .kind) { + case .file: self = .file(path: try fields.decode(String.self, forKey: .path)) + case .directory: self = .directory(path: try fields.decode(String.self, forKey: .path)) + case .root: self = .root(base: try fields.decode(Base.self, forKey: .base), + components: try fields.decode([String].self, forKey: .components)) + } + } +} + +/// Native I/O consumes the selectors and resource budgets returned by Core. +struct KeymapImportSourcePlan: Decodable, Equatable, Sendable { + struct Limits: Decodable, Equatable, Sendable { + let selectors: Int + let probes: Int + let files: Int + let fileBytes: Int + let totalBytes: Int + let chunkBytes: Int + let directoryEntries: Int + let directoryMatches: Int + } + let inputs: [KeymapImportInputSelector] + let recognition: [KeymapImportRecognitionProbe] + let limits: Limits +} + +/// A normalized Core-validated binding; platform adapters map it to native keys. +struct KeymapImportBinding: Codable, Equatable, Sendable { + struct Key: Codable, Hashable, Sendable { + enum Modifier: String, Codable, Hashable, Sendable { case control, alt, shift, meta } + let key: String + let modifiers: [Modifier] + } + let commandID: String + let keys: [Key] +} + +enum KeymapNativeBindingError: Error { case unsupported } + +extension KeymapImportBinding { + /// Converts only keys this host can actually register; forward delete, + /// higher function keys and other unsupported keys fail as a whole. + func nativeBindings() throws -> [KeyboardShortcutBinding] { + try keys.map { item in + var modifiers: KeyboardShortcutModifiers = [] + for modifier in item.modifiers { + switch modifier { + case .control: modifiers.insert(.control) + case .alt: modifiers.insert(.option) + case .shift: modifiers.insert(.shift) + case .meta: modifiers.insert(.command) + } + } + guard item.key != "delete" else { throw KeymapNativeBindingError.unsupported } + let binding = KeyboardShortcutBinding.keyPress(key: item.key == "backspace" ? "delete" : item.key, modifiers: modifiers) + guard binding.isAssignable else { throw KeymapNativeBindingError.unsupported } + return binding + } + } +} + +struct KeymapImportCurrent: Encodable, Sendable { + let commandID: String + let customized: Bool + let keys: [KeymapImportBinding.Key] +} + +struct KeymapImportBindingPlan: Decodable, Sendable { + struct Conflict: Decodable, Sendable { + let commandID: String + let ownerID: String + let key: KeymapImportBinding.Key + } + let displaced: [KeymapImportBinding] + let conflicts: [Conflict] +} + +/// Bridges generic shortcut import data to Core; it never parses source formats. +struct RustKeymapImportOperations: Sendable { + let core: RustCoreBridge + + func planSources(manifest: Data, providerID: String) throws -> KeymapImportSourcePlan { + struct Request: Encodable { let manifest: ToolingJSONValue; let providerID: String } + let payload = Request(manifest: try JSONDecoder().decode(ToolingJSONValue.self, from: manifest), providerID: providerID) + let result: Result = core.executeResult(command: "keymap.planImportSources", payload: payload) + return try result.get() + } + + func decodeImport(commandIDs: [String], reply: ToolingJSONValue) throws -> [KeymapImportBinding] { + struct Request: Encodable { let commandIDs: [String]; let reply: ToolingJSONValue } + struct Response: Decodable { let bindings: [KeymapImportBinding] } + let result: Result = core.executeResult( + command: "keymap.decodeImport", payload: Request(commandIDs: commandIDs, reply: reply) + ) + return try result.get().bindings + } + + func planBindings(current: [KeymapImportCurrent], incoming: [KeymapImportBinding]) throws -> KeymapImportBindingPlan { + struct Reply: Encodable { let bindings: [KeymapImportBinding] } + struct Request: Encodable { let current: [KeymapImportCurrent]; let incoming: Reply } + let result: Result = core.executeResult( + command: "keymap.planImportBindings", payload: Request(current: current, incoming: Reply(bindings: incoming)) + ) + return try result.get() + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index b242adf0b8..835c7c04de 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -338,6 +338,31 @@ extension RustCoreBridge: LanguageServerRuntimeCore { .mapError(Self.runtimeFailure) } + func requestKeymapImportProvider( + sessionID: String, providerID: String, snapshotID: String, timeout: TimeInterval + ) -> Result { + lspKeymapImportRequest(sessionID: sessionID, providerID: providerID, snapshotID: snapshotID, timeout: timeout) + .map { LanguageServerRuntimeOperation(operationID: $0.operationId) } + .mapError(Self.runtimeFailure) + } + + func startPluginKeymapImportBackend( + pluginID: String, providerID: String, executableURL: URL, arguments: [String], + environment: [String: String], rootURL: URL, initializationOptions: ToolingJSONValue?, + hostServices: [String], toolchainProviders: [String], keymapImportProviders: [String], + initializeTimeout: TimeInterval, requestTimeout: TimeInterval, shutdownTimeout: TimeInterval + ) -> Result { + lspStartServer( + providerID: providerID, pluginID: pluginID, executableURL: executableURL, + arguments: arguments, environment: environment, rootURL: rootURL, workingDirectoryURL: rootURL, + initializationOptions: initializationOptions, hostServices: hostServices, + toolchainProviders: toolchainProviders, keymapImportProviders: keymapImportProviders, + initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout + ).map { + LanguageServerRuntimeStart(sessionID: $0.sessionId, state: $0.state, processID: $0.processId) + }.mapError(Self.runtimeFailure) + } + func startPluginToolchainBackend( pluginID: String, providerID: String, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift index 7aa896c939..4782262dd7 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift @@ -78,6 +78,18 @@ extension AppModel { runWorkflowCoordinator.startModuleOperation { [weak self] in await self?.startDebuggingAfterActivation() } } + func startOrContinueDebugging() { + let feature = genericDebugFeatureIfActive + guard let decide = services.debugStartOrContinue, + let action = try? decide(feature?.isSessionActive == true, feature?.state == .paused, + feature?.isExecutionRequestPending == true) else { return } + switch action { + case .start: startDebugging() + case .continue: resumeDebugging() + case .none: break + } + } + func startOrRestartDebugging() { if let target = activeTestTarget, target.name != genericDebugFeatureIfActive?.targetTitle { startDebugging() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 8119456fec..8b10da54ec 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -380,7 +380,7 @@ extension AppModel { case "debug-resume", "debug-step-over", "debug-step-into", "debug-step-out": genericDebugFeatureIfActive?.state == .paused case "close-project", "search-everywhere", "find-action", "search-in-project", - "replace-in-project", "project-local-history", "run", "debug", + "replace-in-project", "project-local-history", "run", "debug", "debug-start-or-continue", "stop-run", "stop-debug", "toggle-terminal", "toggle-problems", "toggle-git-log", "toggle-run", "toggle-tests", "toggle-debug", "view-breakpoints", "spring-endpoints", "rebuild-java-index": diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 37e6296ec9..85b265f778 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -139,6 +139,7 @@ enum LitheCommandCatalog { command("run", "Run", "Run selected configuration", .run, "r", [.control]), command("debug", "Debug", "Start debugging", .run, "d", [.control]), + command("debug-start-or-continue", "Debug: Start or Continue", "Start debugging or continue the paused session", .run), command("stop-run", "Stop Run", "Stop the current run", .run), command("stop-debug", "Stop Debug", "Stop the current debug session", .run), command("debug-resume", "Debug: Resume", "Resume the paused debug session", .run, "f9"), @@ -192,7 +193,7 @@ enum LitheCommandCatalog { static func moduleOwner(for id: String) -> ModuleID? { switch id { case "run", "stop-run", "toggle-run", "toggle-tests": .execution - case "debug", "stop-debug", "debug-resume", "debug-step-over", "debug-step-into", "debug-step-out", + case "debug", "debug-start-or-continue", "stop-debug", "debug-resume", "debug-step-over", "debug-step-into", "debug-step-out", "toggle-breakpoint", "view-breakpoints", "toggle-debug": .debug case "toggle-terminal": .terminal case "toggle-git-log": .git diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index a29fc39dc3..23cb5dc386 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -59,6 +59,7 @@ enum LitheActionRegistry { [ action("run", model: model) { model.runSelectedConfiguration() }, action("debug", model: model) { model.startDebugging() }, + action("debug-start-or-continue", model: model) { model.startOrContinueDebugging() }, action("stop-run", model: model) { model.stopSelectedRun() }, action("stop-debug", model: model) { model.stopDebugging() }, action("debug-resume", model: model) { model.resumeDebugging() }, diff --git a/macos/Sources/Lithe/Models/Settings/EditorSettingsImport.swift b/macos/Sources/Lithe/Models/Settings/EditorSettingsImport.swift index fe0db7400f..15794e2a6e 100644 --- a/macos/Sources/Lithe/Models/Settings/EditorSettingsImport.swift +++ b/macos/Sources/Lithe/Models/Settings/EditorSettingsImport.swift @@ -6,7 +6,7 @@ struct EditorSettingsImportSource: Identifiable, Equatable, Sendable { var id: String { directory.path } let directory: URL let title: String - let revision: String + var revision: String var values: [String: String] var warnings: [String] var lineHeightFontSize: String? = nil @@ -94,6 +94,7 @@ struct EditorSettingsImportJournal: Codable { enum EditorSettingsImportError: Error, LocalizedError { case changed, invalid, storage, read, fontSizeDependency + case ambiguousShortcutProviders case shortcutConflict(String) var errorDescription: String? { if case .shortcutConflict(let command) = self { @@ -103,6 +104,7 @@ enum EditorSettingsImportError: Error, LocalizedError { } private var messageKey: String { switch self { + case .ambiguousShortcutProviders: "More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again." case .shortcutConflict: "Keyboard shortcuts" case .fontSizeDependency: "Select the source font size together with pixel line height, or keep the current line height." case .changed: "Settings changed. Scan again before importing or restoring." diff --git a/macos/Sources/Lithe/Platform/MacOS/MacEditorSettingsImportReader.swift b/macos/Sources/Lithe/Platform/MacOS/MacEditorSettingsImportReader.swift index f2eb1b5eea..633b76af48 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacEditorSettingsImportReader.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacEditorSettingsImportReader.swift @@ -69,7 +69,7 @@ enum MacEditorSettingsImportReader { private static func read(root: URL, title: String, vscode: Bool, fonts: Set, logSink: (any LogSink)?) throws -> EditorSettingsImportSource { let fm = FileManager.default var files: [String: Data] = [:] - var names = vscode ? ["settings.json", "keybindings.json"] : ["options/editor.xml", "options/editor-font.xml", "options/colors.scheme.xml", "options/laf.xml", "options/keymap.xml", "options/code.style.schemes.xml"] + var names = vscode ? ["settings.json"] : ["options/editor.xml", "options/editor-font.xml", "options/colors.scheme.xml", "options/laf.xml", "options/code.style.schemes.xml"] if !vscode { let colors = root.appendingPathComponent("colors") if fm.fileExists(atPath: colors.path) { @@ -81,7 +81,7 @@ enum MacEditorSettingsImportReader { } } if !vscode { - for folder in ["keymaps", "codestyles"] { + for folder in ["codestyles"] { let directory = root.appendingPathComponent(folder) if fm.fileExists(atPath: directory.path) { guard directory.resolvingSymlinksInPath().path.hasPrefix(root.resolvingSymlinksInPath().path + "/") else { throw EditorSettingsImportError.read } @@ -107,7 +107,6 @@ enum MacEditorSettingsImportReader { guard totalBytes <= 8_388_608 else { throw EditorSettingsImportError.read } files[name] = data } - guard vscode || !files.isEmpty else { throw EditorSettingsImportError.read } var digest = SHA256() for name in names { digest.update(data: Data(name.utf8)); digest.update(data: files[name] ?? Data()) } // ponytail: parse at most 8 MiB twice to distinguish explicit values from reference defaults. @@ -116,18 +115,11 @@ enum MacEditorSettingsImportReader { var resolved = try vscode ? parseVSCode(files["settings.json"] ?? Data("{}".utf8), fonts: fonts, includeAppearanceDefaults: true) : parseIDEA(files, fonts: fonts, includeAppearanceDefaults: true) - if vscode { - let bindings = try MacImportedKeybindings.parseVSCode(files["keybindings.json"] ?? Data("[]".utf8), includeDefaults: true) - if !bindings.isEmpty { resolved.0["keymap"] = EditorImportKeymap(preset: "", commands: bindings).encoded } - if files["keybindings.json"] != nil { resolved.1.append(MacImportedKeybindings.skipped) } - } if resolved.0["colorScheme"] != nil, resolved.0["customTheme"] == nil { resolved.0["customTheme"] = "" } var defaults = Set(resolved.0.keys).subtracting(parsed.0.keys) - if vscode, files["keybindings.json"] != nil { defaults.remove("keymap") } // Pixel height is explicit even when its conversion depends on the reference font size. if vscode, resolved.2 != nil { defaults.remove("lineHeight") } var approximate: Set = resolved.0["colorScheme"] == nil ? [] : ["colorScheme"] - if vscode, resolved.0["keymap"] != nil { approximate.insert("keymap") } if !vscode { approximate.formUnion(defaults); if resolved.0["lineHeight"] != nil { approximate.insert("lineHeight") } } return EditorSettingsImportSource(directory: root, title: title, revision: digest.finalize().map { String(format: "%02x", $0) }.joined(), values: resolved.0, warnings: resolved.1, lineHeightFontSize: resolved.2, isVSCode: vscode, settingsFileMissing: vscode && files["settings.json"] == nil, defaultFields: defaults, approximateFields: approximate) @@ -383,24 +375,6 @@ enum MacEditorSettingsImportReader { put(.detectIndentation, code?.codeOptions["AUTODETECT_INDENTS"] ?? (includeAppearanceDefaults && knownCodeBase ? "true" : nil)) if code != nil || includeAppearanceDefaults { warnings.append("Only global indentation is imported. Language-specific code styles and formatter rules are skipped.") } - let active = documents["options/keymap.xml"]?.activeKeymap ?? (includeAppearanceDefaults ? "Mac OS X 10.5+" : nil) - if let active { - var visited: Set = [] - func resolve(_ name: String) throws -> [String: [KeyboardShortcutBinding]] { - guard visited.insert(name).inserted, visited.count <= 64 else { throw EditorSettingsImportError.invalid } - let matches = documents.filter { $0.key.hasPrefix("keymaps/") && $0.value.keymapName == name }.map(\.value) - guard matches.count <= 1 else { throw EditorSettingsImportError.invalid } - guard let map = matches.first else { return includeAppearanceDefaults ? MacImportedKeybindings.ideaDefaults(name) ?? [:] : [:] } - var result = includeAppearanceDefaults && map.keymapParent != nil ? try resolve(map.keymapParent!) : [:] - result.merge(map.keyBindings) { _, explicit in explicit } - // Unsupported explicit actions mask inherited bindings; never revive their parent shortcut. - for rejected in map.rejectedActions { result.removeValue(forKey: rejected) } - return result - } - let bindings = try resolve(active) - if !bindings.isEmpty { values["keymap"] = EditorImportKeymap(preset: "", commands: bindings).encoded } - warnings.append("Supported keyboard commands are mapped; unknown keymaps, mouse shortcuts and chords are skipped.") - } warnings.append("Appearance defaults are labeled separately. Workspace and language overrides, formatters, extensions and account data are skipped.") if let version = font["VERSION"], !["0", "1"].contains(version) { for key in ["fontFamily", "fontSize", "lineHeight", "fontLigatures"] { values.removeValue(forKey: key) } @@ -418,12 +392,6 @@ enum MacEditorSettingsImportReader { /// XMLParser never resolves external entities; declarations are rejected before parsing. final class ImportXML: NSObject, XMLParserDelegate { var components: [String: [String: String]] = [:] - var activeKeymap: String? - var keymapName: String? - var keymapParent: String? - var keyBindings: [String: [KeyboardShortcutBinding]] = [:] - private var action: String? - var rejectedActions: Set = [] var codeName: String? var codeParent: String? var codeOptions: [String: String] = [:] @@ -453,23 +421,6 @@ final class ImportXML: NSObject, XMLParserDelegate { let parent = elements.last elements.append(elementName) optionNames.append(elementName == "option" ? attributes["name"] : nil) - if elementName == "active_keymap", component == "KeymapManager" { activeKeymap = attributes["name"] } - if elementName == "keymap", elements.count == 1 { keymapName = attributes["name"]; keymapParent = attributes["parent"] } - if elementName == "action", parent == "keymap", elements.count == 2 { - action = attributes["id"].flatMap { MacImportedKeybindings.idea[$0] } - if let action { - if keyBindings[action] != nil { rejectedActions.insert(action) } - keyBindings[action] = [] - } - } - if elementName == "keyboard-shortcut", parent == "action", elements.count == 3, let action { - if attributes["second-keystroke"] != nil { rejectedActions.insert(action) } - else if let key = attributes["first-keystroke"], let binding = MacImportedKeybindings.binding(key, idea: true), - keyBindings[action, default: []].count < 8 { - if !keyBindings[action, default: []].contains(binding) { keyBindings[action, default: []].append(binding) } - } else { rejectedActions.insert(action) } - } - if elementName == "mouse-shortcut", let action { rejectedActions.insert(action) } if elementName == "code_scheme", elements.count == 1 { codeName = attributes["name"]; codeParent = attributes["parent_scheme"] } if elementName == "option", let name = attributes["name"], let value = attributes["value"], codeName != nil { if parent == "code_scheme", elements.count == 2 { codeOptions[name] = value } @@ -510,8 +461,6 @@ final class ImportXML: NSObject, XMLParserDelegate { if elementName == "global_color_scheme", component == "EditorColorsManager" { scheme = attributes["name"] } } func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { - if elementName == "action" { action = nil } - if elementName == "keymap" { for rejected in rejectedActions { keyBindings.removeValue(forKey: rejected) } } optionNames.removeLast() if elementName == "component" { component = nil } elements.removeLast() diff --git a/macos/Sources/Lithe/Platform/MacOS/MacImportedKeybindings.swift b/macos/Sources/Lithe/Platform/MacOS/MacImportedKeybindings.swift deleted file mode 100644 index 9ec5d8e662..0000000000 --- a/macos/Sources/Lithe/Platform/MacOS/MacImportedKeybindings.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Foundation - -/// Whitelisted command mappings; conditional, chord and extension semantics are never made global. -enum MacImportedKeybindings { - static let vscode: [String: String] = [ - "workbench.action.files.save": "save", "workbench.action.openSettings": "settings", - "workbench.action.navigateBack": "navigate-back", "workbench.action.navigateForward": "navigate-forward", - "workbench.action.findInFiles": "search-in-project", "workbench.action.replaceInFiles": "replace-in-project", - "workbench.action.gotoLine": "go-to-line", "workbench.action.terminal.toggleTerminal": "toggle-terminal", - "workbench.action.debug.start": "debug", "workbench.action.debug.run": "run", - "workbench.action.debug.continue": "debug-resume", "workbench.action.debug.stepOver": "debug-step-over", - "workbench.action.debug.stepInto": "debug-step-into", "workbench.action.debug.stepOut": "debug-step-out", - ] - static let idea: [String: String] = [ - "ShowSettings": "settings", "Back": "navigate-back", "Forward": "navigate-forward", - "FindInPath": "search-in-project", "ReplaceInPath": "replace-in-project", "GotoLine": "go-to-line", - "ActivateTerminalToolWindow": "toggle-terminal", "Run": "run", "Debug": "debug", - "Resume": "debug-resume", "StepOver": "debug-step-over", "StepInto": "debug-step-into", "StepOut": "debug-step-out", - ] - // Matching keyboard actions resolved from JetBrains Community keymaps ($default and macOS), - // Apache-2.0, revision e12d185a335d02f3831c12f2af64130b3ace46db. - // Parent control/meta modifiers are swapped by upstream MacOSDefaultKeymap. - // Mouse bindings and actions without a Lithe equivalent are intentionally not imported. - static func ideaDefaults(_ name: String) -> [String: [KeyboardShortcutBinding]]? { - guard let entries = ideaReferenceKeys[name] else { return nil } - return entries.mapValues { $0.compactMap { binding($0, idea: true) } } - } - private static let ideaReferenceKeys: [String: [String: [String]]] = [ - "$default": [ - "debug": ["shift F9"], - "debug-resume": ["F9"], - "debug-step-into": ["F7"], - "debug-step-out": ["shift F8"], - "debug-step-over": ["F8"], - "go-to-line": ["control G"], - "navigate-back": ["control alt LEFT"], - "navigate-forward": ["control alt RIGHT"], - "replace-in-project": ["control shift R"], - "run": ["shift F10"], - "search-in-project": ["control shift F"], - "settings": ["control alt S"], - "toggle-terminal": ["alt F12"], - ], - "Mac OS X": [ - "debug": ["shift F9"], - "debug-resume": ["F9"], - "debug-step-into": ["F7"], - "debug-step-out": ["shift F8"], - "debug-step-over": ["F8"], - "go-to-line": ["meta G"], - "navigate-back": ["meta alt LEFT"], - "navigate-forward": ["meta alt RIGHT"], - "replace-in-project": ["control shift R"], - "run": ["shift F10"], - "search-in-project": ["control shift F"], - "settings": ["meta COMMA"], - "toggle-terminal": ["alt F12"], - ], - "Mac OS X 10.5+": [ - "debug": ["control D"], - "debug-resume": ["meta alt R", "F9"], - "debug-step-into": ["F7"], - "debug-step-out": ["shift F8"], - "debug-step-over": ["F8"], - "go-to-line": ["meta L"], - "navigate-back": ["meta OPEN_BRACKET", "meta alt LEFT"], - "navigate-forward": ["meta CLOSE_BRACKET", "meta alt RIGHT"], - "replace-in-project": ["meta shift R"], - "run": ["control R"], - "search-in-project": ["meta shift F"], - "settings": ["meta COMMA"], - "toggle-terminal": ["alt F12"], - ], - ] - static let skipped = "Reference shortcuts are merged with supported user overrides. Conditional rules, chords and removals suppress the affected mappings; extension keymaps are skipped." - - static func binding(_ value: String, idea: Bool) -> KeyboardShortcutBinding? { - let parts = idea ? value.lowercased().split(separator: " ").map(String.init) - : value.lowercased().split(separator: "+", omittingEmptySubsequences: false).map(String.init) - guard let last = parts.last, !last.isEmpty, last != "delete" else { return nil } - var modifiers: KeyboardShortcutModifiers = [] - for part in parts.dropLast() { - let flag: KeyboardShortcutModifiers - switch part { - case "ctrl", "control": flag = .control - case "alt", "option": flag = .option - case "shift": flag = .shift - case "cmd", "meta", "command": flag = .command - default: return nil - } - guard !modifiers.contains(flag) else { return nil } - modifiers.insert(flag) - } - let key = ["enter": "return", "backspace": "delete", "back_space": "delete", "comma": ",", "period": ".", - "slash": "/", "back_slash": "\\", "open_bracket": "[", "close_bracket": "]", - "minus": "-", "equals": "=" ][last] ?? last - let result = KeyboardShortcutBinding.keyPress(key: key, modifiers: modifiers) - return result.isAssignable ? result : nil - } - - static func parseVSCode(_ data: Data, includeDefaults: Bool = false) throws -> [String: [KeyboardShortcutBinding]] { - guard data.count <= 1_048_576, - let rows = try JSONSerialization.jsonObject(with: data, options: [.json5Allowed]) as? [[String: Any]], - rows.count <= 4096 else { throw EditorSettingsImportError.invalid } - // VS Code macOS reference: https://code.visualstudio.com/docs/reference/default-keybindings - // Only context-independent commands with existing Lithe equivalents are included. - let reference = ["save": "cmd+s", "settings": "cmd+,", "navigate-back": "ctrl+-", - "navigate-forward": "ctrl+shift+-", "search-in-project": "cmd+shift+f", - "replace-in-project": "cmd+shift+h", "go-to-line": "ctrl+g", "toggle-terminal": "ctrl+`"] - var result: [String: [KeyboardShortcutBinding]] = includeDefaults - ? reference.mapValues { [binding($0, idea: false)!] } : [:] - var seen: Set = [], rejected: Set = [] - for row in rows { - // Later rules own their first key even when a chord/command/context cannot be migrated. - // Apply this to both reference defaults and earlier user mappings. - if let key = row["key"] as? String, - let firstKey = key.split(whereSeparator: \.isWhitespace).first, - let value = binding(String(firstKey), idea: false) { - result = result.filter { !$0.value.contains(value) } - } - guard let name = row["command"] as? String else { continue } - let removal = name.hasPrefix("-") - guard let command = vscode[removal ? String(name.dropFirst()) : name] else { continue } - guard seen.insert(command).inserted, !removal, row["when"] == nil, row["args"] == nil, - let key = row["key"] as? String, let binding = binding(key, idea: false) else { - rejected.insert(command); continue - } - result[command] = [binding] - } - for command in rejected { result.removeValue(forKey: command) } - return result - } -} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index d10cf49b49..01e1a33494 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1181,7 +1181,30 @@ final class MacServiceContainer { plugins: pluginManager?.snapshots.filter { $0.isEnabled }.map(\.id.rawValue) ?? []) } services.logSink = applicationLogging as? any LogSink - services.editorSettingsImportScan = { try await MacEditorSettingsImportReader.scan(directory: $0, logSink: logSink) } + services.debugStartOrContinue = { active, paused, pending in + try rustCore.debugStartOrContinue(active: active, paused: paused, requestPending: pending) + } + let keymapImport = MacEditorKeymapImportService( + find: { try await pluginBackends.matchingKeymapProviders(root: $0) }, + importProvider: { provider, root in + try await pluginBackends.importKeymap(provider.id, selectedRoot: root, ofPlugin: provider.pluginID, timeout: 30) + }, + operations: RustKeymapImportOperations(core: rustCore) + ) + services.editorSettingsImportScan = { directory in + let sources = try await MacEditorSettingsImportReader.scan(directory: directory, logSink: logSink) + guard directory != nil else { return sources } + var imported: [EditorSettingsImportSource] = [] + for source in sources { + try Task.checkCancellation() + imported.append(source.readFailed ? source : try await keymapImport.importing(source)) + } + return imported + } + services.editorSettingsImportLoadKeymapOnSelection = true + services.editorSettingsImportKeymapPlan = { current, incoming in + try RustKeymapImportOperations(core: rustCore).planBindings(current: current, incoming: incoming) + } services.ideCapabilityRegistry = ideCapabilityRegistry services.pluginRegistrationChanges = livePlugins.changes // Static editor resources follow their own enablement; ordinary tool @@ -1306,7 +1329,7 @@ final class MacServiceContainer { manifest: plugin.descriptor.normalizedManifest, isEnabled: enabled.contains(plugin.descriptor.id), // Short tool IDs need an official catalog entry installed through a verified channel. - verifiedOfficial: OfficialPluginCatalog.entry(for: plugin.descriptor.id) != nil + verifiedOfficial: OfficialPluginCatalog.trustPolicy(for: plugin.descriptor.id) != nil && [.bundled, .marketplace].contains(plugin.installation.origin) ) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacEditorKeymapImportService.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacEditorKeymapImportService.swift new file mode 100644 index 0000000000..272dc4a6e8 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacEditorKeymapImportService.swift @@ -0,0 +1,58 @@ +import Foundation +import LitheCoreContracts + +/// Adds a plugin-provided keymap to an already discovered settings source. +/// Detection is metadata-only; only an explicitly selected source starts a backend. +@MainActor +final class MacEditorKeymapImportService { + typealias Provider = MacPluginBackendSessions.KeymapProvider + typealias ImportResult = MacPluginBackendSessions.KeymapImportResult + private let find: (URL) async throws -> [Provider] + private let importProvider: (Provider, URL) async throws -> ImportResult + private let operations: RustKeymapImportOperations + + init(find: @escaping (URL) async throws -> [Provider], + importProvider: @escaping (Provider, URL) async throws -> ImportResult, + operations: RustKeymapImportOperations) { + self.find = find + self.importProvider = importProvider + self.operations = operations + } + + func importing(_ source: EditorSettingsImportSource) async throws -> EditorSettingsImportSource { + let candidates = try await find(source.directory) + guard let provider = candidates.first else { return source } + guard candidates.count == 1 else { throw EditorSettingsImportError.ambiguousShortcutProviders } + let imported = try await importProvider(provider, source.directory) + try Task.checkCancellation() + guard case let .object(envelope) = imported.response, + case let .object(response)? = envelope["response"], + let reply = response["result"], response["error"] == nil else { + throw EditorSettingsImportError.invalid + } + let operations = operations + let commandIDs = LitheCommandCatalog.commands.map(\.id) + let task = Task.detached(priority: .userInitiated) { + try Task.checkCancellation() + let bindings = try operations.decodeImport(commandIDs: commandIDs, reply: reply) + var commands: [String: [KeyboardShortcutBinding]] = [:] + for binding in bindings { + try Task.checkCancellation() + commands[binding.commandID] = try binding.nativeBindings() + } + return commands + } + let commands = try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } + try Task.checkCancellation() + var result = source + result.revision += "|\(provider.pluginID)|\(provider.id)|\(imported.sourceDigest)" + if !commands.isEmpty { + result.values["keymap"] = EditorImportKeymap(preset: "", commands: commands).encoded + result.approximateFields.insert("keymap") + if imported.sourceFileCount == 0 { result.defaultFields.insert("keymap") } + let warning = "Reference shortcuts are merged with supported user overrides. Conditional rules, chords and removals suppress the affected mappings; extension keymaps are skipped." + if !result.warnings.contains(warning) { result.warnings.append(warning) } + } + return result + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportHostService.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportHostService.swift new file mode 100644 index 0000000000..efbbd78ab2 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportHostService.swift @@ -0,0 +1,58 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Exposes only the files in a short-lived snapshot to its owning backend. +@MainActor +final class MacKeymapImportHostService: PluginHostServiceHandling { + let service = "keymapImport" + let methods: Set = ["lithe/host/keymapImport/listInputs", "lithe/host/keymapImport/readChunk"] + let snapshots = MacKeymapImportSnapshots() + + func endSession(pluginID: String, sessionID: String) { + snapshots.endSession(pluginID: pluginID, sessionID: sessionID) + } + + func answer(_ call: PluginHostCall) async -> Result { + let invalid = PluginHostServiceError(.invalidParams, L10n.string("The plugin host request parameters are invalid.")) + guard case let .string(token) = call.params["snapshotID"], + case let .string(providerID) = call.params["providerID"] else { return .failure(invalid) } + let snapshot: MacKeymapImportSnapshot + do { snapshot = try snapshots.snapshot(token, pluginID: call.pluginID, sessionID: call.sessionID, providerID: providerID) } + catch { return .failure(PluginHostServiceError(.contextExpired, L10n.string("The plugin host request could not be completed."))) } + + switch call.request.method { + case "lithe/host/keymapImport/listInputs": + return .success(.object(["files": .array(snapshot.files.map { file in + .object(["id": .string(file.id), "path": .string(file.path), "byteCount": .integer(file.byteCount)]) + })])) + case "lithe/host/keymapImport/readChunk": + guard case let .string(fileID) = call.params["fileID"], + case let .integer(offset) = call.params["offset"], + case let .integer(maximumBytes) = call.params["maximumBytes"] else { return .failure(invalid) } + let task = Task.detached { try snapshot.read(fileID: fileID, offset: offset, maximumBytes: maximumBytes) } + do { + let data = try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } + try Task.checkCancellation() + guard try snapshots.snapshot(token, pluginID: call.pluginID, sessionID: call.sessionID, providerID: providerID) === snapshot else { + throw MacKeymapImportSnapshots.Failure.expired + } + return .success(.object(["data": .string(data.base64EncodedString())])) + } catch { + let code: PluginHostErrorCode + switch error { + case is CancellationError: code = .cancelled + case MacKeymapImportSnapshots.Failure.expired, MacKeymapImportSnapshot.Failure.closed: code = .contextExpired + case MacKeymapImportSnapshot.Failure.sourceChanged: code = .versionConflict + case MacKeymapImportSnapshot.Failure.limitExceeded: code = .limitExceeded + case MacKeymapImportSnapshot.Failure.unknownFile: code = .notFound + case MacKeymapImportSnapshot.Failure.invalidRange: code = .invalidParams + default: code = .failed + } + return .failure(PluginHostServiceError(code, L10n.string("The plugin host request could not be completed."))) + } + default: + return .failure(PluginHostServiceError(.unavailable, L10n.string("The plugin host request could not be completed."))) + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportRecognition.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportRecognition.swift new file mode 100644 index 0000000000..00b033ae94 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportRecognition.swift @@ -0,0 +1,45 @@ +import Darwin +import Foundation + +/// Checks only declared metadata below a source root, without reading files or +/// starting a backend. Recognition is repeated when the import is started. +enum MacKeymapImportRecognition { + static func matches(root: URL, plan: KeymapImportSourcePlan, + userConfigRoot: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")) throws -> Bool { + guard !plan.recognition.isEmpty, plan.recognition.count <= plan.limits.probes else { + throw MacKeymapImportSnapshot.Failure.invalidPlan + } + let rootFD = try MacWorkspaceSecurePath.openRoot(root) + defer { Darwin.close(rootFD) } + for probe in plan.recognition { + try Task.checkCancellation() + let path: String + let expectedKind: mode_t + switch probe { + case .root(base: .userConfig, components: let expected): + let base = userConfigRoot.standardizedFileURL.pathComponents + let candidate = root.standardizedFileURL.pathComponents + if candidate.starts(with: base), candidate.count == base.count + expected.count, + zip(candidate.dropFirst(base.count), expected).allSatisfy({ $0.1 == "*" || $0.0 == $0.1 }) { + return true + } + continue + case .file(path: let value): path = value; expectedKind = mode_t(S_IFREG) + case .directory(path: let value): path = value; expectedKind = mode_t(S_IFDIR) + } + let components = try MacWorkspaceSecurePath.components(of: path) + guard let name = components.last else { throw MacKeymapImportSnapshot.Failure.invalidPlan } + do { + let parent = try MacWorkspaceSecurePath.openDirectory(components.dropLast(), below: rootFD) + defer { Darwin.close(parent) } + let status = try MacWorkspaceSecurePath.status(of: name, in: parent) + let kind = status.st_mode & S_IFMT + guard kind != S_IFLNK else { throw MacKeymapImportSnapshot.Failure.invalidPlan } + if kind == expectedKind { return true } + } catch PluginWorkspaceFileError.notFound { + continue + } + } + return false + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportSnapshot.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportSnapshot.swift new file mode 100644 index 0000000000..bfdcad74ab --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportSnapshot.swift @@ -0,0 +1,259 @@ +import Darwin +import Foundation +import CryptoKit + +/// A short-lived, read-only view of Core-planned files under one selected root. +/// Construct and read it off the main actor; its owner must close it when the +/// import completes, is cancelled, or its plugin session ends. +final class MacKeymapImportSnapshot: @unchecked Sendable { + struct File: Equatable, Sendable { + let id: String + let path: String + let byteCount: Int + } + + enum Failure: Error { + case invalidPlan, limitExceeded, closed, unknownFile, invalidRange, sourceChanged + } + + private struct OpenFile { + let file: File + let descriptor: Int32 + let components: [String] + let status: Darwin.stat + } + + let files: [File] + private let lock = NSLock() + private let rootDescriptor: Int32 + private let maximumChunkBytes: Int + private var openFiles: [String: OpenFile] + private var isClosed = false + + init(root: URL, plan: KeymapImportSourcePlan) throws { + let limits = plan.limits + guard !plan.inputs.isEmpty, plan.inputs.count <= limits.selectors, + limits.files > 0, limits.fileBytes > 0, limits.totalBytes > 0, + limits.chunkBytes > 0, limits.directoryEntries > 0, limits.directoryMatches > 0 else { + throw Failure.invalidPlan + } + let rootFD = try MacWorkspaceSecurePath.openRoot(root) + var opened: [(path: String, components: [String], fd: Int32, status: Darwin.stat)] = [] + var committed = false + defer { + if !committed { + opened.forEach { Darwin.close($0.fd) } + Darwin.close(rootFD) + } + } + var seen: Set = [] + var totalBytes = 0 + + func add(_ components: [String]) throws { + try Task.checkCancellation() + let path = components.joined(separator: "/") + guard let name = components.last, seen.insert(path).inserted else { return } + do { + let parent = try MacWorkspaceSecurePath.openDirectory(components.dropLast(), below: rootFD) + defer { Darwin.close(parent) } + let file = try MacWorkspaceSecurePath.openRegularFile(named: name, in: parent) + let size = file.status.st_size + let (sum, overflow) = totalBytes.addingReportingOverflow(Int(size)) + guard size >= 0, size <= off_t(limits.fileBytes), !overflow, + opened.count < limits.files, sum <= limits.totalBytes else { + Darwin.close(file.descriptor) + throw Failure.limitExceeded + } + totalBytes = sum + opened.append((path, components, file.descriptor, file.status)) + } catch PluginWorkspaceFileError.notFound { + // A declared optional source is allowed to be absent. + } + } + + for selector in plan.inputs { + try Task.checkCancellation() + let components = try MacWorkspaceSecurePath.components(of: selector.path) + guard !components.isEmpty else { throw Failure.invalidPlan } + switch selector.kind { + case .file: + guard selector.fileExtension == nil else { throw Failure.invalidPlan } + try add(components) + case .directory: + guard let ext = selector.fileExtension, !ext.isEmpty else { throw Failure.invalidPlan } + do { + let directory = try MacWorkspaceSecurePath.openDirectory(components[...], below: rootFD) + defer { Darwin.close(directory) } + let names = try Self.names(in: directory, maximum: limits.directoryEntries).filter { $0.hasSuffix("." + ext) }.sorted() + guard names.count <= limits.directoryMatches else { throw Failure.limitExceeded } + for name in names { + let normalized = try MacWorkspaceSecurePath.components(of: (components + [name]).joined(separator: "/")) + try add(normalized) + } + } catch PluginWorkspaceFileError.notFound { + // An absent directory contributes no files. + } + } + } + + opened.sort { $0.path < $1.path } + var byID: [String: OpenFile] = [:] + var listed: [File] = [] + for (index, item) in opened.enumerated() { + let file = File(id: "file-\(index)", path: item.path, byteCount: Int(item.status.st_size)) + listed.append(file) + byID[file.id] = OpenFile(file: file, descriptor: item.fd, components: item.components, status: item.status) + } + rootDescriptor = rootFD + maximumChunkBytes = limits.chunkBytes + openFiles = byID + files = listed + committed = true + } + + deinit { close() } + + /// Reads one bounded chunk and refuses a file replaced or modified since the snapshot. + func read(fileID: String, offset: Int, maximumBytes: Int) throws -> Data { + try Task.checkCancellation() + guard offset >= 0, maximumBytes > 0, maximumBytes <= maximumChunkBytes else { throw Failure.invalidRange } + lock.lock() + guard !isClosed else { lock.unlock(); throw Failure.closed } + guard let file = openFiles[fileID] else { lock.unlock(); throw Failure.unknownFile } + let rootFD = dup(rootDescriptor) + guard rootFD >= 0 else { + let error = errno + lock.unlock() + throw MacWorkspaceSecurePath.map(error) + } + let fileFD = dup(file.descriptor) + guard fileFD >= 0 else { + let error = errno + lock.unlock() + Darwin.close(rootFD) + throw MacWorkspaceSecurePath.map(error) + } + lock.unlock() + defer { Darwin.close(rootFD); Darwin.close(fileFD) } + guard offset <= file.file.byteCount else { throw Failure.invalidRange } + try Self.check(file: file, rootFD: rootFD, fileFD: fileFD) + let wanted = min(maximumBytes, file.file.byteCount - offset) + var data = Data(count: wanted) + let count = try data.withUnsafeMutableBytes { bytes -> Int in + if wanted == 0 { return 0 } + while true { + try Task.checkCancellation() + let result = pread(fileFD, bytes.baseAddress, wanted, off_t(offset)) + if result >= 0 { return result } + if errno != EINTR { throw MacWorkspaceSecurePath.map(errno) } + } + } + guard count > 0 || wanted == 0 else { throw Failure.sourceChanged } + data.count = count + try Self.check(file: file, rootFD: rootFD, fileFD: fileFD) + try Task.checkCancellation() + lock.lock() + let closed = isClosed + lock.unlock() + guard !closed else { throw Failure.closed } + return data + } + + /// Rechecks every captured file, including files the provider did not read. + /// The caller must repeat its source checks at the point of applying changes. + func validate() throws { + for file in files { + try Task.checkCancellation() + _ = try read(fileID: file.id, offset: 0, maximumBytes: 1) + } + lock.lock() + let closed = isClosed + lock.unlock() + guard !closed else { throw Failure.closed } + } + + /// A source revision for the preview/apply check, computed off the main + /// actor from every captured file and its relative name. + func contentDigest() throws -> String { + var hash = SHA256() + for file in files { + try Task.checkCancellation() + hash.update(data: Data(file.path.utf8)) + hash.update(data: Data([0])) + var size = UInt64(file.byteCount).bigEndian + withUnsafeBytes(of: &size) { hash.update(bufferPointer: $0) } + var offset = 0 + while offset < file.byteCount { + let data = try read(fileID: file.id, offset: offset, maximumBytes: min(maximumChunkBytes, file.byteCount - offset)) + guard !data.isEmpty else { throw Failure.sourceChanged } + hash.update(data: data) + offset += data.count + } + } + try validate() + return hash.finalize().map { String(format: "%02x", $0) }.joined() + } + + /// Revokes future reads and releases every descriptor; safe to call twice. + func close() { + lock.lock() + guard !isClosed else { lock.unlock(); return } + isClosed = true + let descriptors = openFiles.values.map(\.descriptor) + openFiles.removeAll() + lock.unlock() + descriptors.forEach { Darwin.close($0) } + Darwin.close(rootDescriptor) + } + + private static func check(file: OpenFile, rootFD: Int32, fileFD: Int32) throws { + var status = Darwin.stat() + guard fstat(fileFD, &status) == 0 else { throw MacWorkspaceSecurePath.map(errno) } + guard sameFile(status, file.status) else { throw Failure.sourceChanged } + do { + let parent = try MacWorkspaceSecurePath.openDirectory(file.components.dropLast(), below: rootFD) + defer { Darwin.close(parent) } + let current = try MacWorkspaceSecurePath.status(of: file.components.last!, in: parent) + guard sameFile(current, file.status) else { throw Failure.sourceChanged } + } catch { + throw Failure.sourceChanged + } + } + + private static func sameFile(_ left: Darwin.stat, _ right: Darwin.stat) -> Bool { + left.st_mode & S_IFMT == S_IFREG && left.st_dev == right.st_dev && left.st_ino == right.st_ino + && left.st_size == right.st_size + && left.st_mtimespec.tv_sec == right.st_mtimespec.tv_sec && left.st_mtimespec.tv_nsec == right.st_mtimespec.tv_nsec + && left.st_ctimespec.tv_sec == right.st_ctimespec.tv_sec && left.st_ctimespec.tv_nsec == right.st_ctimespec.tv_nsec + } + + private static func names(in directory: Int32, maximum: Int) throws -> [String] { + let copy = dup(directory) + guard copy >= 0 else { throw MacWorkspaceSecurePath.map(errno) } + guard let stream = fdopendir(copy) else { + let error = errno + Darwin.close(copy) + throw MacWorkspaceSecurePath.map(error) + } + defer { closedir(stream) } + var result: [String] = [] + var scanned = 0 + while true { + errno = 0 + guard let entry = readdir(stream) else { + if errno != 0 { throw MacWorkspaceSecurePath.map(errno) } + return result + } + let capacity = Int(entry.pointee.d_namlen) + 1 + let name = withUnsafePointer(to: &entry.pointee.d_name) { + $0.withMemoryRebound(to: CChar.self, capacity: capacity) { String(validatingCString: $0) } + } + if name == "." || name == ".." { continue } + guard scanned < maximum else { throw Failure.limitExceeded } + scanned += 1 + if scanned % 128 == 0 { try Task.checkCancellation() } + guard let name else { continue } + result.append(name.precomposedStringWithCanonicalMapping) + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportSnapshots.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportSnapshots.swift new file mode 100644 index 0000000000..aea8c4a6b7 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportSnapshots.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Owns short-lived source snapshots for the backend session that requested them. +/// A token grants access only together with the plugin, session and provider IDs. +@MainActor +final class MacKeymapImportSnapshots { + enum Failure: Error { case expired } + + private struct Entry { + let pluginID: String + let sessionID: String + let providerID: String + let snapshot: MacKeymapImportSnapshot + } + + private var entries: [String: Entry] = [:] + + func insert(_ snapshot: MacKeymapImportSnapshot, pluginID: String, sessionID: String, providerID: String) -> String { + let superseded = entries.compactMap { token, entry in + entry.pluginID == pluginID && entry.sessionID == sessionID && entry.providerID == providerID ? token : nil + } + for token in superseded { remove(token) } + let token = UUID().uuidString + entries[token] = Entry(pluginID: pluginID, sessionID: sessionID, providerID: providerID, snapshot: snapshot) + return token + } + + func snapshot(_ token: String, pluginID: String, sessionID: String, providerID: String) throws -> MacKeymapImportSnapshot { + guard let entry = entries[token], entry.pluginID == pluginID, + entry.sessionID == sessionID, entry.providerID == providerID else { throw Failure.expired } + return entry.snapshot + } + + func remove(_ token: String) { + entries.removeValue(forKey: token)?.snapshot.close() + } + + func endSession(pluginID: String, sessionID: String) { + let tokens = entries.compactMap { token, entry in + entry.pluginID == pluginID && entry.sessionID == sessionID ? token : nil + } + for token in tokens { remove(token) } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index 9f3cc6f8f8..a6b9ea08b4 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -36,6 +36,23 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi var errorDescription: String? { message } } + struct KeymapImportResult: Sendable { + let response: ToolingJSONValue + let sourceDigest: String + let sourceFileCount: Int + } + + struct KeymapProvider: Equatable, Sendable { + let pluginID: String + let id: String + let displayName: String + } + + private struct KeymapCandidate: Sendable { + let provider: KeymapProvider + let manifest: Data + } + /// The installed package a backend starts from. struct Package: Equatable { let manifest: Data @@ -70,6 +87,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi private let stuckSendGrace: Duration /// Serves the host requests of every backend. let hostDispatcher: PluginHostServiceDispatcher + private let keymapImportHost = MacKeymapImportHostService() /// Reports a backend that an activation event failed to start; the /// workspace or command goes on without it. var editorActions: PluginEditorActionHostService? @@ -86,6 +104,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi /// A replaced or removed package never starts again in this launch. private var replacedPluginIDs: Set = [] private var running: [String: Running] = [:] + private var activeKeymapImports: [String: Int] = [:] private var contextObserver: UUID? /// One process, from its start until it is dropped. `session` is set as @@ -94,6 +113,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi let settings: [String: ToolingJSONValue] var task: Task? var session: LanguageServerRuntimeSession? + var hostSessionID: String? var ready = false /// What Core granted the session, set when the process launches. var hostServices: Set = [] @@ -143,6 +163,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi dispatcher.register(PluginCredentialHostService(credentials: credentials)) self.hostDispatcher = dispatcher } + self.hostDispatcher.register(keymapImportHost) // Running backends learn of every opened, changed, or closed // context; one that starts later asks `listContexts` itself. contextObserver = self.hostDispatcher.contexts.observeRevision { [weak self] revision in @@ -217,6 +238,72 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi return providers.compactMap { $0["id"] as? String } } + /// Provider IDs already accepted by Core's normalized manifest validation. + static func keymapImportProviderIDs(ofManifest manifest: Data) -> [String] { + keymapProviderDeclarations(ofManifest: manifest).map(\.id) + } + + private struct KeymapProviderDeclaration: Decodable { + let id: String + let displayName: String + } + + private struct KeymapProviderManifest: Decodable { + let keymapImportProviders: [KeymapProviderDeclaration]? + } + + private static func keymapProviderDeclarations(ofManifest manifest: Data) -> [KeymapProviderDeclaration] { + (try? JSONDecoder().decode(KeymapProviderManifest.self, from: manifest).keymapImportProviders) ?? [] + } + + /// A provider-only backend belongs to the import workflow, so it can be + /// stopped as soon as its last import finishes. A multipurpose backend + /// remains shared with its other consumers. + static func isKeymapOnlyBackend(manifest: Data) -> Bool { + guard let object = try? JSONSerialization.jsonObject(with: manifest) as? [String: Any], + !(keymapProviderDeclarations(ofManifest: manifest).isEmpty), + let permissions = object["permissions"] as? [String], + Set(permissions).isSubset(of: ["backend.launch", "keymaps.provide", "keymaps.readSelectedSources"]), + object["ui"] == nil || object["ui"] is NSNull else { return false } + for key in ["activationEvents", "languageServers", "buildSystems", "debugAdapters", "projectGenerators", + "toolchainProviders", "toolDefinitions", "settings", "settingActions", "vcsProviders", + "executionProviders", "languages"] { + if let values = object[key] as? [Any], !values.isEmpty { return false } + } + return true + } + + /// Finds matching providers using their accepted, metadata-only probes. + /// This never launches a backend or reads source file contents. + func matchingKeymapProviders(root: URL) async throws -> [KeymapProvider] { + guard let rustCore = core as? RustCoreBridge else { return [] } + let candidates: [KeymapCandidate] = packages.flatMap { element -> [KeymapCandidate] in + let (pluginID, package) = element + guard !disabledPluginIDs.contains(pluginID), !replacedPluginIDs.contains(pluginID), canStart(pluginID) else { return [] } + return Self.keymapProviderDeclarations(ofManifest: package.manifest).map { + KeymapCandidate(provider: KeymapProvider(pluginID: pluginID, id: $0.id, displayName: $0.displayName), manifest: package.manifest) + } + }.sorted { ($0.provider.pluginID, $0.provider.id) < ($1.provider.pluginID, $1.provider.id) } + let task = Task.detached(priority: .userInitiated) { + let operations = RustKeymapImportOperations(core: rustCore) + var matches: [KeymapCandidate] = [] + for candidate in candidates { + try Task.checkCancellation() + let plan = try operations.planSources(manifest: candidate.manifest, providerID: candidate.provider.id) + if try MacKeymapImportRecognition.matches(root: root, plan: plan) { matches.append(candidate) } + } + return matches + } + let matches = try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } + try Task.checkCancellation() + return matches.compactMap { candidate in + let id = candidate.provider.pluginID + guard packages[id]?.manifest == candidate.manifest, !disabledPluginIDs.contains(id), + !replacedPluginIDs.contains(id), canStart(id) else { return nil } + return candidate.provider + } + } + /// Asks a plugin's provider, starting or reusing its one backend process. /// Returns `{ id, response }` unvalidated; see `toolchains.decodeReply`. func queryToolchainProvider( @@ -229,6 +316,68 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi return try await session.toolchainRequest(params: params, timeout: timeout) } + /// Gives the negotiated importer a temporary, session-owned view of the + /// Core-planned files under the source the user selected. + func importKeymap( + _ providerID: String, selectedRoot: URL, ofPlugin pluginID: String, timeout: TimeInterval + ) async throws -> KeymapImportResult { + guard let package = packages[pluginID], let rustCore = core as? RustCoreBridge else { + throw UnavailableError(message: L10n.string( + "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again." + )) + } + let plan = try RustKeymapImportOperations(core: rustCore).planSources(manifest: package.manifest, providerID: providerID) + let recognition = Task.detached { try MacKeymapImportRecognition.matches(root: selectedRoot, plan: plan) } + let matches = try await withTaskCancellationHandler { try await recognition.value } onCancel: { recognition.cancel() } + guard matches else { + throw UnavailableError(message: L10n.string( + "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again." + )) + } + // Count callers before awaiting startup: cancellation or failed + // negotiation must also release an import-only backend, while another + // concurrent importer may still be waiting for that same startup. + activeKeymapImports[pluginID, default: 0] += 1 + defer { + let remaining = (activeKeymapImports[pluginID] ?? 1) - 1 + if remaining == 0 { + activeKeymapImports[pluginID] = nil + if packages[pluginID] == package, Self.isKeymapOnlyBackend(manifest: package.manifest) { + stop(pluginID) + } + } else { + activeKeymapImports[pluginID] = remaining + } + } + let session = try await session(for: pluginID) + guard session.keymapImportProviders.contains(providerID), packages[pluginID] == package, + let entry = running[pluginID], let hostSessionID = entry.hostSessionID else { + throw UnavailableError(message: L10n.string( + "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again." + )) + } + let task = Task.detached { try MacKeymapImportSnapshot(root: selectedRoot, plan: plan) } + let snapshot = try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } + guard !Task.isCancelled, running[pluginID] === entry, entry.session === session, + entry.hostSessionID == hostSessionID else { + snapshot.close() + throw CancellationError() + } + let token = keymapImportHost.snapshots.insert(snapshot, pluginID: pluginID, sessionID: hostSessionID, providerID: providerID) + defer { keymapImportHost.snapshots.remove(token) } + let reply = try await session.keymapImportRequest(providerID: providerID, snapshotID: token, timeout: timeout) + let validation = Task.detached { + guard try MacKeymapImportRecognition.matches(root: selectedRoot, plan: plan) else { + throw MacKeymapImportSnapshot.Failure.sourceChanged + } + return try snapshot.contentDigest() + } + let digest = try await withTaskCancellationHandler { try await validation.value } onCancel: { validation.cancel() } + try Task.checkCancellation() + _ = try keymapImportHost.snapshots.snapshot(token, pluginID: pluginID, sessionID: hostSessionID, providerID: providerID) + return KeymapImportResult(response: reply, sourceDigest: digest, sourceFileCount: snapshot.files.count) + } + /// Plugins whose backend starts on `event`, such as `onWorkspaceOpen` /// or `onCommand:`. `owner` limits a command to the plugin whose UI /// module declares it. @@ -502,7 +651,8 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi moduleID: ModuleID("\(pluginID).ui"), pluginID: pluginID, role: .backend, hostServices: hostServices, - toolchainProviders: packages[pluginID].map { Self.toolchainProviderIDs(ofManifest: $0.manifest) } ?? [] + toolchainProviders: packages[pluginID].map { Self.toolchainProviderIDs(ofManifest: $0.manifest) } ?? [], + keymapImportProviders: packages[pluginID].map { Self.keymapImportProviderIDs(ofManifest: $0.manifest) } ?? [] ) let endBackendProcesses = endBackendProcesses let hostDispatcher = hostDispatcher @@ -520,6 +670,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi // A new process starts with no subscriptions and nothing in flight. hostDispatcher.endSession(pluginID: pluginID) let sessionToken = hostDispatcher.beginSession(pluginID: pluginID, hostServices: hostServices) + entry.hostSessionID = sessionToken entry.hostServices = Set(hostServices) session.onFunctionsChange = { [weak self] ids in self?.editorActions?.updateFunctions(pluginID: pluginID, sessionID: sessionToken, ids: ids) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift index e26e4a18be..2056c77087 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift @@ -308,7 +308,7 @@ final class MacPluginManager: PluginManaging { throw PluginManagerError.unknownPlugin(pluginID) } beforePackageReplacement(pluginID) - if OfficialPluginCatalog.entry(for: pluginID)?.bundledSignatureRequirement == .sameTeamAsHost { + if OfficialPluginCatalog.trustPolicy(for: pluginID)?.bundledSignatureRequirement == .sameTeamAsHost { try packageStore.setBundledPluginRemoved(pluginID, removed: true) } try packageStore.stageInvalidPackageUninstall(pluginID) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index 374c8ab892..4d9a05a11a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -921,7 +921,7 @@ final class MacPluginPackageStore { } private func validateOfficialTrustPolicy(_ manifest: PluginManifest) throws { - guard let trusted = OfficialPluginCatalog.entry(for: manifest.id) else { return } + guard let trusted = OfficialPluginCatalog.trustPolicy(for: manifest.id) else { return } guard manifest.vendor.signatureRequirement == trusted.signatureRequirement, manifest.vendor.bundledSignatureRequirement == trusted.bundledSignatureRequirement else { throw PluginPackageStoreError.officialSignatureRequirementMismatch(manifest.id) @@ -1021,7 +1021,7 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { /// The host's trust policy wins over the package's own declaration, so an /// official plugin cannot downgrade the signature it must carry. static func signatureRequirement(for manifest: PluginManifest) -> PluginSignatureRequirement { - OfficialPluginCatalog.entry(for: manifest.id)?.signatureRequirement + OfficialPluginCatalog.trustPolicy(for: manifest.id)?.signatureRequirement ?? manifest.vendor.signatureRequirement } @@ -1050,7 +1050,7 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { if manifest.vendor.signatureRequirement == .unsigned || origin == .community { guard manifest.vendor.signatureRequirement == .unsigned, origin == .community, - OfficialPluginCatalog.entry(for: manifest.id) == nil else { + OfficialPluginCatalog.trustPolicy(for: manifest.id) == nil else { throw PluginPackageStoreError.unsignedPackageNotAllowed(manifest.id) } if Self.hasPackageSignature(at: packageURL) { @@ -1075,7 +1075,7 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { let usesHostSeal = origin == .bundled && packagePath.hasPrefix(hostBundlePath) && manifest.vendor.bundledSignatureRequirement == .sameTeamAsHost - && OfficialPluginCatalog.entry(for: manifest.id)?.bundledSignatureRequirement == .sameTeamAsHost + && OfficialPluginCatalog.trustPolicy(for: manifest.id)?.bundledSignatureRequirement == .sameTeamAsHost if (signatureRequirement == .publisherPackage && !usesHostSeal) || Self.hasPackageSignature(at: packageURL) { try verifyPublisherPackageSignature(packageAt: packageURL, manifest: manifest) } diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift index 3eba92c3b6..df63e73a36 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift @@ -149,17 +149,16 @@ enum MacShortcutInputPolicy { (responder as? NSTextInputClient)?.hasMarkedText() == true } - /// Terminal surfaces own plain Control chords. Readline, shells, and TUI - /// applications bind them to editing (Ctrl-D EOF, Ctrl-R history, Ctrl-W - /// delete-word), so the application keymap must not consume them. Chords - /// that add Command, Shift, or Option keep their application meaning. + /// Shells and TUIs own plain Control chords, except the configured terminal + /// toggle itself: the user must be able to leave the focused terminal. static func shouldDeferToTerminal( _ responder: NSResponder?, - modifierFlags: NSEvent.ModifierFlags + modifierFlags: NSEvent.ModifierFlags, + commandID: String? = nil ) -> Bool { guard responder is LitheTerminalView else { return false } let flags = modifierFlags.intersection([.command, .control, .option, .shift]) - return flags == .control + return flags == .control && commandID != "toggle-terminal" } /// A focused HTML preview page answers its own browser shortcuts (find, zoom, @@ -222,14 +221,6 @@ private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { if MacShortcutInputPolicy.shouldDeferToMarkedText(event.window?.firstResponder) { return event } - // A focused terminal receives Control chords untouched; otherwise - // Ctrl-D would start a debug session instead of ending input. - if MacShortcutInputPolicy.shouldDeferToTerminal( - event.window?.firstResponder, - modifierFlags: event.modifierFlags - ) { - return event - } // A focused HTML preview page owns its find, zoom and print // chords; the keymap would otherwise take ⌘F before the page. // Note: 预览页面快捷键的归属见 .agents/notes/implemented/feature/2026-10-22-html-preview-embedded-browser.md @@ -252,6 +243,15 @@ private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { ) else { return event } + // Preserve shell input such as Ctrl-D even when another application + // action is registered, while allowing the configured terminal toggle. + if MacShortcutInputPolicy.shouldDeferToTerminal( + event.window?.firstResponder, + modifierFlags: event.modifierFlags, + commandID: commandID + ) { + return event + } Task { @MainActor in self.onCommand(commandID) } diff --git a/macos/Sources/Lithe/Views/App/EditorSettingsImportView.swift b/macos/Sources/Lithe/Views/App/EditorSettingsImportView.swift index 4d7594b395..ab6866028f 100644 --- a/macos/Sources/Lithe/Views/App/EditorSettingsImportView.swift +++ b/macos/Sources/Lithe/Views/App/EditorSettingsImportView.swift @@ -58,10 +58,16 @@ struct EditorSettingsImportStartup: View { struct EditorSettingsImportView: View { @ObservedObject var feature: EditorSettingsImportFeatureModel + @State private var operation: Task? var startup = false let onDismiss: () -> Void private var language: AppLanguage { feature.settings.language } + private func start(_ work: @escaping @MainActor () async -> Void) { + operation?.cancel() + operation = Task { await work() } + } + private func display(_ value: String, field: EditorSettingsImportField) -> String { let title: String switch field { @@ -104,7 +110,7 @@ struct EditorSettingsImportView: View { HStack { Spacer() Button { - Task { await feature.scan(directory: feature.isChoosingSettings ? feature.source?.directory : nil) } + start { await feature.scan(directory: feature.isChoosingSettings ? feature.source?.directory : nil) } } label: { Text("Scan again").font(LitheTheme.uiFont(size: 11)) } @@ -123,6 +129,7 @@ struct EditorSettingsImportView: View { feature.backToSources() if !startup, feature.sources.isEmpty { await feature.scan() } } + .onDisappear { operation?.cancel(); operation = nil } } private var sourcePage: some View { @@ -134,7 +141,7 @@ struct EditorSettingsImportView: View { ScrollView { VStack(spacing: 8) { ForEach(feature.sources) { source in - Button { feature.chooseSource(source.id) } label: { + Button { start { await feature.chooseSource(source.id) } } label: { HStack(spacing: 8) { LitheIDEAIcon(resourcePath: source.isCursor ? "importSettings/cursor.svg" : source.isVSCode ? "importSettings/vscode.svg" : "importSettings/idea.svg", size: 20, preservesOriginalColors: true) @@ -306,13 +313,13 @@ struct EditorSettingsImportView: View { Button("Close") { onDismiss() }.buttonStyle(LitheActionButtonStyle()) if feature.isChoosingSettings, feature.source?.values.isEmpty == false { Button("Import selected") { - Task { if await feature.apply(), startup { onDismiss() } } + start { if await feature.apply(), startup { onDismiss() } } } .buttonStyle(LitheActionButtonStyle(isPrimary: true)) .accessibilityIdentifier("editor-import-apply") .disabled(feature.source?.readFailed == true || feature.selected.isEmpty || feature.settings.editorImportRecoveryFailed) } else if !feature.isChoosingSettings { - Button("Choose directory") { Task { await feature.chooseDirectory() } }.buttonStyle(LitheActionButtonStyle()) + Button("Choose directory") { start { await feature.chooseDirectory() } }.buttonStyle(LitheActionButtonStyle()) } } .disabled(feature.isBusy) diff --git a/macos/Sources/Lithe/Views/App/PluginManagementView.swift b/macos/Sources/Lithe/Views/App/PluginManagementView.swift index 1b789631f8..9ef4a7e583 100644 --- a/macos/Sources/Lithe/Views/App/PluginManagementView.swift +++ b/macos/Sources/Lithe/Views/App/PluginManagementView.swift @@ -485,7 +485,7 @@ private struct InstalledPluginsView: View { let isEnabled = effectiveEnabledState(for: plugin) // Built-in feature groups are listed as bundled too, but only an // official plugin package can be removed. - let canUninstall = plugin.origin != .bundled || OfficialPluginCatalog.entry(for: plugin.id) != nil + let canUninstall = plugin.origin != .bundled || OfficialPluginCatalog.trustPolicy(for: plugin.id) != nil return PluginDetailActions( toggleEnabled: { stageEnabledState(!isEnabled, for: plugin) }, update: update.map { update in { performPackageAction { await feature.install(update) } } }, @@ -664,7 +664,7 @@ struct PluginManagementListContent { /// plugin, so only official plugins and installed packages are listed. init(plugins: [PluginManagementSnapshot]) { self.plugins = plugins.filter { - OfficialPluginCatalog.entry(for: $0.id) != nil || $0.origin != .bundled + OfficialPluginCatalog.trustPolicy(for: $0.id) != nil || $0.origin != .bundled } } diff --git a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift index ead5e6b746..e2968dc096 100644 --- a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift +++ b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift @@ -769,6 +769,10 @@ enum PluginMarketplacePresentation { L10n.string("Add clickable actions beside document text") case "terminal.create": L10n.string("Open and manage its own native terminals, retaining bounded display history for reopening") + case "keymaps.provide": + L10n.string("Offer shortcut mappings for import") + case "keymaps.readSelectedSources": + L10n.string("Read declared shortcut files from the editor you select for import") case "process.run": PluginMarketplacePresentation.localized("Run your files", locale: locale) case "ui.contribute": diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 3741c553e4..0e330a7be1 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -509,6 +509,29 @@ package protocol LanguageServerRuntimeCore: Sendable { params: ToolingJSONValue, timeout: TimeInterval ) -> Result + /// Sends an import request with an opaque snapshot token to a negotiated provider. + func requestKeymapImportProvider( + sessionID: String, + providerID: String, + snapshotID: String, + timeout: TimeInterval + ) -> Result + /// Starts a plugin backend offering its shortcut and optional toolchain providers. + func startPluginKeymapImportBackend( + pluginID: String, + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + initializationOptions: ToolingJSONValue?, + hostServices: [String], + toolchainProviders: [String], + keymapImportProviders: [String], + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result /// Starts a plugin backend and offers it the manifest's toolchain providers. func startPluginToolchainBackend( pluginID: String, @@ -779,6 +802,25 @@ package extension LanguageServerRuntimeCore { )) } + func requestKeymapImportProvider( + sessionID _: String, providerID _: String, snapshotID _: String, timeout _: TimeInterval + ) -> Result { + .failure(LanguageServerRuntimeFailure(code: "not_supported", message: L10n.string( + "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again." + ))) + } + + func startPluginKeymapImportBackend( + pluginID _: String, providerID _: String, executableURL _: URL, arguments _: [String], + environment _: [String: String], rootURL _: URL, initializationOptions _: ToolingJSONValue?, + hostServices _: [String], toolchainProviders _: [String], keymapImportProviders _: [String], + initializeTimeout _: TimeInterval, requestTimeout _: TimeInterval, shutdownTimeout _: TimeInterval + ) -> Result { + .failure(LanguageServerRuntimeFailure(code: "not_supported", message: L10n.string( + "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again." + ))) + } + /// Runtimes without the protocol start the backend without the offer, so /// it negotiates no provider and receives no query. func startPluginToolchainBackend( diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 9cc5632c47..f54c6d83fe 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -57,6 +57,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private let hostServices: [String] /// Manifest providers this backend is offered during `initialize`. private let offeredToolchainProviders: [String] + private let offeredKeymapImportProviders: [String] private var sessionID: String? private var startupGeneration: UUID? @@ -140,6 +141,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { package private(set) var vcsNegotiation: VcsProviderNegotiation? /// Providers the backend selected, from the `toolchainProviders` event. package private(set) var toolchainProviders: [String] = [] + /// Shortcut import providers selected by this backend during initialization. + package private(set) var keymapImportProviders: [String] = [] package var onServerInfoChange: ((LanguageServerInfo?) -> Void)? package var javaTestRunnerURL: URL? { hostIntegrationLaunch?.coreLaunch?.jdtlsLaunchResources?.javaTestRunnerURL @@ -165,7 +168,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { role: LanguageServerLogRole = .languageServer, logSubject: String? = nil, hostServices: [String] = [], - toolchainProviders: [String] = [] + toolchainProviders: [String] = [], + keymapImportProviders: [String] = [] ) { self.providerID = providerID self.executableURL = executableURL @@ -186,6 +190,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { self.pluginID = pluginID self.hostServices = hostServices self.offeredToolchainProviders = toolchainProviders + self.offeredKeymapImportProviders = keymapImportProviders } /// Derived from the last lifecycle state Rust published: there is no local @@ -299,6 +304,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { let pluginID = pluginID let hostServices = hostServices let offeredToolchainProviders = offeredToolchainProviders + let offeredKeymapImportProviders = offeredKeymapImportProviders let providerID = providerID let executableURL = executableURL let environment = environment @@ -311,7 +317,15 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { let resolvedOptions = initializationOptions let resolvedConfiguration = workspaceConfiguration let launch: @Sendable () -> Result = { - if let pluginID, !offeredToolchainProviders.isEmpty { + if let pluginID, !offeredKeymapImportProviders.isEmpty { + core.startPluginKeymapImportBackend( + pluginID: pluginID, providerID: providerID, executableURL: executableURL, + arguments: resolvedArguments, environment: environment, rootURL: normalizedRoot, + initializationOptions: resolvedOptions, hostServices: hostServices, + toolchainProviders: offeredToolchainProviders, keymapImportProviders: offeredKeymapImportProviders, + initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout + ) + } else if let pluginID, !offeredToolchainProviders.isEmpty { core.startPluginToolchainBackend( pluginID: pluginID, providerID: providerID, @@ -973,6 +987,38 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } } + /// Requests an import from this session; cancellation reaches the backend. + package func keymapImportRequest(providerID: String, snapshotID: String, timeout: TimeInterval) async throws -> ToolingJSONValue { + guard let sessionID, state == .ready else { + throw LanguageServerRuntimeSessionError.notReady + } + let started: LanguageServerRuntimeOperation + switch await send(timeout: timeout, { core in + core.requestKeymapImportProvider(sessionID: sessionID, providerID: providerID, snapshotID: snapshotID, timeout: timeout) + }) { + case .success(let operation): started = operation + case .failure(let error): + sendsInFlight -= 1 + try Task.checkCancellation() + throw LanguageServerRuntimeSessionError.requestRejected(error.userMessage) + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + registerPendingOperation(started, name: "lithe/keymap/import") { result in + continuation.resume(with: result.flatMap { event in + guard case .object(let object)? = event.result, let value = object["value"] else { + return .failure(LanguageServerRuntimeSessionError.missingResult) + } + return .success(value) + }) + } + sendsInFlight -= 1 + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancelOffMain(started.operationID) } + } + } + /// Invokes a plugin function of the plugin that owns this session. Core /// checks ownership and registration; a refusal or a failed call throws /// `PluginFunctionFailure`, and cancelling the task cancels the call. @@ -1573,6 +1619,13 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } } return false + case "keymapImportProviders": + if case .object(let object)? = event.result, case .array(let providers)? = object["providers"] { + keymapImportProviders = providers.compactMap { + if case .string(let provider) = $0 { provider } else { nil } + } + } + return false case "featuresChanged": updateFeatures(capabilityNames: event.capabilities ?? []) return false @@ -1676,6 +1729,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } vcsNegotiation = nil toolchainProviders = [] + keymapImportProviders = [] let released = onReleased onReleased = nil released?() diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 95eee7b464..ce2182b99d 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -201,6 +201,11 @@ public enum BuiltInPluginCatalog { /// of its language and process declarations. Retired official plugins stay /// listed so installed copies keep that pinned trust policy. public enum OfficialPluginCatalog { + /// Host-pinned trust for an official package, including non-language plugins. + public struct TrustPolicy: Equatable, Sendable { + public let signatureRequirement: PluginSignatureRequirement + public let bundledSignatureRequirement: PluginSignatureRequirement? + } public enum Distribution: Equatable, Sendable { /// Shipped inside the application bundle and sealed by its signature. case bundled @@ -237,6 +242,7 @@ public enum OfficialPluginCatalog { public static let phpPluginID = PluginID("dev.lithe.plugin.php-support") public static let goPluginID = PluginID("dev.lithe.plugin.go-support") public static let javaPluginID = PluginID("dev.lithe.plugin.java-support") + public static let editorKeymapImportPluginID = PluginID("dev.lithe.plugin.editor-keymap-import") public static let entries: [Entry] = [ Entry( @@ -276,6 +282,19 @@ public enum OfficialPluginCatalog { entries.first { $0.id == id } } + /// Language suggestions use `entries`; other official packages still need + /// an explicit host trust policy without pretending to own a language. + public static func trustPolicy(for id: PluginID) -> TrustPolicy? { + if let entry = entry(for: id) { + return TrustPolicy(signatureRequirement: entry.signatureRequirement, + bundledSignatureRequirement: entry.bundledSignatureRequirement) + } + if id == editorKeymapImportPluginID { + return TrustPolicy(signatureRequirement: .publisherPackage, bundledSignatureRequirement: .sameTeamAsHost) + } + return nil + } + /// Entries still published as official plugins, which the host may /// suggest and whose languages it ties to the official package. public static var publishedEntries: [Entry] { diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 2f4e4af16d..e21b398e67 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -519,6 +519,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// level 3 plugin that requires anything else. Validating a capability is /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ + "keymapImportProviders": [1], "packageTrust": [1], "backend": [1, 2], "buildProviders": [1, 2, 3, 4, 5, 6], "buildModelInputs": [1], diff --git a/macos/Tests/LitheTests/EditorSettingsImportTests.swift b/macos/Tests/LitheTests/EditorSettingsImportTests.swift index 695ca5c8ef..1e9ad44788 100644 --- a/macos/Tests/LitheTests/EditorSettingsImportTests.swift +++ b/macos/Tests/LitheTests/EditorSettingsImportTests.swift @@ -39,41 +39,6 @@ struct EditorSettingsImportTests { #expect(AppSettings(store: store).editorAppearance.tabSize == 3) } - @Test(arguments: [false, true]) - func laterUnsupportedKeybindingShadowsEarlierUserMapping(includeDefaults: Bool) throws { - let supported = #"{"key":"ctrl+alt+cmd+b","command":"workbench.action.navigateBack"}"# - for later in [ - #"{"key":"ctrl+alt+cmd+b","command":"extension.other"}"#, - #"{"key":"ctrl+alt+cmd+b","command":"workbench.action.navigateForward","when":"editorTextFocus"}"#, - #"{"key":"ctrl+alt+cmd+b","command":"extension.other","when":"editorTextFocus"}"#, - ] { - let parsed = try MacImportedKeybindings.parseVSCode(Data("[\(supported),\(later)]".utf8), includeDefaults: includeDefaults) - #expect(parsed["navigate-back"] == nil) - } - let reversed = try MacImportedKeybindings.parseVSCode(Data("[{\"key\":\"ctrl+alt+cmd+b\",\"command\":\"extension.other\"},\(supported)]".utf8), includeDefaults: includeDefaults) - #expect(reversed["navigate-back"] == [.keyPress(key: "b", modifiers: [.control, .option, .command])]) - } - - @Test(arguments: [false, true]) - func laterChordShadowsItsFirstKeyWithoutBecomingASingleKeyBinding(includeDefaults: Bool) throws { - let supported = #"{"key":"ctrl+alt+cmd+b","command":"workbench.action.navigateBack"}"# - for chord in [ - #"{"key":"ctrl+alt+cmd+b ctrl+c","command":"extension.other"}"#, - #"{"key":"ctrl+alt+cmd+b ctrl+c","command":"extension.other","when":"editorTextFocus"}"#, - #"{"key":"ctrl+alt+cmd+b ctrl+c","command":"workbench.action.navigateForward"}"#, - ] { - let parsed = try MacImportedKeybindings.parseVSCode(Data("[\(supported),\(chord)]".utf8), includeDefaults: includeDefaults) - #expect(parsed["navigate-back"] == nil) - #expect(!parsed.values.flatMap { $0 }.contains(.keyPress(key: "c", modifiers: .control))) - if chord.contains("workbench.action.navigateForward") { #expect(parsed["navigate-forward"] == nil) } - let reversed = try MacImportedKeybindings.parseVSCode(Data("[\(chord),\(supported)]".utf8), includeDefaults: includeDefaults) - #expect(reversed["navigate-back"] == [.keyPress(key: "b", modifiers: [.control, .option, .command])]) - } - let defaults = try MacImportedKeybindings.parseVSCode(Data(#"[{"key":"ctrl+- ctrl+c","command":"extension.other"}]"#.utf8), includeDefaults: includeDefaults) - #expect(defaults["navigate-back"] == nil) - #expect((defaults["settings"] != nil) == includeDefaults) - } - @Test @MainActor func vscodeDefaultsOverridesAndShadowedKeys() throws { let defaults = try MacEditorSettingsImportReader.parseVSCode(Data("{}".utf8), fonts: [], includeAppearanceDefaults: true).0 #expect(defaults["tabSize"] == "4" && defaults["insertSpaces"] == "true") @@ -82,9 +47,6 @@ struct EditorSettingsImportTests { let explicit = try MacEditorSettingsImportReader.parseVSCode(Data(#"{"editor.tabSize":2,"editor.insertSpaces":false,"editor.wordWrap":"on","editor.minimap.enabled":null}"#.utf8), fonts: [], includeAppearanceDefaults: true).0 #expect(explicit["tabSize"] == "2" && explicit["insertSpaces"] == "false") #expect(explicit["softWrap"] == "true" && explicit["minimap"] == nil) - let keys = try MacImportedKeybindings.parseVSCode(Data(#"[{"key":"cmd+s","command":"extension.save"},{"key":"cmd+shift+s","command":"workbench.action.files.save","when":"editorTextFocus"},{"key":"cmd+g","command":"-workbench.action.gotoLine"}]"#.utf8), includeDefaults: true) - #expect(keys["save"] == nil && keys["go-to-line"] == nil) - #expect(keys["settings"] != nil) let palette = try MacEditorSettingsImportReader.parseVSCode(Data(##"{"editor.tokenColorCustomizations":{"comments":"#aa00ff"},"workbench.colorCustomizations":{"editor.background":"#121212"}}"##.utf8), fonts: [], includeAppearanceDefaults: true).0 let theme = try #require(palette["customTheme"].flatMap(ImportedEditorTheme.decode)) #expect(theme.tokens["comment"]?.foreground == "#aa00ff") @@ -212,13 +174,13 @@ struct EditorSettingsImportTests { }) active = feature await feature.scan() - feature.chooseSource(source.id) + await feature.chooseSource(source.id) feature.selected = ["fontSize"] #expect(await feature.apply()) #expect(feature.importResult?.applied == 1 && feature.importResult?.unselected == 2) #expect(!feature.isImporting && !feature.isBusy) #expect(settings.themePreference == .dark && !settings.editorSoftWrapEnabled) - feature.chooseSource(source.id) + await feature.chooseSource(source.id) #expect(feature.importResult == nil) #expect(await feature.apply()) #expect(feature.importResult?.applied == 2 && feature.importResult?.unselected == 0) @@ -276,7 +238,7 @@ struct EditorSettingsImportTests { if let directory = ProcessInfo.processInfo.environment["LITHE_POPUP_CAPTURE_DIR"] { let view = try #require(parent.contentView) for page in ["sources", "settings"] { - if page == "settings" { feature.chooseSource(source.id) } + if page == "settings" { await feature.chooseSource(source.id) } view.layoutSubtreeIfNeeded() let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) @@ -320,14 +282,14 @@ struct EditorSettingsImportTests { let feature = EditorSettingsImportFeatureModel(settings: settings, chooseDirectory: { nil }, scan: { _ in [source] }) await feature.scan() #expect(!feature.isChoosingSettings) - feature.chooseSource("missing") + await feature.chooseSource("missing") #expect(!feature.isChoosingSettings) - feature.chooseSource(source.id) + await feature.chooseSource(source.id) #expect(feature.isChoosingSettings && feature.selected.contains("fontSize")) #expect(settings.editorFontSize == 13) feature.backToSources() #expect(!feature.isChoosingSettings && settings.editorFontSize == 13) - feature.chooseSource(source.id) + await feature.chooseSource(source.id) let applied = await feature.apply() #expect(applied && settings.editorFontSize == 16) } @@ -461,7 +423,7 @@ struct EditorSettingsImportTests { let skipFrame = try #require(skip.accessibilityFrame?()) let scanFrame = try #require(scan.accessibilityFrame?()) #expect(scanFrame.minY > skipFrame.maxY && scanFrame.minX > skipFrame.maxX) - feature.chooseSource(current.id) + await feature.chooseSource(current.id) _ = try #require(await button("editor-import-back")) #expect(!elements(host).contains { $0.accessibilityIdentifier?() == "editor-import-apply" }) if let directory = ProcessInfo.processInfo.environment["LITHE_POPUP_CAPTURE_DIR"] { @@ -504,7 +466,7 @@ struct EditorSettingsImportTests { #expect(settings.didOfferEditorSettingsImport == startup) #expect(!settings.canUndoEditorSettingsImport) // The same native controls also complete a real isolated import and undo. - feature.chooseSource(current.id) + await feature.chooseSource(current.id) let apply = try #require(await button("editor-import-apply")) #expect(apply.accessibilityPerformPress?() == true) _ = try #require(await button("editor-import-result")) @@ -676,7 +638,7 @@ struct EditorSettingsImportTests { #expect(feature.sources.map(\.title) == ["VS Code", "Cursor"]) for source in feature.sources { let categories = Set(EditorSettingsImportField.allCases.filter { source.values[$0.rawValue] != nil }.map(\.category)) - #expect(categories == Set(["appearance", "keymap", "code", "preferences"])) + #expect(categories == Set(["appearance", "code", "preferences"])) } #expect(feature.sources.allSatisfy { $0.settingsFileMissing && !$0.readFailed && !$0.values.isEmpty && !$0.defaultFields.isEmpty }) #expect(feature.sourceStatusMessage == nil) @@ -823,22 +785,7 @@ struct EditorSettingsImportTests { #expect(AppSettings(store: store).editorFontSize == 13) #expect(store.data(forKey: "settings.editorImportJournal.v1") == nil) } - @Test func explicitKeymapsAndGlobalCodeSettings() throws { - let keys = try MacImportedKeybindings.parseVSCode(Data(#""" - [ // Conditions and duplicate/removal rules must never become unconditional bindings. - {"key":"cmd+alt+s","command":"workbench.action.files.save"}, - {"key":"cmd+f","command":"workbench.action.findInFiles","when":"editorTextFocus"}, - {"key":"cmd+k cmd+s","command":"workbench.action.openSettings"}, - {"key":"cmd+b","command":"workbench.action.navigateBack"}, - {"key":"cmd+b","command":"-workbench.action.navigateBack"}, - ] - """#.utf8)) - #expect(Set(keys.keys) == ["save"]) - #expect(keys["save"] == [.keyPress(key: "s", modifiers: [.command, .option])]) - #expect(MacImportedKeybindings.binding("cmd+delete", idea: false) == nil) - #expect(MacImportedKeybindings.binding("meta BACK_SPACE", idea: true) == .keyPress(key: "delete", modifiers: .command)) - #expect(MacImportedKeybindings.vscode.values.allSatisfy { LitheCommandCatalog.command(id: $0) != nil }) - #expect(MacImportedKeybindings.idea.values.allSatisfy { LitheCommandCatalog.command(id: $0) != nil }) + @Test func globalCodeSettings() throws { let code = try MacEditorSettingsImportReader.parseVSCode(Data(#"{"editor.tabSize":2,"editor.insertSpaces":false,"editor.detectIndentation":false}"#.utf8), fonts: []) #expect(code.0["tabSize"] == "2" && code.0["insertSpaces"] == "false" && code.0["detectIndentation"] == "false") let distinctIndent = try MacEditorSettingsImportReader.parseVSCode(Data(#"{"editor.tabSize":4,"editor.indentSize":2}"#.utf8), fonts: []) @@ -846,17 +793,81 @@ struct EditorSettingsImportTests { let invalid = try MacEditorSettingsImportReader.parseVSCode(Data(#"{"editor.tabSize":true,"editor.insertSpaces":1,"editor.detectIndentation":"true"}"#.utf8), fonts: []) #expect(invalid.0["tabSize"] == nil && invalid.0["insertSpaces"] == nil && invalid.0["detectIndentation"] == nil) let files = [ - "options/keymap.xml": "", - "keymaps/custom.xml": "", "options/code.style.schemes.xml": "", "codestyles/custom.xml": "" ].mapValues { Data($0.utf8) } let idea = try MacEditorSettingsImportReader.parseIDEA(files, fonts: []) - let map = try #require(idea.0["keymap"].flatMap(EditorImportKeymap.decode)) - #expect(Set(map.commands.keys) == ["navigate-back"]) #expect(idea.0["tabSize"] == "2" && idea.0["insertSpaces"] == "false" && idea.0["detectIndentation"] == "false") } + @Test @MainActor func selectingSourceLoadsShortcutsAndApplyRechecksTheSource() async throws { + let settings = AppSettings(store: ImportTestStore()) + let map = EditorImportKeymap(preset: "", commands: ["toggle-breakpoint": [.keyPress(key: "f9", modifiers: [])]]) + var source = EditorSettingsImportSource(directory: URL(fileURLWithPath: "/fixture"), title: "Fixture", revision: "initial", + values: ["tabSize": "2"], warnings: []) + var reads = 0 + let feature = EditorSettingsImportFeatureModel(settings: settings, chooseDirectory: { nil }, scan: { directory in + if directory != nil { reads += 1; source.values["keymap"] = map.encoded; source.revision = "keymap-revision" } + return [source] + }, loadKeymapOnSelection: true) + await feature.scan() + #expect(reads == 0 && feature.source?.values["keymap"] == nil) + await feature.chooseSource(source.id) + #expect(reads == 1 && feature.source?.values["keymap"] != nil) + #expect(settings.keyboardShortcutOverrides.isEmpty) + feature.selected = ["tabSize"] + #expect(await feature.apply()) + #expect(reads == 2) + } + + @Test @MainActor func cancelledSourceSelectionDiscardsLateResults() async throws { + let settings = AppSettings(store: ImportTestStore()) + let source = EditorSettingsImportSource(directory: URL(fileURLWithPath: "/fixture"), title: "Fixture", revision: "before", + values: ["tabSize": "2"], warnings: []) + let entered = TestGate(), release = TestGate() + defer { release.open() } + let feature = EditorSettingsImportFeatureModel(settings: settings, chooseDirectory: { nil }, scan: { directory in + guard directory != nil else { return [source] } + entered.open() + _ = await release.waitUntilOpen(timeout: .seconds(5)) + var late = source + late.revision = "late" + return [late] + }, loadKeymapOnSelection: true) + await feature.scan() + let task = Task { await feature.chooseSource(source.id) } + try #require(await entered.waitUntilOpen()) + task.cancel() + await task.value + #expect(!feature.isBusy && feature.source?.revision == "before") + #expect(feature.message == nil && settings.editorImportValues()["tabSize"] != "2") + } + + @Test @MainActor func importedBindingDisplacesOnlyDefaultsAndPreservesUserOverrides() async throws { + let store = ImportTestStore(), settings = AppSettings(store: store) + let incoming = EditorImportKeymap(preset: "", commands: ["toggle-breakpoint": [.keyPress(key: "f9", modifiers: [])]]) + let source = EditorSettingsImportSource(directory: URL(fileURLWithPath: "/fixture"), title: "Fixture", revision: "1", + values: ["keymap": incoming.encoded], warnings: []) + let core = RustKeymapImportOperations(core: RustCoreBridge()) + let feature = EditorSettingsImportFeatureModel(settings: settings, chooseDirectory: { nil }, scan: { _ in [source] }, + planKeymap: { try core.planBindings(current: $0, incoming: $1) }) + await feature.scan() + #expect(feature.keymapConflict == nil) + #expect(await feature.apply()) + #expect(settings.keyboardShortcutOverrides["toggle-breakpoint"] == [.keyPress(key: "f9", modifiers: [])]) + #expect(settings.keyboardShortcutOverrides["debug-resume"] == []) + try settings.undoEditorSettingsImport() + #expect(settings.keyboardShortcutOverrides.isEmpty) + + settings.setKeyboardShortcutOverrides(["debug-resume": [.keyPress(key: "f9", modifiers: [])]]) + let custom = EditorSettingsImportFeatureModel(settings: settings, chooseDirectory: { nil }, scan: { _ in [source] }, + planKeymap: { try core.planBindings(current: $0, incoming: $1) }) + await custom.scan() + #expect(custom.keymapConflict != nil) + #expect(await custom.apply() == false) + #expect(settings.keyboardShortcutOverrides["debug-resume"] == [.keyPress(key: "f9", modifiers: [])]) + } + @Test @MainActor func keymapAndIndentImportMergePersistUndoAndConflict() async throws { let store = ImportTestStore(), settings = AppSettings(store: ImportTestStore()) // Use a separate persisted store for the merge/undo transaction. @@ -890,60 +901,6 @@ struct EditorSettingsImportTests { #expect(restored.editorImportValues() == before) } - @Test @MainActor func keymapOnlyDirectoryIsReadOnlyAndDetectsSourceChanges() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let file = directory.appendingPathComponent("keybindings.json") - let data = Data(#"[{"key":"ctrl+alt+cmd+b","command":"workbench.action.navigateBack"}]"#.utf8) - try data.write(to: file) - let settings = AppSettings(store: ImportTestStore()) - let feature = EditorSettingsImportFeatureModel(settings: settings, chooseDirectory: { nil }, scan: { _ in - try MacEditorSettingsImportReader.scan(home: directory, directory: directory, fonts: []) - }) - await feature.scan() - #expect(feature.source?.values["keymap"] != nil) - #expect(try Data(contentsOf: file) == data) - try Data(#"[{"key":"ctrl+alt+cmd+n","command":"workbench.action.navigateBack"}]"#.utf8).write(to: file) - #expect(await feature.apply() == false) - #expect(settings.keyboardShortcutOverrides.isEmpty) - await feature.scan() - #expect(await feature.apply()) - #expect(settings.keyboardShortcutOverrides["navigate-back"] == [.keyPress(key: "n", modifiers: [.control, .option, .command])]) - } - - @Test @MainActor func ideaDefaultsImportWithoutCustomFilesAndPreserveInheritance() async throws { - let parsed = try MacEditorSettingsImportReader.parseIDEA([:], fonts: [], includeAppearanceDefaults: true) - #expect(parsed.0["tabSize"] == "4" && parsed.0["insertSpaces"] == "true" && parsed.0["detectIndentation"] == "true") - let defaults = try #require(parsed.0["keymap"].flatMap(EditorImportKeymap.decode)) - #expect(MacImportedKeybindings.ideaDefaults("Mac OS X")?["go-to-line"] == [.keyPress(key: "g", modifiers: .command)]) - #expect(defaults.commands["run"] == [.keyPress(key: "r", modifiers: .control)]) - #expect(defaults.commands["settings"] == [.keyPress(key: ",", modifiers: .command)]) - let settings = AppSettings(store: ImportTestStore()) - let source = EditorSettingsImportSource(directory: URL(fileURLWithPath: "/fixture/IDEA"), title: "IDEA", revision: "default", values: parsed.0, warnings: []) - let feature = EditorSettingsImportFeatureModel(settings: settings, chooseDirectory: { nil }, scan: { _ in [source] }) - await feature.scan() - #expect(feature.keymapConflict == nil) - #expect(await feature.apply()) - #expect(settings.keyboardShortcutOverrides["run"] == defaults.commands["run"]) - try settings.undoEditorSettingsImport() - #expect(settings.keyboardShortcutOverrides.isEmpty) - let files = [ - "options/keymap.xml": "", - "keymaps/mine.xml": "" - ].mapValues { Data($0.utf8) } - let inherited = try MacEditorSettingsImportReader.parseIDEA(files, fonts: [], includeAppearanceDefaults: true) - let map = try #require(inherited.0["keymap"].flatMap(EditorImportKeymap.decode)) - #expect(map.commands["settings"] == defaults.commands["settings"]) - #expect(map.commands["run"] == [.keyPress(key: "r", modifiers: [.command, .option])]) - #expect(map.commands["debug"] == nil) - let unknown = try MacEditorSettingsImportReader.parseIDEA(["options/keymap.xml": Data("".utf8)], fonts: [], includeAppearanceDefaults: true) - #expect(unknown.0["keymap"] == nil) - var cyclic = files - cyclic["keymaps/mine.xml"] = Data("".utf8) - #expect(throws: EditorSettingsImportError.self) { try MacEditorSettingsImportReader.parseIDEA(cyclic, fonts: [], includeAppearanceDefaults: true) } - } - @Test @MainActor func importedThemeInheritsPurpleCommentsPersistsAndUndoes() async throws { let files = [ "options/colors.scheme.xml": "", diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index 887cca2dfd..d3556d8ff4 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,10 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() throws { let commands = LitheCommandCatalog.commands - #expect(commands.count == 39) + #expect(commands.count == 40) + let startOrContinue = try #require(LitheCommandCatalog.command(id: "debug-start-or-continue")) + #expect(startOrContinue.defaultBindings.isEmpty) + #expect(LitheCommandCatalog.moduleOwner(for: startOrContinue.id) == .debug) let findAction = try #require(LitheCommandCatalog.command(id: "find-action")) #expect(findAction.defaultBindings == [.keyPress(key: "a", modifiers: [.shift, .command])]) diff --git a/macos/Tests/LitheTests/MacEditorKeymapImportServiceTests.swift b/macos/Tests/LitheTests/MacEditorKeymapImportServiceTests.swift new file mode 100644 index 0000000000..807063e390 --- /dev/null +++ b/macos/Tests/LitheTests/MacEditorKeymapImportServiceTests.swift @@ -0,0 +1,60 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@MainActor +struct MacEditorKeymapImportServiceTests { + private let provider = MacPluginBackendSessions.KeymapProvider(pluginID: "fixture.plugin", id: "first", displayName: "Fixture") + + private func source() -> EditorSettingsImportSource { + EditorSettingsImportSource(directory: URL(fileURLWithPath: "/fixture"), title: "Fixture", revision: "appearance", + values: ["tabSize": "2"], warnings: []) + } + + @Test + func unmatchedSourceKeepsOtherSettingsWithoutStartingAProvider() async throws { + let service = MacEditorKeymapImportService(find: { _ in [] }, importProvider: { _, _ in + Issue.record("An unmatched source must not start a provider") + throw EditorSettingsImportError.invalid + }, operations: RustKeymapImportOperations(core: RustCoreBridge())) + #expect(try await service.importing(source()) == source()) + } + + @Test + func mergesValidatedProviderBindingsAndSourceRevision() async throws { + let response: ToolingJSONValue = .object(["response": .object(["result": .object(["bindings": .array([ + .object(["commandID": .string("toggle-breakpoint"), "keys": .array([ + .object(["key": .string("f9"), "modifiers": .array([])]) + ])]) + ])])])]) + let service = MacEditorKeymapImportService(find: { _ in [provider] }, importProvider: { _, _ in + .init(response: response, sourceDigest: "digest", sourceFileCount: 0) + }, operations: RustKeymapImportOperations(core: RustCoreBridge())) + let result = try await service.importing(source()) + let keymap = try #require(result.values["keymap"].flatMap(EditorImportKeymap.decode)) + #expect(keymap.commands["toggle-breakpoint"] == [.keyPress(key: "f9", modifiers: [])]) + #expect(result.values["tabSize"] == "2") + #expect(result.revision != "appearance") + #expect(result.defaultFields.contains("keymap")) + } + + @Test + func rejectsAmbiguousProvidersAndUnsupportedNativeKeys() async throws { + let other = MacPluginBackendSessions.KeymapProvider(pluginID: "other.plugin", id: "second", displayName: "Other") + let ambiguous = MacEditorKeymapImportService(find: { _ in [provider, other] }, importProvider: { _, _ in + Issue.record("Ambiguous providers must not be started") + throw EditorSettingsImportError.invalid + }, operations: RustKeymapImportOperations(core: RustCoreBridge())) + await #expect(throws: EditorSettingsImportError.self) { try await ambiguous.importing(source()) } + let response: ToolingJSONValue = .object(["response": .object(["result": .object(["bindings": .array([ + .object(["commandID": .string("find-next"), "keys": .array([ + .object(["key": .string("f24"), "modifiers": .array([])]) + ])]) + ])])])]) + let unsupported = MacEditorKeymapImportService(find: { _ in [provider] }, importProvider: { _, _ in + .init(response: response, sourceDigest: "digest", sourceFileCount: 1) + }, operations: RustKeymapImportOperations(core: RustCoreBridge())) + await #expect(throws: (any Error).self) { try await unsupported.importing(source()) } + } +} diff --git a/macos/Tests/LitheTests/MacKeymapImportRecognitionTests.swift b/macos/Tests/LitheTests/MacKeymapImportRecognitionTests.swift new file mode 100644 index 0000000000..29f7c602d0 --- /dev/null +++ b/macos/Tests/LitheTests/MacKeymapImportRecognitionTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import Lithe + +struct MacKeymapImportRecognitionTests { + @Test + func usesOnlyDeclaredMetadataAndRejectsSymlinks() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("keymap-recognition-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let manifest = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json") + let operations = RustKeymapImportOperations(core: RustCoreBridge()) + let first = try operations.planSources(manifest: Data(contentsOf: manifest), providerID: "first") + let second = try operations.planSources(manifest: Data(contentsOf: manifest), providerID: "second") + let defaultRoot = root.appendingPathComponent("FirstEditor/User") + let profile = defaultRoot.appendingPathComponent("profiles/example") + let unrelated = root.appendingPathComponent("OtherEditor/User") + for directory in [defaultRoot, profile, unrelated] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + #expect(try MacKeymapImportRecognition.matches(root: defaultRoot, plan: first, userConfigRoot: root)) + #expect(try MacKeymapImportRecognition.matches(root: profile, plan: first, userConfigRoot: root)) + #expect(try !MacKeymapImportRecognition.matches(root: unrelated, plan: first, userConfigRoot: root)) + #expect(try !MacKeymapImportRecognition.matches(root: root, plan: first)) + #expect(try !MacKeymapImportRecognition.matches(root: root, plan: second)) + try Data("unread".utf8).write(to: root.appendingPathComponent("bindings.json")) + #expect(try MacKeymapImportRecognition.matches(root: root, plan: first)) + try FileManager.default.createDirectory(at: root.appendingPathComponent("keymaps"), withIntermediateDirectories: true) + #expect(try MacKeymapImportRecognition.matches(root: root, plan: second)) + try FileManager.default.removeItem(at: root.appendingPathComponent("bindings.json")) + try FileManager.default.createSymbolicLink(at: root.appendingPathComponent("bindings.json"), + withDestinationURL: root.appendingPathComponent("keymaps")) + #expect(throws: (any Error).self) { try MacKeymapImportRecognition.matches(root: root, plan: first) } + } +} diff --git a/macos/Tests/LitheTests/MacKeymapImportSnapshotTests.swift b/macos/Tests/LitheTests/MacKeymapImportSnapshotTests.swift new file mode 100644 index 0000000000..9fd5d86722 --- /dev/null +++ b/macos/Tests/LitheTests/MacKeymapImportSnapshotTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@testable import Lithe + +struct MacKeymapImportSnapshotTests { + private func plan(_ providerID: String) throws -> KeymapImportSourcePlan { + let fixture = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json") + return try RustKeymapImportOperations(core: RustCoreBridge()).planSources( + manifest: Data(contentsOf: fixture), providerID: providerID + ) + } + + @Test + func directoryScanCountsEntriesWithUnmatchedExtensions() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("keymap-names-\(UUID().uuidString)") + let directory = root.appendingPathComponent("keymaps") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data().write(to: directory.appendingPathComponent("first.txt")) + try Data().write(to: directory.appendingPathComponent("second.txt")) + let original = try plan("second") + let limits = KeymapImportSourcePlan.Limits(selectors: original.limits.selectors, probes: original.limits.probes, + files: original.limits.files, fileBytes: original.limits.fileBytes, totalBytes: original.limits.totalBytes, + chunkBytes: original.limits.chunkBytes, directoryEntries: 1, directoryMatches: original.limits.directoryMatches) + let bounded = KeymapImportSourcePlan(inputs: original.inputs, recognition: original.recognition, limits: limits) + #expect(throws: (any Error).self) { try MacKeymapImportSnapshot(root: root, plan: bounded) } + } + + @Test + func readsOnlyDeclaredFilesInBoundedChunksAndCloses() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("keymap-snapshot-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root.appendingPathComponent("keymaps"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("abcdef".utf8).write(to: root.appendingPathComponent("bindings.json")) + try Data("xml".utf8).write(to: root.appendingPathComponent("keymaps/custom.xml")) + try Data("private".utf8).write(to: root.appendingPathComponent("keymaps/secret.txt")) + let first = try plan("first"), second = try plan("second") + let snapshot = try MacKeymapImportSnapshot(root: root, + plan: .init(inputs: first.inputs + second.inputs, recognition: first.recognition + second.recognition, limits: first.limits)) + defer { snapshot.close() } + #expect(snapshot.files.map(\.path) == ["bindings.json", "keymaps/custom.xml"]) + let file = try #require(snapshot.files.first) + #expect(file.byteCount == 6) + #expect(try snapshot.read(fileID: file.id, offset: 1, maximumBytes: 2) == Data("bc".utf8)) + #expect(try snapshot.read(fileID: file.id, offset: 5, maximumBytes: 20) == Data("f".utf8)) + #expect(try snapshot.read(fileID: file.id, offset: 6, maximumBytes: 20).isEmpty) + #expect(throws: (any Error).self) { try snapshot.read(fileID: file.id, offset: 0, maximumBytes: first.limits.chunkBytes + 1) } + snapshot.close() + #expect(throws: (any Error).self) { try snapshot.read(fileID: file.id, offset: 0, maximumBytes: 1) } + } + + @Test + func rejectsSymlinksAndDetectsReplacementOfASnapshottedFile() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("keymap-snapshot-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root.appendingPathComponent("keymaps"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("bindings.json") + try Data("before".utf8).write(to: target) + let link = root.appendingPathComponent("keymaps/link.xml") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + #expect(throws: (any Error).self) { + try MacKeymapImportSnapshot(root: root, plan: plan("second")) + } + let snapshot = try MacKeymapImportSnapshot(root: root, plan: plan("first")) + defer { snapshot.close() } + let id = try #require(snapshot.files.first?.id) + try snapshot.validate() + let originalDigest = try snapshot.contentDigest() + let same = try MacKeymapImportSnapshot(root: root, plan: plan("first")) + #expect(try same.contentDigest() == originalDigest) + same.close() + try Data("after!".utf8).write(to: target, options: .atomic) + #expect(throws: (any Error).self) { try snapshot.read(fileID: id, offset: 0, maximumBytes: 6) } + #expect(throws: (any Error).self) { try snapshot.validate() } + let replacement = try MacKeymapImportSnapshot(root: root, plan: plan("first")) + defer { replacement.close() } + #expect(try replacement.contentDigest() != originalDigest) + } +} diff --git a/macos/Tests/LitheTests/MacKeymapImportSnapshotsTests.swift b/macos/Tests/LitheTests/MacKeymapImportSnapshotsTests.swift new file mode 100644 index 0000000000..a658a09a34 --- /dev/null +++ b/macos/Tests/LitheTests/MacKeymapImportSnapshotsTests.swift @@ -0,0 +1,75 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@MainActor +struct MacKeymapImportSnapshotsTests { + @Test + func hostServiceRequiresGrantAndRevokesSnapshotWhenSessionEnds() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("keymap-host-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("bindings".utf8).write(to: root.appendingPathComponent("bindings.json")) + let fixture = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json") + let plan = try RustKeymapImportOperations(core: RustCoreBridge()).planSources( + manifest: Data(contentsOf: fixture), providerID: "first" + ) + let service = MacKeymapImportHostService() + let dispatcher = PluginHostServiceDispatcher(contexts: PluginWorkspaceContexts()) + dispatcher.register(service) + let method = "lithe/host/keymapImport/readChunk" + let listMethod = "lithe/host/keymapImport/listInputs" + let session = dispatcher.beginSession(pluginID: "editor", hostServices: [method, listMethod]) + let snapshot = try MacKeymapImportSnapshot(root: root, plan: plan) + let token = service.snapshots.insert(snapshot, pluginID: "editor", sessionID: session, providerID: "first") + let file = try #require(snapshot.files.first) + let list = PluginHostRequest(id: "list", method: listMethod, service: "keymapImport", params: + .object(["snapshotID": .string(token), "providerID": .string("first")])) + #expect(try await dispatcher.dispatch(list, pluginID: "editor").get() == .object(["files": .array([ + .object(["id": .string(file.id), "path": .string("bindings.json"), "byteCount": .integer(8)]) + ])])) + let params: ToolingJSONValue = .object(["snapshotID": .string(token), "providerID": .string("first"), + "fileID": .string(file.id), "offset": .integer(0), "maximumBytes": .integer(8)]) + let request = PluginHostRequest(id: "1", method: method, service: "keymapImport", params: params) + #expect(try await dispatcher.dispatch(request, pluginID: "editor").get() == .object(["data": .string("YmluZGluZ3M=")])) + _ = dispatcher.beginSession(pluginID: "other", hostServices: [method]) + if case .success = await dispatcher.dispatch(request, pluginID: "other") { + Issue.record("Another plugin must not read this snapshot") + } + dispatcher.endSession(pluginID: "editor") + #expect(throws: (any Error).self) { try snapshot.read(fileID: file.id, offset: 0, maximumBytes: 1) } + dispatcher.endSession(pluginID: "other") + } + @Test + func snapshotsAreBoundToTheOwningSessionAndRevoked() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("keymap-owner-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("bindings".utf8).write(to: root.appendingPathComponent("bindings.json")) + let fixture = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json") + let plan = try RustKeymapImportOperations(core: RustCoreBridge()).planSources( + manifest: Data(contentsOf: fixture), providerID: "first" + ) + let registry = MacKeymapImportSnapshots() + let snapshot = try MacKeymapImportSnapshot(root: root, plan: plan) + let token = registry.insert(snapshot, pluginID: "editor", sessionID: "session-1", providerID: "first") + #expect(try registry.snapshot(token, pluginID: "editor", sessionID: "session-1", providerID: "first") === snapshot) + #expect(throws: (any Error).self) { try registry.snapshot(token, pluginID: "other", sessionID: "session-1", providerID: "first") } + #expect(throws: (any Error).self) { try registry.snapshot(token, pluginID: "editor", sessionID: "session-2", providerID: "first") } + #expect(throws: (any Error).self) { try registry.snapshot(token, pluginID: "editor", sessionID: "session-1", providerID: "second") } + registry.endSession(pluginID: "other", sessionID: "session-1") + #expect(try registry.snapshot(token, pluginID: "editor", sessionID: "session-1", providerID: "first") === snapshot) + let replacement = try MacKeymapImportSnapshot(root: root, plan: plan) + let replacementToken = registry.insert(replacement, pluginID: "editor", sessionID: "session-1", providerID: "first") + #expect(throws: (any Error).self) { try registry.snapshot(token, pluginID: "editor", sessionID: "session-1", providerID: "first") } + let originalFileID = try #require(snapshot.files.first?.id) + #expect(throws: (any Error).self) { try snapshot.read(fileID: originalFileID, offset: 0, maximumBytes: 1) } + registry.endSession(pluginID: "editor", sessionID: "session-1") + #expect(throws: (any Error).self) { try registry.snapshot(replacementToken, pluginID: "editor", sessionID: "session-1", providerID: "first") } + let fileID = try #require(replacement.files.first?.id) + #expect(throws: (any Error).self) { try replacement.read(fileID: fileID, offset: 0, maximumBytes: 1) } + } +} diff --git a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift index fc3dde6933..e61ed4d327 100644 --- a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift +++ b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift @@ -10,6 +10,28 @@ import Testing /// receives, how its credential requests are answered, and when it stops. @MainActor struct MacPluginBackendSessionsTests { + @Test + func shortcutImportProviderIDsComeFromThePluginManifest() throws { + let fixture = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json") + let manifest = try Data(contentsOf: fixture) + #expect(MacPluginBackendSessions.keymapImportProviderIDs(ofManifest: manifest) == ["first", "second"]) + #expect(MacPluginBackendSessions.isKeymapOnlyBackend(manifest: manifest)) + let normalized = try RustDeclarativePluginOperations(core: RustCoreBridge()).validate(manifest: manifest).normalizedManifest + #expect(MacPluginBackendSessions.keymapImportProviderIDs(ofManifest: normalized) == ["first", "second"]) + #expect(MacPluginBackendSessions.isKeymapOnlyBackend(manifest: normalized)) + var object = try #require(JSONSerialization.jsonObject(with: manifest) as? [String: Any]) + object["permissions"] = ["backend.launch", "keymaps.provide", "keymaps.readSelectedSources", "workspace.metadata"] + let shared = try JSONSerialization.data(withJSONObject: object) + #expect(!MacPluginBackendSessions.isKeymapOnlyBackend(manifest: shared)) + object["permissions"] = ["backend.launch", "keymaps.provide", "keymaps.readSelectedSources"] + for contribution in ["settings", "settingActions", "toolDefinitions"] { + object[contribution] = [[:]] + #expect(!MacPluginBackendSessions.isKeymapOnlyBackend(manifest: try JSONSerialization.data(withJSONObject: object))) + object.removeValue(forKey: contribution) + } + } + @Test func anOlderNegotiatedProtocolSkipsNotificationsWithoutStoppingTheBackend() async throws { let fixture = try BackendFixture(backendVersion: 2) diff --git a/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift b/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift index 932766d8e3..432cdeb1b4 100644 --- a/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift +++ b/macos/Tests/LitheTests/PluginBundledPublisherTrustTests.swift @@ -11,6 +11,38 @@ import Testing /// No fixture process executes, and all files belong to this test's root. @MainActor struct PluginBundledPublisherTrustTests { + @Test + func bundledNonLanguageImporterUsesOnlyThePinnedHostSeal() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-importer-trust-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let host = root.appendingPathComponent("Lithe.app") + let bundled = host.appendingPathComponent("Contents/Resources/OfficialPlugins/dev.lithe.plugin.editor-keymap-import") + let manifestURL = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("Plugins/mac/Official/EditorKeymapImport/plugin.json") + let manifest = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: manifestURL)) as? [String: Any]) + try DeclarativePluginTestSupport.writePackage(at: bundled, manifest: manifest) + let executable = bundled.appendingPathComponent("bin/editor-keymap-import") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + let verifier = MacOfficialPluginSignatureVerifier(allowsLocalDevelopmentPackages: false, hostBundleURL: host) + let store = MacPluginPackageStore(rootURL: root.appendingPathComponent("installed"), + bundledRootURL: bundled.deletingLastPathComponent(), + stateDirectories: MacPluginStateDirectories(rootURL: root.appendingPathComponent("state")), + validator: DeclarativePluginTestSupport.operations, verifier: verifier) + let scan = try store.scanInstalledPlugins() + #expect(scan.issues.isEmpty) + let package = try #require(scan.packages.first) + #expect(package.descriptor.id == OfficialPluginCatalog.editorKeymapImportPluginID) + #expect(package.installation.origin == .bundled) + #expect(OfficialPluginCatalog.entry(for: package.descriptor.id) == nil) + let outside = root.appendingPathComponent("download") + try FileManager.default.copyItem(at: bundled, to: outside) + #expect(throws: PluginPackageStoreError.missingPackageSignature) { + try verifier.verify(packageAt: outside, manifest: package.descriptor.pluginManifest, origin: .marketplace) + } + } + @Test(arguments: [true, false], ["direct", "marketplace", "disk"]) func installingOverBundledCopyPreservesDefaultOrDisabledChoice(enabled: Bool, source: String) async throws { let fixture = try Fixture() diff --git a/macos/Tests/LitheTests/RustDebugShortcutOperationsTests.swift b/macos/Tests/LitheTests/RustDebugShortcutOperationsTests.swift new file mode 100644 index 0000000000..0eb49d9def --- /dev/null +++ b/macos/Tests/LitheTests/RustDebugShortcutOperationsTests.swift @@ -0,0 +1,12 @@ +import Testing +@testable import Lithe + +struct RustDebugShortcutOperationsTests { + @Test func combinedDebugShortcutUsesTheSharedSessionDecision() throws { + let core = RustCoreBridge() + #expect(try core.debugStartOrContinue(active: false, paused: false, requestPending: false) == .start) + #expect(try core.debugStartOrContinue(active: true, paused: true, requestPending: false) == .continue) + #expect(try core.debugStartOrContinue(active: true, paused: false, requestPending: false) == .none) + #expect(try core.debugStartOrContinue(active: true, paused: true, requestPending: true) == .none) + } +} diff --git a/macos/Tests/LitheTests/RustKeymapImportOperationsTests.swift b/macos/Tests/LitheTests/RustKeymapImportOperationsTests.swift new file mode 100644 index 0000000000..c6e4f02852 --- /dev/null +++ b/macos/Tests/LitheTests/RustKeymapImportOperationsTests.swift @@ -0,0 +1,69 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +struct RustKeymapImportOperationsTests { + @Test + func convertsOnlyKeysTheNativeHostCanRegister() throws { + let binding = KeymapImportBinding(commandID: "toggle-breakpoint", keys: [ + .init(key: "f9", modifiers: []), .init(key: "backspace", modifiers: [.alt, .meta]) + ]) + #expect(try binding.nativeBindings() == [.keyPress(key: "f9", modifiers: []), + .keyPress(key: "delete", modifiers: [.option, .command])]) + for unsupported in ["delete", "escape", "f24"] { + let bad = KeymapImportBinding(commandID: "find-next", keys: [.init(key: unsupported, modifiers: [])]) + #expect(throws: (any Error).self) { try bad.nativeBindings() } + } + } + @Test + func plansDefaultDisplacementWithoutOverwritingCustomBindings() throws { + let operations = RustKeymapImportOperations(core: RustCoreBridge()) + let f9 = KeymapImportBinding.Key(key: "f9", modifiers: []) + let incoming = [KeymapImportBinding(commandID: "toggle-breakpoint", keys: [f9])] + let base = [KeymapImportCurrent(commandID: "debug-resume", customized: false, keys: [f9]), + KeymapImportCurrent(commandID: "toggle-breakpoint", customized: false, keys: [])] + let plan = try operations.planBindings(current: base, incoming: incoming) + #expect(plan.displaced == [KeymapImportBinding(commandID: "debug-resume", keys: [f9])]) + #expect(plan.conflicts.isEmpty) + let custom = [KeymapImportCurrent(commandID: "debug-resume", customized: true, keys: [f9]), base[1]] + let blocked = try operations.planBindings(current: custom, incoming: incoming) + #expect(blocked.displaced.isEmpty) + #expect(blocked.conflicts.first?.ownerID == "debug-resume") + } + + @Test + func plansOnlyTheSelectedProvidersDeclaredSources() throws { + let manifest = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json") + let operations = RustKeymapImportOperations(core: RustCoreBridge()) + let plan = try operations.planSources(manifest: Data(contentsOf: manifest), providerID: "second") + #expect(plan.inputs == [ + .init(kind: .file, path: "options/map.xml", fileExtension: nil), + .init(kind: .directory, path: "keymaps", fileExtension: "xml") + ]) + #expect(plan.limits.fileBytes == 1_048_576) + #expect(plan.recognition == [.directory(path: "keymaps")]) + #expect(plan.limits.chunkBytes == 131_072) + #expect(throws: (any Error).self) { + try operations.planSources(manifest: Data(contentsOf: manifest), providerID: "unknown") + } + } + + @Test + func validatesAnUntrustedProviderReplyBeforeNativeUse() throws { + let operations = RustKeymapImportOperations(core: RustCoreBridge()) + let reply: ToolingJSONValue = .object(["bindings": .array([ + .object(["commandID": .string("find-next"), "keys": .array([ + .object(["key": .string("g"), "modifiers": .array([.string("meta"), .string("alt")])]) + ])]) + ])]) + let bindings = try operations.decodeImport(commandIDs: ["find-next"], reply: reply) + #expect(bindings.count == 1) + #expect(bindings[0].commandID == "find-next") + #expect(bindings[0].keys[0].modifiers == [.alt, .meta]) + #expect(throws: (any Error).self) { + try operations.decodeImport(commandIDs: ["other"], reply: reply) + } + } +} diff --git a/macos/Tests/LitheTests/RustLanguageServerLaunchTests.swift b/macos/Tests/LitheTests/RustLanguageServerLaunchTests.swift index 09756a301f..b615c8cb9f 100644 --- a/macos/Tests/LitheTests/RustLanguageServerLaunchTests.swift +++ b/macos/Tests/LitheTests/RustLanguageServerLaunchTests.swift @@ -7,6 +7,134 @@ import Testing @MainActor struct RustLanguageServerLaunchTests { + @Test + func keymapProviderReadsOnlyItsSelectedSnapshotThroughRealSession() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("keymap-source-session-\(UUID().uuidString)") + let source = root.appendingPathComponent("source") + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("bindings".utf8).write(to: source.appendingPathComponent("bindings.json")) + let executable = root.appendingPathComponent("server.py") + try Self.keymapSourceServer.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + let methods = ["lithe/host/keymapImport/listInputs", "lithe/host/keymapImport/readChunk"] + let dispatcher = PluginHostServiceDispatcher(contexts: PluginWorkspaceContexts()) + let service = MacKeymapImportHostService() + dispatcher.register(service) + let pluginID = "dev.lithe.fixture.import" + let hostSession = dispatcher.beginSession(pluginID: pluginID, hostServices: methods) + let session = LanguageServerRuntimeSession(providerID: "fixture", executableURL: executable, + arguments: [], environment: [:], initializeTimeout: 5, shutdownTimeout: 0.1, + core: RustCoreBridge(), pluginID: pluginID, role: .backend, hostServices: methods, + keymapImportProviders: ["first"]) + defer { session.stop(); dispatcher.endSession(pluginID: pluginID) } + session.onHostRequest = { [weak session] request in + dispatcher.handle(request, pluginID: pluginID, sessionID: hostSession) { result in + session?.respond(to: request.id, with: result) + } + } + session.onHostRequestCancelled = { requestID in + dispatcher.cancel(hostRequestID: requestID, pluginID: pluginID, sessionID: hostSession) + } + let ready = TestGate() + session.onStateChange = { state in if case .ready = state { ready.open() } } + try session.start(rootURL: root, workspaceFingerprint: nil) + try #require(await ready.waitUntilOpen(timeout: .seconds(5))) + let manifestURL = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json") + let plan = try RustKeymapImportOperations(core: RustCoreBridge()).planSources( + manifest: Data(contentsOf: manifestURL), providerID: "first") + let snapshot = try MacKeymapImportSnapshot(root: source, plan: plan) + let token = service.snapshots.insert(snapshot, pluginID: pluginID, sessionID: hostSession, providerID: "first") + let response = try await session.keymapImportRequest(providerID: "first", snapshotID: token, timeout: 5) + guard case .object(let value) = response, case .object(let raw)? = value["response"], + case .object(let payload)? = raw["result"] else { + Issue.record("Provider response was not returned") + return + } + #expect(payload["bindings"] == .array([.object([ + "commandID": .string("find-next"), "keys": .array([.object(["key": .string("f3"), "modifiers": .array([])])]) + ])])) + #expect(await session.stopAndWait()) + dispatcher.endSession(pluginID: pluginID) + let fileID = try #require(snapshot.files.first?.id) + #expect(throws: (any Error).self) { try snapshot.read(fileID: fileID, offset: 0, maximumBytes: 1) } + } + + @Test + func shortcutImportRunsThroughManagedPluginSession() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("keymap-managed-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let executable = root.appendingPathComponent("server.py") + try Self.server.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + let session = LanguageServerRuntimeSession(providerID: "fixture", executableURL: executable, + arguments: [], environment: [:], initializeTimeout: 5, shutdownTimeout: 0.1, + core: RustCoreBridge(), pluginID: "dev.lithe.fixture.import", role: .backend, + keymapImportProviders: ["fixture"]) + defer { session.stop() } + let ready = TestGate() + session.onStateChange = { state in if case .ready = state { ready.open() } } + try session.start(rootURL: root, workspaceFingerprint: nil) + try #require(await ready.waitUntilOpen(timeout: .seconds(5))) + #expect(session.keymapImportProviders == ["fixture"]) + let response = try await session.keymapImportRequest(providerID: "fixture", snapshotID: "snapshot-1", timeout: 2) + guard case .object(let value) = response, case .object(let raw)? = value["response"], + case .object(let payload)? = raw["result"] else { + Issue.record("Provider response was not returned") + return + } + #expect(payload["bindings"] == .array([])) + #expect(await session.stopAndWait()) + } + + @Test + func shortcutImportUsesNegotiatedPluginSessionThroughRustBridge() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("keymap-bridge-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let executable = root.appendingPathComponent("server.py") + try Self.server.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: executable.path) + let core = RustCoreBridge() + let started = try core.lspStartServer(providerID: "fixture", pluginID: "dev.lithe.fixture.import", + executableURL: executable, arguments: [], environment: [:], rootURL: root, + workingDirectoryURL: root, keymapImportProviders: ["fixture"], + initializeTimeout: 5, requestTimeout: 5, shutdownTimeout: 0.1).get() + defer { core.stopLanguageServer(sessionID: started.sessionId); core.destroyLanguageServer(sessionID: started.sessionId) } + var selected = false + let deadline = ContinuousClock.now.advanced(by: .seconds(5)) + while ContinuousClock.now < deadline && !selected { + for event in core.pollLanguageServerEvents(sessionID: started.sessionId) where event.type == "keymapImportProviders" { + if case .object(let object)? = event.result, case .array(let ids)? = object["providers"] { + selected = ids == [.string("fixture")] + } + } + await Task.yield() + } + try #require(selected, "Plugin did not negotiate the declared import provider") + if case .success = core.lspKeymapImportRequest(sessionID: started.sessionId, providerID: "other", snapshotID: "snapshot-1", timeout: 1) { + Issue.record("Core accepted an unnegotiated provider") + } + let operation = try core.lspKeymapImportRequest(sessionID: started.sessionId, providerID: "fixture", snapshotID: "snapshot-1", timeout: 1).get() + var received = false + let responseDeadline = ContinuousClock.now.advanced(by: .seconds(5)) + while ContinuousClock.now < responseDeadline && !received { + for event in core.pollLanguageServerEvents(sessionID: started.sessionId) + where event.type == "requestCompleted" && event.operationID == operation.operationId { + guard case .object(let result)? = event.result, case .object(let value)? = result["value"], + case .object(let response)? = value["response"], case .object(let payload)? = response["result"] else { continue } + received = payload["bindings"] == .array([]) + } + await Task.yield() + } + #expect(received) + let sent = try #require(read("keymap-import", root) as? [String: Any]) + let params = try #require(sent["params"] as? [String: String]) + #expect(params == ["providerID": "fixture", "snapshotID": "snapshot-1"]) + } + @Test(arguments: [false, true]) func startupMapsIdentityConfigurationServicesAndJdtContextThroughCore(useJdt: Bool) async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent("plugin-core-launch-\(UUID().uuidString)") @@ -217,6 +345,42 @@ struct RustLanguageServerLaunchTests { // macOS developer tools already provide Python; this explicit integration // fixture speaks framed JSON-RPC and accepts the JVM argv without a JDK. + private static let keymapSourceServer = #""" +#!/usr/bin/python3 +import json, sys +def send(value): + body = json.dumps(value).encode() + sys.stdout.buffer.write(('Content-Length: %d\r\n\r\n' % len(body)).encode() + body) + sys.stdout.buffer.flush() +while True: + headers = {} + while True: + line = sys.stdin.buffer.readline() + if not line: sys.exit(0) + if line == b'\r\n': break + key, value = line.decode().split(':', 1) + headers[key.lower()] = value.strip() + message = json.loads(sys.stdin.buffer.read(int(headers['content-length']))) + method = message.get('method') + if method == 'initialize': + send({'jsonrpc':'2.0','id':message['id'],'result':{'capabilities':{'experimental':{ + 'litheFunctions':{'version':3,'functions':[]}, + 'litheKeymapImport':{'version':1,'providers':['first']}}}}}) + elif method == 'lithe/keymap/import': + request_id = message['id'] + params = message['params'] + send({'jsonrpc':'2.0','id':'list','method':'lithe/host/keymapImport/listInputs','params':params}) + elif message.get('id') == 'list': + files = message['result']['files'] + send({'jsonrpc':'2.0','id':'read','method':'lithe/host/keymapImport/readChunk','params':{ + **params, 'fileID':files[0]['id'], 'offset':0, 'maximumBytes':8}}) + elif message.get('id') == 'read': + keys = [{'key':'f3','modifiers':[]}] if message['result']['data'] == 'YmluZGluZ3M=' else [] + send({'jsonrpc':'2.0','id':request_id,'result':{'bindings':[{'commandID':'find-next','keys':keys}]}}) + elif method == 'exit': break + elif method and 'id' in message: send({'jsonrpc':'2.0','id':message['id'],'result':None}) +"""# + private static let server = #""" #!/usr/bin/python3 import json, pathlib, sys @@ -245,9 +409,15 @@ while True: method = message.get('method') if method == 'initialize': record('initialize', message) - send({'jsonrpc':'2.0','id':message['id'],'result':{'capabilities':{'experimental':{'litheFunctions':{'version':2,'functions':[{'id':'snapshot.refresh'}]}}}}}) + experimental = {'litheFunctions':{'version':2,'functions':[{'id':'snapshot.refresh'}]}} + offered = message.get('params', {}).get('capabilities', {}).get('experimental', {}).get('litheKeymapImport') + if offered: experimental['litheKeymapImport'] = {'version':1, 'providers':offered['providers']} + send({'jsonrpc':'2.0','id':message['id'],'result':{'capabilities':{'experimental':experimental}}}) elif method == '$/setTrace': record('trace', message['params']['value']) + elif method == 'lithe/keymap/import': + record('keymap-import', message) + send({'jsonrpc':'2.0','id':message['id'],'result':{'bindings':[]}}) elif method == 'initialized': send({'jsonrpc':'2.0','method':'window/logMessage','params':{'type':3,'message':'fixture diagnostic'}}) send({'jsonrpc':'2.0','method':'language/status','params':{'type':'ServiceReady'}}) diff --git a/macos/Tests/LitheTests/TerminalShortcutInputPolicyTests.swift b/macos/Tests/LitheTests/TerminalShortcutInputPolicyTests.swift index f612ff892d..dbcebf7169 100644 --- a/macos/Tests/LitheTests/TerminalShortcutInputPolicyTests.swift +++ b/macos/Tests/LitheTests/TerminalShortcutInputPolicyTests.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import Testing @testable import Lithe @@ -6,6 +7,62 @@ import Testing @MainActor @Suite("Terminal shortcut input policy") struct TerminalShortcutInputPolicyTests { + @MainActor + private final class Commands: ObservableObject { + private(set) var ids: [String] = [] + + func record(_ id: String) { + ids.append(id) + objectWillChange.send() + } + } + + @Test + func keyMonitorTogglesTerminalWithoutStealingShellControlKeys() async throws { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 320, height: 120), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let terminal = LitheTerminalView(frame: window.contentView!.bounds) + window.contentView?.addSubview(terminal) + defer { window.close() } + #expect(window.makeFirstResponder(terminal)) + let commands = Commands() + let detector = MacShortcutDetectorFactory().make { commands.record($0) } + defer { detector.stop() } + detector.update(registrations: [ + .init(commandID: "debug", bindings: [.keyPress(key: "d", modifiers: .control)]), + .init(commandID: "run", bindings: [.keyPress(key: "r", modifiers: .control)]), + .init(commandID: "toggle-terminal", bindings: [.keyPress(key: "`", modifiers: .control)]), + .init(commandID: "save", bindings: [.keyPress(key: "s", modifiers: .command)]) + ]) + detector.start() + for (key, code, flags) in [("d", UInt16(2), NSEvent.ModifierFlags.control), + ("`", UInt16(50), .control), ("r", UInt16(15), .control), + ("s", UInt16(1), .command)] { + NSApp.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: key, + charactersIgnoringModifiers: key, isARepeat: false, keyCode: code))) + } + let received = await waitForChanges([commands.objectWillChange.eraseToAnyPublisher()], timeout: .seconds(5)) { + commands.ids.count >= 2 + } + #expect(received) + #expect(commands.ids == ["toggle-terminal", "save"]) + } + + @Test + func terminalToggleBindingRemainsAvailableWhenTerminalIsFocused() { + let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 320, height: 120)) + let binding = KeyboardShortcutBinding.keyPress(key: "`", modifiers: .control) + let registrations = [KeyboardShortcutRegistration(commandID: "toggle-terminal", bindings: [binding])] + let command = MacKeyboardShortcutMatcher.commandID(for: binding, registrations: registrations) + #expect(command == "toggle-terminal") + #expect(!MacShortcutInputPolicy.shouldDeferToTerminal(terminal, modifierFlags: [.control], commandID: command)) + #expect(MacShortcutInputPolicy.shouldDeferToTerminal(terminal, modifierFlags: [.control], commandID: nil)) + #expect(MacShortcutInputPolicy.shouldDeferToTerminal(terminal, modifierFlags: [.control], commandID: "debug")) + #expect(MacShortcutInputPolicy.shouldDeferToTerminal(terminal, modifierFlags: [.control], commandID: "run")) + } + @Test func focusedTerminalDefersPlainControlChords() { let terminal = LitheTerminalView(frame: NSRect(x: 0, y: 0, width: 320, height: 120)) diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs index c90f841630..f8c774d61e 100644 --- a/rust/lithe-core/src/debug/mod.rs +++ b/rust/lithe-core/src/debug/mod.rs @@ -8,6 +8,7 @@ mod engine; mod java_test; mod projection_v2; mod protocol; +mod shortcut; mod transport_lifecycle; mod types; @@ -15,6 +16,7 @@ pub(crate) use breakpoint_relocation::*; pub(crate) use endpoint::*; pub(crate) use engine::*; pub(crate) use java_test::*; +pub(crate) use shortcut::*; pub(crate) use transport_lifecycle::*; pub(crate) use types::*; diff --git a/rust/lithe-core/src/debug/shortcut.rs b/rust/lithe-core/src/debug/shortcut.rs new file mode 100644 index 0000000000..459b0c139a --- /dev/null +++ b/rust/lithe-core/src/debug/shortcut.rs @@ -0,0 +1,58 @@ +//! Portable decision for a combined start-or-continue debugger command. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +/// Chooses one action without restarting a running session or overlapping a request. +pub(crate) fn start_or_continue(payload: Value) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Request { + active: bool, + paused: bool, + request_pending: bool, + } + let request: Request = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug shortcut state.") + .with_details(error.to_string()) + })?; + let action = if request.request_pending { + "none" + } else if !request.active { + "start" + } else if request.paused { + "continue" + } else { + "none" + }; + Ok(json!({ "action": action })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn starts_or_continues_only_when_the_current_session_allows_it() { + for (active, paused, pending, expected) in [ + (false, false, false, "start"), + (false, true, false, "start"), + (true, true, false, "continue"), + (true, false, false, "none"), + (true, true, true, "none"), + (false, false, true, "none"), + ] { + assert_eq!( + start_or_continue(json!({"active": active, "paused": paused, + "requestPending": pending})) + .unwrap()["action"], + expected + ); + } + assert!(start_or_continue(json!({"active": true, "paused": true})).is_err()); + assert!(start_or_continue(json!({"active": true, "paused": true, + "requestPending": false, "unexpected": true})) + .is_err()); + } +} diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 369a4243d6..d15360a060 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -130,7 +130,7 @@ pub(super) fn client_initialize_for_plugin( "window": { "workDoneProgress": true }, - "experimental": experimental_capabilities(plugin_owned, &state.offered_toolchain_providers) + "experimental": experimental_capabilities(plugin_owned, &state.offered_toolchain_providers, &state.offered_keymap_import_providers) }, "initializationOptions": request.initialization_options }), @@ -684,6 +684,27 @@ pub(super) fn client_apply_server_message_for_plugin( error: None, }); } + state.keymap_import_providers = plugin_owned + .then(|| { + super::keymap_provider::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + &state.offered_keymap_import_providers, + ) + }) + .flatten() + .unwrap_or_default(); + if !state.keymap_import_providers.is_empty() { + events.push(LspClientEvent { + kind: super::keymap_provider::NEGOTIATED_EVENT.to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ "providers": state.keymap_import_providers })), + error: None, + }); + } state.text_document_sync = text_document_sync_kind(result.get("capabilities").unwrap_or(&Value::Null)); state.text_document_save_include_text = match result @@ -828,6 +849,7 @@ pub(super) fn json_rpc_result(id: &Value, result: Value) -> Result, + keymap_import_providers: &std::collections::BTreeSet, ) -> Value { let mut experimental = super::build_provider::client_experimental_capabilities(); if plugin_owned { @@ -840,6 +862,11 @@ fn experimental_capabilities( { experimental[toolchain_key] = toolchain_offer; } + if let Some((key, offer)) = + super::keymap_provider::client_experimental_capabilities(keymap_import_providers) + { + experimental[key] = offer; + } } experimental } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 68967fdb0b..8dcea17914 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -73,6 +73,8 @@ const MAX_BUILD_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; const MAX_VCS_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; /// Longest toolchain query a host may ask for; queries only read manager state. const MAX_TOOLCHAIN_QUERY_TIMEOUT_MS: u64 = 60_000; +/// Bounds one provider request; the host can cancel sooner when a snapshot expires. +const MAX_KEYMAP_IMPORT_TIMEOUT_MS: u64 = 60_000; const MONITOR_INTERVAL_MS: u64 = 10; /// JSON-RPC method carrying Java Debug Server build commands. const JAVA_BUILD_METHOD: &str = "workspace/executeCommand"; @@ -125,6 +127,9 @@ pub struct StartServerRequest { /// session with some is offered `litheToolchains`. #[serde(default)] pub toolchain_providers: Vec, + /// Shortcut import providers declared by this plugin's verified manifest. + #[serde(default)] + pub keymap_import_providers: Vec, pub executable_path: String, #[serde(default)] pub arguments: Vec, @@ -469,6 +474,26 @@ pub struct ToolchainProviderRequest { pub timeout_milliseconds: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One shortcut import operation against an opaque, host-owned snapshot. +pub struct KeymapImportProviderRequest { + /// Plugin-owned session which negotiated the provider. + pub session_id: String, + /// Optional correlation identity for cancellation and completion events. + #[serde(default)] + pub operation_id: Option, + /// Plugin-local identity of the selected provider. + #[serde(rename = "providerID")] + pub provider_id: String, + /// Host-owned snapshot reference, scoped to this import operation. + #[serde(rename = "snapshotID")] + pub snapshot_id: String, + /// Requested deadline in milliseconds, bounded by the runtime. + #[serde(default)] + pub timeout_milliseconds: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] /// One VCS-provider request. Core plans the method and params from the @@ -1611,6 +1636,20 @@ pub fn toolchain_provider_request( Ok(OperationResponse { operation_id }) } +/// Sends a shortcut import request only to a negotiated plugin provider. +pub fn keymap_import_provider_request( + request: KeymapImportProviderRequest, +) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .keymap_import_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + /// Sends one VCS-provider request to a session that negotiated the protocol. pub fn vcs_provider_request(request: VcsProviderRequest) -> Result { let operation_id = request @@ -1992,6 +2031,11 @@ impl LspEngine { } else { Default::default() }, + offered_keymap_import_providers: if request.plugin_id.is_some() { + request.keymap_import_providers.iter().cloned().collect() + } else { + Default::default() + }, ..LspClientState::default() }, root_uri: request.root_uri.clone(), @@ -2766,6 +2810,91 @@ impl RuntimeSession { self.send_request_or_fail(&outbound_order, messages, &request_id) } + fn keymap_import_request( + &self, + request: KeymapImportProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + // A snapshot reference is an opaque token, never a filesystem path. + // The future host service must separately enforce its session ownership. + if request.snapshot_id.is_empty() + || request.snapshot_id.len() > 128 + || !request + .snapshot_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid import snapshot reference.", + )); + } + let outbound_order = self.lock_outbound_order()?; + let (messages, request_id) = { + let mut state = self.lock_state()?; + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Plugin backend is not ready.", + )); + } + if state.request_by_operation.contains_key(&operation_id) + || state.java_builds.contains(&operation_id) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The operation ID is already pending.", + )); + } + if !state + .client + .keymap_import_providers + .contains(&request.provider_id) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The plugin did not negotiate this import provider.", + ) + .with_details("plugin.keymapImport.providerUnavailable")); + } + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_KEYMAP_IMPORT_TIMEOUT_MS)) + }) + .unwrap_or( + state + .request_timeout + .min(Duration::from_millis(MAX_KEYMAP_IMPORT_TIMEOUT_MS)), + ); + let response = allocate_raw_request( + state.client.clone(), + super::keymap_provider::IMPORT_METHOD, + json!({"providerID": request.provider_id, "snapshotID": request.snapshot_id}), + )?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: super::keymap_provider::IMPORT_METHOD.to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + /// Writes a VCS-provider request planned by `crate::vcs::plan`, which /// checks the operation's arguments and negotiated capability. fn toolchain_request( @@ -4031,6 +4160,48 @@ impl RuntimeSession { } } } + Some(PendingKind::Feature) + if pending_before.as_ref().is_some_and(|pending| { + pending.method == super::keymap_provider::IMPORT_METHOD + }) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = if value.to_string().len() + > super::keymap_provider::MAX_REPLY_BYTES + { + ( + None, + Some(runtime_error( + self, + "plugin.keymapImport.replyTooLarge", + "request", + Some(&pending.method), + None, + "The shortcut import provider reply is too large.", + None, + None, + )), + ) + } else { + ( + Some( + json!({"value": {"id": value.get("id"), "response": value}}), + ), + None, + ) + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } Some(PendingKind::Feature) if pending_before.as_ref().is_some_and(|pending| { pending.method == super::toolchain_provider::QUERY_METHOD @@ -4717,10 +4888,18 @@ impl RuntimeSession { } else if event.kind == super::vcs_provider::NEGOTIATED_EVENT { push_vcs_provider_event(self, &mut state, event.result.unwrap_or_default()); } else if event.kind == super::toolchain_provider::NEGOTIATED_EVENT { - push_toolchain_provider_event( + push_negotiated_provider_event( self, &mut state, event.result.unwrap_or_default(), + "toolchainProviders", + ); + } else if event.kind == super::keymap_provider::NEGOTIATED_EVENT { + push_negotiated_provider_event( + self, + &mut state, + event.result.unwrap_or_default(), + "keymapImportProviders", ); } else if event.kind == "diagnostics" { if let Some(uri) = event.uri.as_deref() { @@ -6967,17 +7146,18 @@ fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, r } /// Publishes the providers a backend selected, so the host queries only those. -fn push_toolchain_provider_event( +fn push_negotiated_provider_event( session: &RuntimeSession, state: &mut SessionState, result: Value, + kind: &str, ) { let sequence = take_sequence(state); enqueue_runtime_event( session, state, LspRuntimeEvent { - kind: "toolchainProviders".to_string(), + kind: kind.to_string(), channel: None, plugin_id: None, role: None, @@ -7821,6 +8001,7 @@ fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { clear_function_registry(session, state); state.client.vcs_provider = None; state.client.toolchain_providers.clear(); + state.client.keymap_import_providers.clear(); state.linked_document_paths = LinkedDocumentPaths::default(); state.java_navigation_marker_batches.clear(); state.java_navigation_marker_cache.clear(); @@ -7951,6 +8132,7 @@ mod tests { plugin_id: None, host_services: Vec::new(), toolchain_providers: Vec::new(), + keymap_import_providers: Vec::new(), executable_path: "/usr/bin/scripted-server".to_string(), arguments: vec!["--stdio".to_string()], environment: BTreeMap::new(), @@ -12971,6 +13153,7 @@ public class Main { plugin_id: None, host_services: Vec::new(), toolchain_providers: Vec::new(), + keymap_import_providers: Vec::new(), executable_path, arguments: Vec::new(), environment: BTreeMap::from([( @@ -13733,6 +13916,7 @@ public class Main { plugin_id: None, host_services: Vec::new(), toolchain_providers: Vec::new(), + keymap_import_providers: Vec::new(), executable_path: "/bin/server".to_string(), arguments: Vec::new(), environment: BTreeMap::new(), @@ -14687,6 +14871,189 @@ public class Main { assert!(harness.server.await_notification("$/cancelRequest")); } + #[test] + fn keymap_import_negotiation_is_available_only_to_plugin_owned_sessions() { + for plugin_owned in [false, true] { + let mut harness = Harness::start(|request| { + request.keymap_import_providers = vec!["fixture".to_string()]; + if plugin_owned { + request.plugin_id = Some("dev.lithe.fixture.import".to_string()); + } + }); + harness.server.complete_initialize(json!({"experimental": { + "litheKeymapImport": {"version": 1, "providers": ["fixture"]} + }})); + harness.await_state(LspLifecycleState::Ready); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .unwrap(); + let offer = + initialize["params"]["capabilities"]["experimental"].get("litheKeymapImport"); + assert_eq!(offer.is_some(), plugin_owned); + if plugin_owned { + assert_eq!( + offer, + Some(&json!({"versions": [1], "providers": ["fixture"]})) + ); + } + let event = harness + .events + .iter() + .find(|event| event.kind == "keymapImportProviders"); + assert_eq!(event.is_some(), plugin_owned); + if let Some(event) = event { + assert_eq!(event.result, Some(json!({"providers": ["fixture"]}))); + } + } + } + + #[test] + fn keymap_import_provider_cannot_select_an_undeclared_identity() { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.import".to_string()); + request.keymap_import_providers = vec!["fixture".to_string()]; + }); + harness.server.complete_initialize(json!({"experimental": { + "litheKeymapImport": {"version": 1, "providers": ["fixture", "other"]} + }})); + harness.await_state(LspLifecycleState::Ready); + assert!(harness + .events + .iter() + .all(|event| event.kind != "keymapImportProviders")); + } + + #[test] + fn keymap_import_request_only_reaches_its_negotiated_provider() { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.import".to_string()); + request.keymap_import_providers = vec!["fixture".to_string()]; + }); + harness.server.complete_initialize(json!({"experimental": { + "litheKeymapImport": {"version": 1, "providers": ["fixture"]} + }})); + harness.await_state(LspLifecycleState::Ready); + let request = |provider: &str| KeymapImportProviderRequest { + session_id: harness.session_id.clone(), + operation_id: None, + provider_id: provider.to_string(), + snapshot_id: "snapshot-1".to_string(), + timeout_milliseconds: Some(1_000), + }; + assert!(harness + .session() + .keymap_import_request(request("other"), "refused".to_string()) + .is_err()); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/keymap/import")); + harness + .session() + .keymap_import_request(request("fixture"), "import-1".to_string()) + .unwrap(); + let id = harness.server.await_request("lithe/keymap/import").unwrap(); + let wire_id: u64 = id.parse().unwrap(); + let sent = harness.written_request(&id); + assert_eq!( + sent["params"], + json!({"providerID": "fixture", "snapshotID": "snapshot-1"}) + ); + let response = json!({"jsonrpc": "2.0", "id": wire_id, "result": {"bindings": []}}); + harness.server.send(response.clone()); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" && event.operation_id.as_deref() == Some("import-1") + }); + assert_eq!( + event.result, + Some(json!({"value": {"id": wire_id, "response": response}})) + ); + } + + #[test] + fn keymap_import_request_rejects_paths_and_can_be_cancelled() { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.import".to_string()); + request.keymap_import_providers = vec!["fixture".to_string()]; + }); + harness.server.complete_initialize(json!({"experimental": { + "litheKeymapImport": {"version": 1, "providers": ["fixture"]} + }})); + harness.await_state(LspLifecycleState::Ready); + let mut request = KeymapImportProviderRequest { + session_id: harness.session_id.clone(), + operation_id: None, + provider_id: "fixture".to_string(), + snapshot_id: "../private".to_string(), + timeout_milliseconds: None, + }; + assert!(harness + .session() + .keymap_import_request(request.clone(), "invalid".to_string()) + .is_err()); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/keymap/import")); + request.snapshot_id = "snapshot-1".to_string(); + harness + .session() + .keymap_import_request(request, "import-2".to_string()) + .unwrap(); + harness.server.await_request("lithe/keymap/import").unwrap(); + harness.session().cancel_operation("import-2").unwrap(); + assert!(harness.server.await_notification("$/cancelRequest")); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" && event.operation_id.as_deref() == Some("import-2") + }); + assert_eq!( + event.error.as_ref().map(|error| error.code.as_str()), + Some("requestCancelled") + ); + assert!(event.result.is_none()); + } + + #[test] + fn keymap_import_request_rejects_oversized_provider_replies() { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.import".to_string()); + request.keymap_import_providers = vec!["fixture".to_string()]; + }); + harness.server.complete_initialize(json!({"experimental": { + "litheKeymapImport": {"version": 1, "providers": ["fixture"]} + }})); + harness.await_state(LspLifecycleState::Ready); + let request = KeymapImportProviderRequest { + session_id: harness.session_id.clone(), + operation_id: None, + provider_id: "fixture".to_string(), + snapshot_id: "snapshot-1".to_string(), + timeout_milliseconds: Some(5_000), + }; + harness + .session() + .keymap_import_request(request, "import-3".to_string()) + .unwrap(); + let id = harness.server.await_request("lithe/keymap/import").unwrap(); + harness + .server + .send(json!({"jsonrpc": "2.0", "id": id.parse::().unwrap(), + "result": {"data": "x".repeat(super::super::keymap_provider::MAX_REPLY_BYTES)}})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" && event.operation_id.as_deref() == Some("import-3") + }); + assert_eq!( + event.error.as_ref().map(|error| error.code.as_str()), + Some("plugin.keymapImport.replyTooLarge") + ); + assert!(event.result.is_none()); + } + // Toolchain providers against a scripted backend. Only a plugin-owned // session launched with declared providers is offered `litheToolchains`, // and only a provider its process selected receives `toolchains/query`. diff --git a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs index 011f6d3cbe..00f50e500f 100644 --- a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs +++ b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs @@ -343,6 +343,7 @@ fn real_start_request( plugin_id: None, host_services: Vec::new(), toolchain_providers: Vec::new(), + keymap_import_providers: Vec::new(), executable_path: toolchain .root .join("bin") diff --git a/rust/lithe-core/src/lsp/interface/keymap_provider.rs b/rust/lithe-core/src/lsp/interface/keymap_provider.rs new file mode 100644 index 0000000000..b1b85c1e2d --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/keymap_provider.rs @@ -0,0 +1,106 @@ +//! Negotiation for the independent shortcut import provider protocol. + +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +const EXPERIMENTAL_KEY: &str = "litheKeymapImport"; +/// Client event carrying the provider subset selected during initialization. +pub(crate) const NEGOTIATED_EVENT: &str = "keymapImportProvidersNegotiated"; +/// Host-to-provider request for one explicitly selected source snapshot. +pub(crate) const IMPORT_METHOD: &str = "lithe/keymap/import"; +/// Maximum encoded provider reply forwarded to the host for Core validation. +pub(crate) const MAX_REPLY_BYTES: usize = 1024 * 1024; + +fn valid_offer(providers: &BTreeSet) -> bool { + !providers.is_empty() + && providers.len() <= crate::plugins::keymap_import::MAX_PROVIDERS + && providers + .iter() + .all(|id| crate::plugins::keymap_import::valid_provider_id(id)) +} + +/// Offers this protocol only for a bounded set of declared provider identities. +pub(crate) fn client_experimental_capabilities( + providers: &BTreeSet, +) -> Option<(&'static str, Value)> { + valid_offer(providers).then(|| { + ( + EXPERIMENTAL_KEY, + json!({"versions": [1], "providers": providers}), + ) + }) +} + +/// Rejects the entire answer if it selects an unsupported or undeclared provider. +pub(crate) fn negotiate(capabilities: &Value, offered: &BTreeSet) -> Option> { + if !valid_offer(offered) { + return None; + } + let answer = capabilities.pointer("/experimental/litheKeymapImport")?; + if answer.get("version")?.as_u64()? != 1 { + return None; + } + let mut selected = BTreeSet::new(); + for provider in answer.get("providers")?.as_array()? { + let provider = provider.as_str()?; + if !offered.contains(provider) || !selected.insert(provider.to_owned()) { + return None; + } + } + (!selected.is_empty()).then(|| selected.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + use super::{client_experimental_capabilities, negotiate, IMPORT_METHOD}; + use serde_json::{json, Value}; + use std::collections::BTreeSet; + + #[test] + fn shortcut_providers_negotiate_only_an_offered_subset() { + let fixtures: Value = serde_json::from_str(include_str!( + "../../../../../shared/fixtures/keymap/provider-negotiation-v1.json" + )) + .unwrap(); + for case in fixtures["cases"].as_array().unwrap() { + let offered: BTreeSet = case["offered"] + .as_array() + .unwrap() + .iter() + .map(|id| id.as_str().unwrap().to_owned()) + .collect(); + let answer = json!({"experimental": {"litheKeymapImport": case["answer"]}}); + let actual = negotiate(&answer, &offered); + let expected: Option> = + serde_json::from_value(case["expected"].clone()).unwrap(); + assert_eq!(actual, expected, "{}", case["name"]); + } + assert!(client_experimental_capabilities(&BTreeSet::new()).is_none()); + let offered = BTreeSet::from(["second".to_string(), "first".to_string()]); + assert_eq!( + client_experimental_capabilities(&offered).unwrap().1, + json!({"versions": [1], "providers": ["first", "second"]}) + ); + } + + #[test] + fn malformed_or_oversized_offers_negotiate_nothing() { + let bad_id = BTreeSet::from(["../private".to_string()]); + let many: BTreeSet<_> = (0..9).map(|index| format!("provider-{index}")).collect(); + for offered in [bad_id, many] { + assert!(client_experimental_capabilities(&offered).is_none()); + let answer = json!({"experimental": {"litheKeymapImport": { + "version": 1, "providers": [offered.iter().next().unwrap()] + }}}); + assert!(negotiate(&answer, &offered).is_none()); + } + } + + #[test] + fn backend_cannot_recursively_invoke_an_import() { + assert_eq!( + super::super::plugin_function::refused_backend_request(IMPORT_METHOD), + Some(super::super::plugin_function::codes::RECURSIVE_CALL) + ); + } +} diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index a98593c149..8d5b5f0e64 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -4,6 +4,7 @@ mod build_provider; mod client; mod engine; mod engine_logging; +mod keymap_provider; pub(crate) mod plugin_function; pub(crate) mod process; pub(crate) mod remote_process; diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index e28cdc61fc..50c68da235 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -106,6 +106,7 @@ pub(crate) const HOST_PERMISSIONS: &[&str] = &[ "browser.open", "window.messages", "terminal.create", + "keymaps.readSelectedSources", ]; const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; @@ -430,7 +431,10 @@ pub(crate) fn parse_progress(params: Option<&Value>) -> Option /// requesting backend, whose handler would then wait on itself; host /// capabilities are offered per protocol version, and version 1 offers none. pub(crate) fn refused_backend_request(method: &str) -> Option<&'static str> { - if method == INVOKE_METHOD || method.starts_with("lithe/build/") { + if method == INVOKE_METHOD + || method.starts_with("lithe/build/") + || method.starts_with("lithe/keymap/") + { Some(codes::RECURSIVE_CALL) } else if method.starts_with("lithe/host/") { Some(codes::HOST_UNAVAILABLE) @@ -472,6 +476,8 @@ pub(crate) enum HostParams { Subscription, /// Exactly `{ context }`, a well-formed context reference. Context, + /// Snapshot source listing or one bounded chunk from a selected source. + KeymapSnapshot { chunk: bool }, /// Any object whose `context`, when present, is a well-formed /// reference. The service checks the rest and answers /// `plugin.host.invalidParams` or, for a field it does not know, @@ -500,6 +506,20 @@ pub(crate) struct HostMethod { /// service adds its rows here, its grant to `GRANTABLE_HOST_SERVICES` and /// the manifest permission, and its handler to the host's dispatcher. pub(crate) const HOST_METHODS: &[HostMethod] = &[ + HostMethod { + method: "lithe/host/keymapImport/listInputs", + service: "keymapImport", + permission: Some("keymaps.readSelectedSources"), + since: CONTEXT_VERSION, + params: HostParams::KeymapSnapshot { chunk: false }, + }, + HostMethod { + method: "lithe/host/keymapImport/readChunk", + service: "keymapImport", + permission: Some("keymaps.readSelectedSources"), + since: CONTEXT_VERSION, + params: HostParams::KeymapSnapshot { chunk: true }, + }, HostMethod { method: "lithe/host/terminal/reopen", service: "terminal", @@ -1034,6 +1054,14 @@ fn check_host_params( HostParams::Credential { with_secret: false } => &["key"], HostParams::Subscription => &["subscriptionID"], HostParams::Context => &["context"], + HostParams::KeymapSnapshot { chunk: false } => &["snapshotID", "providerID"], + HostParams::KeymapSnapshot { chunk: true } => &[ + "snapshotID", + "providerID", + "fileID", + "offset", + "maximumBytes", + ], HostParams::Object => { return match params.get("context") { Some(context) if !is_context_reference(context) => { @@ -1091,6 +1119,42 @@ fn check_host_params( return Err(invalid(CONTEXT_REFERENCE_MESSAGE)); } } + HostParams::KeymapSnapshot { chunk } => { + let opaque_id = |key: &str| { + params.get(key).and_then(Value::as_str).is_some_and(|id| { + !id.is_empty() + && id.len() <= 128 + && id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + }) + }; + let provider = params + .get("providerID") + .and_then(Value::as_str) + .is_some_and(crate::plugins::keymap_import::valid_provider_id); + let valid_chunk = !chunk + || (opaque_id("fileID") + && params + .get("offset") + .and_then(Value::as_u64) + .is_some_and(|offset| { + offset <= crate::plugins::keymap_import::MAX_INPUT_FILE_BYTES as u64 + }) + && params + .get("maximumBytes") + .and_then(Value::as_u64) + .is_some_and(|size| { + size > 0 + && size + <= crate::plugins::keymap_import::MAX_INPUT_CHUNK_BYTES as u64 + })); + if !opaque_id("snapshotID") || !provider || !valid_chunk { + return Err(invalid( + "Snapshot source parameters are malformed or exceed their bounds.", + )); + } + } HostParams::Empty | HostParams::Object => {} } Ok(()) @@ -1686,6 +1750,44 @@ mod tests { ); } + /// Snapshot reads are separately granted and may name only bounded opaque references. + #[test] + fn keymap_snapshot_host_requests_require_grants_and_bounded_references() { + let method = "lithe/host/keymapImport/readChunk"; + let granted = BTreeSet::from([method.to_string()]); + let valid = json!({ "snapshotID": "abc-123", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 131072 }); + assert!(matches!( + host_request(method, Some(&valid), 3, &granted), + Ok(HostRoute::Forward("keymapImport")) + )); + assert!(host_request(method, Some(&valid), 2, &granted).is_err()); + assert!(host_request(method, Some(&valid), 3, &BTreeSet::new()).is_err()); + for invalid in [ + json!({ "snapshotID": "../private", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 1 }), + json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 131073 }), + json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": -1, "maximumBytes": 1 }), + json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 1, "path": "/private" }), + ] { + assert!(host_request(method, Some(&invalid), 3, &granted).is_err()); + } + let list = "lithe/host/keymapImport/listInputs"; + let list_grant = BTreeSet::from([list.to_string()]); + assert!(host_request( + list, + Some(&json!({ "snapshotID": "abc", "providerID": "editor" })), + 3, + &list_grant + ) + .is_ok()); + assert!(host_request( + list, + Some(&json!({ "snapshotID": "abc", "providerID": "editor", "path": "secret" })), + 3, + &list_grant + ) + .is_err()); + } + /// A message over the bound is cut at a character boundary, never inside /// a multi-byte character. #[test] diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index 0687f265b3..85849c07f9 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -162,6 +162,12 @@ pub struct LspClientState { /// The offered providers the backend selected; empty until negotiated. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub toolchain_providers: Vec, + /// Manifest providers offered under `litheKeymapImport` for this session. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub offered_keymap_import_providers: BTreeSet, + /// The shortcut import providers selected by the backend. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub keymap_import_providers: Vec, } impl Default for LspClientState { @@ -185,6 +191,8 @@ impl Default for LspClientState { vcs_provider: None, offered_toolchain_providers: BTreeSet::new(), toolchain_providers: Vec::new(), + offered_keymap_import_providers: BTreeSet::new(), + keymap_import_providers: Vec::new(), } } } diff --git a/rust/lithe-core/src/plugins/backend.rs b/rust/lithe-core/src/plugins/backend.rs index 3521c3bdb0..1d768b7bd6 100644 --- a/rust/lithe-core/src/plugins/backend.rs +++ b/rust/lithe-core/src/plugins/backend.rs @@ -67,7 +67,7 @@ pub(crate) struct DeclarationContext<'a> { /// Whether the manifest lists `onWorkspaceOpen`. pub(crate) activates_on_workspace_open: bool, /// A provider capability may start this backend for a host-owned query. - pub(crate) toolchain_query_trigger: bool, + pub(crate) provider_query_trigger: bool, } impl BackendDeclaration { @@ -80,7 +80,7 @@ impl BackendDeclaration { } // Only the plugin's own pages call a backend in version 1, so one // without a web window could never start. Version 2 may also start - // with a workspace, a command or an admitted toolchain query trigger. + // with a workspace, a command or an admitted provider query trigger. if context.version < WORKSPACE_CONTEXT_VERSION { if !context.has_web_window { return Err(invalid( @@ -91,11 +91,11 @@ impl BackendDeclaration { } else if !context.has_web_window && !context.has_command && !context.activates_on_workspace_open - && !context.toolchain_query_trigger + && !context.provider_query_trigger { return Err(invalid( "backend", - "A plugin backend needs an admitted page, command, workspace or toolchain query trigger.", + "A plugin backend needs an admitted page, command, workspace or provider query trigger.", )); } self.process.validate( diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 5814d9a5b6..bb180293f2 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -69,6 +69,8 @@ pub(crate) const DEBUG_ADAPTERS_API_VERSION: u32 = 4; /// Shared executable candidate order and strict explicit selection. pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; +/// Independent providers that translate external shortcut settings. +pub(crate) const KEYMAP_IMPORT_PROVIDERS: &str = "keymapImportProviders"; /// Plugin-owned tool identities, discovery policy and executable candidates. pub(crate) const TOOL_DEFINITIONS: &str = "toolDefinitions"; /// Backend facts that can influence the executable selected for a defined tool. @@ -84,6 +86,7 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (DEBUG_ADAPTERS, &[1, 2]), (EXECUTION_PROVIDERS, &[1]), (EXECUTABLE_RESOLUTION, &[1, 2]), + (KEYMAP_IMPORT_PROVIDERS, &[1]), (LANGUAGE_SERVERS, &[1, 2]), (LANGUAGES, &[1, 2]), (PACKAGE_TRUST, &[1]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 831589d8ba..52224e91ac 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -54,6 +54,10 @@ const PERMISSION_PROJECT_GENERATE: &str = "project.generate"; const PERMISSION_BACKEND: &str = "backend.launch"; /// Supplying facts that can change the executable selected for a defined tool. const PERMISSION_TOOLCHAINS: &str = "toolchains.provide"; +/// Providing normalized shortcuts during an explicit import. +const PERMISSION_KEYMAPS: &str = "keymaps.provide"; +/// Reading declared files from the explicitly selected import source. +const PERMISSION_KEYMAP_SOURCES: &str = "keymaps.readSelectedSources"; /// Letting the backend keep secrets in the host's credential store, private /// to the plugin. `backend` 1. const PERMISSION_CREDENTIALS: &str = "credentials.store"; @@ -71,6 +75,8 @@ const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_DEBUG_ATTACH, PERMISSION_DEBUG_LAUNCH, PERMISSION_LANGUAGE_SERVER, + PERMISSION_KEYMAPS, + PERMISSION_KEYMAP_SOURCES, PERMISSION_PROJECT_GENERATE, PERMISSION_RUN, PERMISSION_TEST, @@ -216,6 +222,9 @@ pub struct DeclarativePluginManifest { /// Fact providers served by the plugin's existing backend process. #[serde(default, skip_serializing_if = "Vec::is_empty")] toolchain_providers: Vec, + /// Independent shortcut importers implemented by this plugin's backend. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + keymap_import_providers: Vec, } /// UI supplied by plugins. The host owns every window shell: docking, title, @@ -812,7 +821,11 @@ pub fn validate_manifest_value(value: Value) -> Validation Validation Validation<()> { + if self.plugin_api_version < CAPABILITIES_API_VERSION || self.backend_version() != 2 { + return Err(invalid( + "requires.backend", + "Providers require plugin API 3 and backend 2.", + )); + } + let backend = self + .backend + .as_ref() + .ok_or_else(|| invalid("backend", "A provider requires the plugin backend."))?; + let value = serde_json::to_value(backend) + .map_err(|_| invalid("backend", "The backend cannot be encoded."))?; + let launch = &value["launch"]; + let packaged = launch["kind"] == "packaged" + || (launch["kind"] == "runtime" && launch["packagedRuntime"].as_str().is_some()); + if !packaged + || value["runtimeDependencies"] + .as_array() + .is_some_and(|values| !values.is_empty()) + { + return Err(invalid("backend.launch", "Provider backends must bootstrap from packaged executables or packaged runtimes without discovered dependencies.")); } Ok(()) } @@ -1486,6 +1514,17 @@ impl DeclarativePluginManifest { "toolchains.provide requires a toolchain provider.", )); } + if !self.keymap_import_providers.is_empty() { + required.insert(PERMISSION_KEYMAPS); + required.insert(PERMISSION_KEYMAP_SOURCES); + } else if self.permissions.iter().any(|permission| { + permission == PERMISSION_KEYMAPS || permission == PERMISSION_KEYMAP_SOURCES + }) { + return Err(invalid( + "permissions", + "Shortcut import permissions require a shortcut import provider.", + )); + } if !self.vcs_providers.is_empty() { required.insert(PERMISSION_VCS); } diff --git a/rust/lithe-core/src/plugins/keymap_import.rs b/rust/lithe-core/src/plugins/keymap_import.rs new file mode 100644 index 0000000000..57041ad330 --- /dev/null +++ b/rust/lithe-core/src/plugins/keymap_import.rs @@ -0,0 +1,673 @@ +//! Shared validation for normalized shortcut import provider replies. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +/// Maximum number of import providers one plugin may declare or negotiate. +pub(crate) const MAX_PROVIDERS: usize = 8; +const MAX_BYTES: usize = 1024 * 1024; +const MAX_COMMANDS: usize = 4096; +const MAX_KEYS_PER_COMMAND: usize = 8; +const MAX_INPUT_SELECTORS: usize = 16; +const MAX_RECOGNITION_PROBES: usize = 16; +const MAX_INPUT_FILES: usize = 128; +pub(crate) const MAX_INPUT_FILE_BYTES: usize = 1_048_576; +const MAX_INPUT_TOTAL_BYTES: usize = 8_388_608; +pub(crate) const MAX_INPUT_CHUNK_BYTES: usize = 131_072; +const MAX_DIRECTORY_ENTRIES: usize = 4_096; +const MAX_DIRECTORY_MATCHES: usize = 64; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One plugin-owned importer; the identifier is local to its package. +pub(crate) struct ProviderDeclaration { + id: String, + display_name: String, + recognition: Vec, + inputs: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum RecognitionProbe { + File { + path: String, + }, + Directory { + path: String, + }, + Root { + base: RecognitionBase, + components: Vec, + }, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +enum RecognitionBase { + UserConfig, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum InputSelector { + File { path: String }, + Directory { path: String, extension: String }, +} + +fn safe_relative_path(path: &str) -> bool { + !path.is_empty() + && path.chars().count() <= 256 + && !path + .chars() + .any(|character| character.is_control() || "\\:*?".contains(character)) + && path.split('/').all(|part| { + !part.is_empty() && part != "." && part != ".." && !part.ends_with(['.', ' ']) + }) +} + +fn valid_selector(selector: &InputSelector) -> bool { + match selector { + InputSelector::File { path } => safe_relative_path(path), + InputSelector::Directory { path, extension } => { + safe_relative_path(path) + && !extension.is_empty() + && extension.len() <= 16 + && extension + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + } + } +} + +/// Plugin-local provider IDs are opaque and cannot be paths. +pub(crate) fn valid_provider_id(id: &str) -> bool { + id.len() <= 64 + && id.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +/// Validates and orders provider declarations without knowing their source formats. +pub(crate) fn validate_declarations( + providers: &mut [ProviderDeclaration], +) -> super::declarative::Validation<()> { + let mut ids = BTreeSet::new(); + for provider in providers.iter() { + if !valid_provider_id(&provider.id) + || !ids.insert(&provider.id) + || provider.display_name.trim().is_empty() + || provider.display_name.chars().count() > 64 + || provider.display_name.chars().any(char::is_control) + || provider.recognition.is_empty() + || provider.recognition.len() > MAX_RECOGNITION_PROBES + || provider.inputs.is_empty() + || provider.inputs.len() > MAX_INPUT_SELECTORS + { + return Err(super::declarative::invalid( + "keymapImportProviders", + "Invalid or duplicate shortcut import provider.", + )); + } + let mut probes = BTreeSet::new(); + if provider.recognition.iter().any(|probe| { + let valid = match probe { + RecognitionProbe::File { path } | RecognitionProbe::Directory { path } => { + safe_relative_path(path) + } + RecognitionProbe::Root { components, .. } => { + !components.is_empty() + && components.len() <= 8 + && components + .iter() + .map(|part| part.chars().count()) + .sum::() + <= 256 + && components + .iter() + .filter(|part| part.as_str() == "*") + .count() + <= 1 + && components.first().is_some_and(|part| part != "*") + && components + .iter() + .position(|part| part == "*") + .map_or(true, |index| index + 1 == components.len()) + && components.iter().all(|part| { + part == "*" || (!part.contains('/') && safe_relative_path(part)) + }) + } + }; + !valid || !probes.insert(probe) + }) { + return Err(super::declarative::invalid( + "keymapImportProviders.recognition", + "Invalid or duplicate shortcut source recognition probe.", + )); + } + let mut inputs = BTreeSet::new(); + if provider + .inputs + .iter() + .any(|input| !valid_selector(input) || !inputs.insert(input)) + { + return Err(super::declarative::invalid( + "keymapImportProviders.inputs", + "Invalid or duplicate shortcut source selector.", + )); + } + } + providers.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + #[serde(rename = "commandIDs")] + command_ids: Vec, + reply: Reply, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Reply { + bindings: Vec, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct CommandBindings { + #[serde(rename = "commandID")] + command_id: String, + keys: Vec, +} + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +struct Shortcut { + key: String, + modifiers: Vec, +} + +#[derive(Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +enum Modifier { + Control, + Alt, + Shift, + Meta, +} + +fn invalid() -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid shortcut import provider reply.", + ) +} + +fn identifier(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 128 && !value.chars().any(char::is_control) +} + +fn supported_key(key: &str) -> bool { + if key.len() == 1 { + return key.as_bytes()[0].is_ascii_graphic() && !key.as_bytes()[0].is_ascii_uppercase(); + } + if let Some(number) = key + .strip_prefix('f') + .and_then(|value| value.parse::().ok()) + { + return (1..=24).contains(&number) && key == format!("f{number}"); + } + matches!( + key, + "up" | "down" + | "left" + | "right" + | "return" + | "tab" + | "space" + | "backspace" + | "delete" + | "escape" + | "pageup" + | "pagedown" + | "home" + | "end" + | "insert" + ) +} + +/// Validates an untrusted provider reply against the host's current action IDs. +/// This does not inspect source formats, resolve conditions or apply the bindings. +pub(crate) fn decode_command(payload: Value) -> Result { + if serde_json::to_vec(&payload).map_err(|_| invalid())?.len() > MAX_BYTES { + return Err(invalid()); + } + let mut request: Request = serde_json::from_value(payload).map_err(|_| invalid())?; + if request.command_ids.len() > MAX_COMMANDS || request.reply.bindings.len() > MAX_COMMANDS { + return Err(invalid()); + } + let mut allowed = BTreeSet::new(); + for id in &request.command_ids { + if !identifier(id) || !allowed.insert(id) { + return Err(invalid()); + } + } + let mut seen = BTreeSet::new(); + for entry in &mut request.reply.bindings { + if !allowed.contains(&entry.command_id) + || !seen.insert(&entry.command_id) + || entry.keys.len() > MAX_KEYS_PER_COMMAND + { + return Err(invalid()); + } + let mut keys = BTreeSet::new(); + for key in &mut entry.keys { + if !supported_key(&key.key) || key.modifiers.len() > 4 { + return Err(invalid()); + } + key.modifiers.sort(); + if key.modifiers.windows(2).any(|pair| pair[0] == pair[1]) + || (key.key.len() == 1 || key.key == "space") + && !key.modifiers.iter().any(|modifier| { + matches!(modifier, Modifier::Control | Modifier::Alt | Modifier::Meta) + }) + || !keys.insert(key.clone()) + { + return Err(invalid()); + } + } + } + // Action ordering is canonical, but key order determines the primary binding. + request + .reply + .bindings + .sort_by(|a, b| a.command_id.cmp(&b.command_id)); + serde_json::to_value(request.reply).map_err(|_| invalid()) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BindingPlanRequest { + current: Vec, + incoming: Reply, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CurrentBindings { + #[serde(rename = "commandID")] + command_id: String, + customized: bool, + keys: Vec, +} + +/// Plans only the keys displaced by an import; existing custom owners and +/// duplicate imported claims are conflicts. The host applies the result only +/// after rechecking the current settings and source revisions. +pub(crate) fn plan_bindings_command(payload: Value) -> Result { + if serde_json::to_vec(&payload).map_err(|_| invalid())?.len() > MAX_BYTES { + return Err(invalid()); + } + let mut request: BindingPlanRequest = serde_json::from_value(payload).map_err(|_| invalid())?; + if request.current.len() > MAX_COMMANDS { + return Err(invalid()); + } + let mut ids = BTreeSet::new(); + let mut owners: BTreeMap> = BTreeMap::new(); + for entry in &mut request.current { + if !identifier(&entry.command_id) + || !ids.insert(entry.command_id.clone()) + || entry.keys.len() > MAX_KEYS_PER_COMMAND + { + return Err(invalid()); + } + let mut seen = BTreeSet::new(); + for key in &mut entry.keys { + // Existing native bindings may use keys outside the portable + // import vocabulary. Keep them as opaque, bounded facts: only + // an exact match with a validated imported key can be displaced. + if !identifier(&key.key) || key.modifiers.len() > 4 { + return Err(invalid()); + } + key.modifiers.sort(); + if key.modifiers.windows(2).any(|pair| pair[0] == pair[1]) || !seen.insert(key.clone()) + { + return Err(invalid()); + } + owners + .entry(key.clone()) + .or_default() + .push((entry.command_id.clone(), entry.customized)); + } + } + let validated = + decode_command(serde_json::json!({ "commandIDs": ids, "reply": request.incoming }))?; + let incoming: Reply = serde_json::from_value(validated).map_err(|_| invalid())?; + let replacing: BTreeSet<_> = incoming + .bindings + .iter() + .map(|entry| entry.command_id.as_str()) + .collect(); + let mut imported_owners: BTreeMap = BTreeMap::new(); + let mut conflicts: BTreeSet<(String, String, Shortcut)> = BTreeSet::new(); + let mut displaced: BTreeMap> = BTreeMap::new(); + let mut comparisons = 0usize; + for entry in &incoming.bindings { + for key in &entry.keys { + if let Some(first) = imported_owners.get(key) { + conflicts.insert((entry.command_id.clone(), first.clone(), key.clone())); + if conflicts.len() > MAX_COMMANDS { + return Err(invalid()); + } + } else { + imported_owners.insert(key.clone(), entry.command_id.clone()); + } + for (owner, customized) in owners.get(key).into_iter().flatten() { + comparisons += 1; + if comparisons > MAX_COMMANDS * MAX_KEYS_PER_COMMAND * 4 { + return Err(invalid()); + } + if replacing.contains(owner.as_str()) { + continue; + } + if *customized { + conflicts.insert((entry.command_id.clone(), owner.clone(), key.clone())); + } else { + displaced + .entry(owner.clone()) + .or_default() + .insert(key.clone()); + } + if conflicts.len() > MAX_COMMANDS { + return Err(invalid()); + } + } + } + } + // A conflicting import has no partial plan the host could accidentally apply. + if !conflicts.is_empty() { + displaced.clear(); + } + let displaced: Vec<_> = displaced + .into_iter() + .map(|(command_id, keys)| serde_json::json!({ "commandID": command_id, "keys": keys })) + .collect(); + let conflicts: Vec<_> = conflicts.into_iter().map(|(command_id, owner_id, key)| { + serde_json::json!({ "commandID": command_id, "ownerID": owner_id, "key": key }) + }).collect(); + Ok(serde_json::json!({ "displaced": displaced, "conflicts": conflicts })) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SourcePlanRequest { + manifest: Value, + #[serde(rename = "providerID")] + provider_id: String, +} + +/// Plans the declared inputs for a plugin-local import provider. The caller +/// supplies a previously accepted manifest; this operation also validates it. +pub(crate) fn plan_sources_command(payload: Value) -> Result { + let bad_plan = || { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid shortcut import source plan.", + ) + }; + if serde_json::to_vec(&payload).map_err(|_| bad_plan())?.len() > MAX_BYTES { + return Err(bad_plan()); + } + let request: SourcePlanRequest = serde_json::from_value(payload).map_err(|_| bad_plan())?; + if !valid_provider_id(&request.provider_id) { + return Err(bad_plan()); + } + let manifest = + super::declarative::validate_manifest_value(request.manifest).map_err(|_| bad_plan())?; + let manifest = serde_json::to_value(manifest).map_err(|_| bad_plan())?; + let provider = manifest["keymapImportProviders"] + .as_array() + .and_then(|providers| { + providers + .iter() + .find(|provider| provider["id"] == request.provider_id) + }) + .ok_or_else(bad_plan)?; + Ok(serde_json::json!({ + "inputs": provider["inputs"], + "recognition": provider["recognition"], + "limits": { + "selectors": MAX_INPUT_SELECTORS, "probes": MAX_RECOGNITION_PROBES, "files": MAX_INPUT_FILES, + "fileBytes": MAX_INPUT_FILE_BYTES, "totalBytes": MAX_INPUT_TOTAL_BYTES, + "chunkBytes": MAX_INPUT_CHUNK_BYTES, "directoryEntries": MAX_DIRECTORY_ENTRIES, + "directoryMatches": MAX_DIRECTORY_MATCHES + } + })) +} + +#[cfg(test)] +mod tests { + use super::{decode_command, plan_sources_command}; + use serde_json::{json, Value}; + + #[test] + fn import_binding_plans_follow_shared_fixtures() { + let fixtures: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/keymap/import-plan-v1.json" + )) + .unwrap(); + for case in fixtures["cases"].as_array().unwrap() { + let request = json!({"id": "fixture", "command": "keymap.planImportBindings", "payload": case["request"]}); + let response: Value = + serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); + if let Some(expected) = case.get("expected") { + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(&response["data"], expected, "{}", case["name"]); + } else { + assert_eq!(response["ok"], false, "{}", case["name"]); + assert!(response.get("data").is_none()); + } + } + } + + #[test] + fn normalized_import_provider_replies_follow_shared_fixtures() { + let fixtures: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/keymap/import-provider-v1.json" + )) + .unwrap(); + for case in fixtures["cases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let actual = decode_command(case["request"].clone()); + if let Some(expected) = case.get("expected") { + assert_eq!( + actual.unwrap_or_else(|e| panic!("{name}: {e:?}")), + *expected, + "{name}" + ); + } else { + assert!(actual.is_err(), "{name}"); + } + } + } + + #[test] + fn dispatcher_returns_the_validated_reply_without_partial_results() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/keymap/import-provider-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let request = json!({"id": "fixture", "command": "keymap.decodeImport", "payload": case["request"]}); + let response: Value = + serde_json::from_str(&crate::execute_json(&request.to_string())).unwrap(); + assert_eq!(response["id"], "fixture"); + match case.get("expected") { + Some(expected) => { + assert_eq!(response["ok"], true, "{}", case["name"]); + assert_eq!(&response["data"], expected, "{}", case["name"]); + } + None => { + assert_eq!(response["ok"], false, "{}", case["name"]); + assert_eq!(response["error"]["code"], "invalid_request"); + assert!(response.get("data").is_none()); + } + } + } + } + + #[test] + fn provider_reply_limits_are_checked_before_application() { + let keys: Vec = (1..=9) + .map(|number| json!({"key": format!("f{number}"), "modifiers": []})) + .collect(); + let mut request = json!({"commandIDs": ["find-next"], "reply": {"bindings": [ + {"commandID": "find-next", "keys": keys} + ]}}); + assert!(decode_command(request.clone()).is_err()); + let _ = request["reply"]["bindings"][0]["keys"] + .as_array_mut() + .unwrap() + .pop(); + assert!(decode_command(request).is_ok()); + + let oversized = "a".repeat(1024 * 1024); + assert!( + decode_command(json!({"commandIDs": ["find-next"], "reply": {"bindings": [ + {"commandID": "find-next", "keys": [{"key": oversized, "modifiers": ["meta"]}]} + ]}})) + .is_err() + ); + } + + #[test] + fn import_provider_manifest_is_generic_bounded_and_permission_checked() { + let plugin: Value = serde_json::from_str(include_str!( + "../../../../shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json" + )) + .unwrap(); + let validate = crate::plugins::declarative::validate_manifest_value; + assert!(validate(plugin.clone()).is_ok()); + let old_host = json!({"backend": [1, 2], "serverProcess": [1]}); + let new_host = + json!({"backend": [1, 2], "serverProcess": [1], "keymapImportProviders": [1]}); + let command = crate::plugins::declarative::validate_command; + let rejected = command(json!({"manifest": plugin, "hostCapabilities": old_host})).unwrap(); + assert_eq!(rejected["status"], "rejected"); + assert_eq!(rejected["error"]["code"], "plugin.api.unsupported"); + let accepted = command(json!({"manifest": plugin, "hostCapabilities": new_host})).unwrap(); + assert_eq!(accepted["status"], "ok"); + + let mut duplicate = plugin.clone(); + duplicate["keymapImportProviders"][1]["id"] = json!("first"); + assert!(validate(duplicate).is_err()); + let mut malformed = plugin.clone(); + malformed["keymapImportProviders"][0]["id"] = json!("../private"); + assert!(validate(malformed).is_err()); + let mut traversal = plugin.clone(); + traversal["keymapImportProviders"][0]["inputs"][0]["path"] = json!("../credentials"); + assert!(validate(traversal).is_err()); + let mut wildcard = plugin.clone(); + wildcard["keymapImportProviders"][1]["inputs"][1]["extension"] = json!("*"); + assert!(validate(wildcard).is_err()); + let mut duplicate_inputs = plugin.clone(); + duplicate_inputs["keymapImportProviders"][0]["inputs"] = json!([ + {"kind": "file", "path": "bindings.json"}, + {"kind": "file", "path": "bindings.json"} + ]); + assert!(validate(duplicate_inputs).is_err()); + let mut no_recognition = plugin.clone(); + no_recognition["keymapImportProviders"][0]["recognition"] = json!([]); + assert!(validate(no_recognition).is_err()); + let mut duplicate_recognition = plugin.clone(); + duplicate_recognition["keymapImportProviders"][0]["recognition"] = json!([ + {"kind": "file", "path": "bindings.json"}, {"kind": "file", "path": "bindings.json"} + ]); + assert!(validate(duplicate_recognition).is_err()); + let mut bad_root = plugin.clone(); + bad_root["keymapImportProviders"][0]["recognition"][1]["components"] = + json!(["..", "User"]); + assert!(validate(bad_root).is_err()); + let mut bad_pattern = plugin.clone(); + bad_pattern["keymapImportProviders"][0]["recognition"][1]["components"] = + json!(["First*", "User"]); + assert!(validate(bad_pattern).is_err()); + let mut too_broad = plugin.clone(); + too_broad["keymapImportProviders"][0]["recognition"][1]["components"] = json!(["*", "*"]); + assert!(validate(too_broad).is_err()); + let mut unanchored = plugin.clone(); + unanchored["keymapImportProviders"][0]["recognition"][1]["components"] = + json!(["*", "User"]); + assert!(validate(unanchored).is_err()); + let mut international = plugin.clone(); + international["keymapImportProviders"][0]["displayName"] = json!("编".repeat(64)); + assert!(validate(international.clone()).is_ok()); + international["keymapImportProviders"][0]["displayName"] = json!("编".repeat(65)); + assert!(validate(international).is_err()); + let mut no_grant = plugin.clone(); + no_grant["permissions"] = json!(["backend.launch"]); + assert_eq!( + validate(no_grant).unwrap_err().code, + "plugin.permission.undeclared" + ); + let mut no_source_grant = plugin.clone(); + no_source_grant["permissions"] = json!(["backend.launch", "keymaps.provide"]); + assert_eq!( + validate(no_source_grant).unwrap_err().code, + "plugin.permission.undeclared" + ); + let mut old_backend = plugin.clone(); + old_backend["requires"]["backend"] = json!(1); + assert!(validate(old_backend).is_err()); + let mut unsafe_launch = plugin.clone(); + unsafe_launch["backend"]["launch"] = + json!({"kind": "runtime", "tool": "node", "entry": "main.mjs"}); + assert!(validate(unsafe_launch).is_err()); + let mut too_many = plugin; + too_many["keymapImportProviders"] = json!((0..9) + .map(|i| json!({"id": format!("provider-{i}"), "displayName": "Provider"})) + .collect::>()); + assert!(validate(too_many).is_err()); + } + + #[test] + fn source_plans_only_expose_inputs_of_the_requested_provider() { + let plugin: Value = serde_json::from_str(include_str!( + "../../../../shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json" + )) + .unwrap(); + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/keymap/source-plans-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let actual = + plan_sources_command(json!({"manifest": plugin, "providerID": case["providerID"]})); + if case["expected"].is_null() { + assert!(actual.is_err()); + } else { + let actual = actual.unwrap(); + assert_eq!(actual["inputs"], case["expected"]["inputs"]); + assert_eq!(actual["recognition"], case["expected"]["recognition"]); + assert_eq!(actual["limits"], fixture["limits"]); + } + } + let mut invalid = plugin; + invalid["keymapImportProviders"][0]["inputs"][0]["path"] = json!("../credentials"); + assert!( + plan_sources_command(json!({"manifest": invalid, "providerID": "second"})).is_err() + ); + let mut invalid: Value = serde_json::from_str(include_str!( + "../../../../shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json" + )) + .unwrap(); + invalid["keymapImportProviders"][1]["recognition"][0]["path"] = json!("../private"); + assert!(plan_sources_command(json!({"manifest": invalid, "providerID": "first"})).is_err()); + } +} diff --git a/rust/lithe-core/src/plugins/mod.rs b/rust/lithe-core/src/plugins/mod.rs index da3fd6006b..0ccdacbd88 100644 --- a/rust/lithe-core/src/plugins/mod.rs +++ b/rust/lithe-core/src/plugins/mod.rs @@ -7,6 +7,7 @@ pub mod declarative; pub mod editor_actions; pub(crate) mod executables; pub(crate) mod grammar; +pub(crate) mod keymap_import; pub mod marketplace; pub(crate) mod native_ui; pub(crate) mod settings; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 8b0914d3be..94896c9ddd 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -116,6 +116,14 @@ pub enum CoreCommand { PluginsDeclarativeDecodeGrammar, /// Validates independent editor action registration or results. PluginsEditorActionsDecode, + /// Validates a normalized shortcut import against the host's action catalog. + KeymapDecodeImport, + /// Plans the selected provider's declared shortcut source inputs. + KeymapPlanImportSources, + /// Plans conflicts and default-key displacement for a shortcut import. + KeymapPlanImportBindings, + /// Decides whether a combined debug shortcut starts, continues or does nothing. + DebugStartOrContinue, /// Registers plugin-owned tools with verified package provenance. ToolchainsRegistry, /// Validates provider answers using native evidence supplied by the host. @@ -387,6 +395,8 @@ pub enum CoreCommand { LspVcsRequest, /// Queues one toolchain-provider query (`lsp.toolchainRequest`). LspToolchainRequest, + /// Queues a shortcut import against a negotiated provider. + LspKeymapImportRequest, /// Invokes one plugin function (`lsp.functionRequest`). LspFunctionRequest, /// Answers a plugin backend's host-service request (`lsp.hostResponse`). @@ -665,6 +675,10 @@ impl CoreCommand { } "plugins.declarative.decodeGrammar" => Some(Self::PluginsDeclarativeDecodeGrammar), "plugins.editorActions.decode" => Some(Self::PluginsEditorActionsDecode), + "keymap.decodeImport" => Some(Self::KeymapDecodeImport), + "keymap.planImportSources" => Some(Self::KeymapPlanImportSources), + "keymap.planImportBindings" => Some(Self::KeymapPlanImportBindings), + "debug.startOrContinue" => Some(Self::DebugStartOrContinue), "toolchains.registry" => Some(Self::ToolchainsRegistry), "toolchains.decodeReply" => Some(Self::ToolchainsDecodeReply), "toolchains.failureReply" => Some(Self::ToolchainsFailureReply), @@ -799,6 +813,7 @@ impl CoreCommand { "lsp.buildRequest" => Some(Self::LspBuildRequest), "lsp.vcsRequest" => Some(Self::LspVcsRequest), "lsp.toolchainRequest" => Some(Self::LspToolchainRequest), + "lsp.keymapImportRequest" => Some(Self::LspKeymapImportRequest), "lsp.functionRequest" => Some(Self::LspFunctionRequest), "lsp.hostResponse" => Some(Self::LspHostResponse), "lsp.hostNotify" => Some(Self::LspHostNotify), @@ -935,6 +950,7 @@ mod tests { "lsp.buildRequest", "lsp.vcsRequest", "lsp.toolchainRequest", + "lsp.keymapImportRequest", "lsp.functionRequest", "lsp.hostResponse", "lsp.hostNotify", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 23c116256b..033de1ba3e 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -296,6 +296,27 @@ fn execute(request: &str) -> CoreResponse { parsed.payload, crate::plugins::editor_actions::decode_command, ), + CoreCommand::KeymapDecodeImport => plugin_response( + id, + parsed.payload, + crate::plugins::keymap_import::decode_command, + ), + CoreCommand::KeymapPlanImportSources => plugin_response( + id, + parsed.payload, + crate::plugins::keymap_import::plan_sources_command, + ), + CoreCommand::KeymapPlanImportBindings => plugin_response( + id, + parsed.payload, + crate::plugins::keymap_import::plan_bindings_command, + ), + CoreCommand::DebugStartOrContinue => { + match crate::debug::start_or_continue(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::ToolchainsRegistry => { plugin_response(id, parsed.payload, crate::toolchain::registry_command) } @@ -2043,6 +2064,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspKeymapImportRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid shortcut import request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::keymap_import_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Shortcut import response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspVcsRequest => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/scripts/resources/reuse-worktree-resources.mjs b/scripts/resources/reuse-worktree-resources.mjs index d06388b158..cc41cbd0a5 100644 --- a/scripts/resources/reuse-worktree-resources.mjs +++ b/scripts/resources/reuse-worktree-resources.mjs @@ -162,7 +162,7 @@ async function requiredFile(root, relativePath, resourceID) { async function validatorArguments(resource, cachePath, targetRoot) { if (resource.validator === "cargo") { const lockPaths = []; - for (const relativePath of ["rust/Cargo.lock", "windows/tauri/src-tauri/Cargo.lock"]) { + for (const relativePath of ["rust/Cargo.lock", "windows/tauri/src-tauri/Cargo.lock", "Plugins/mac/Official/EditorKeymapImport/Cargo.lock"]) { const candidate = path.join(targetRoot, relativePath); if (await pathExists(candidate)) lockPaths.push(candidate); } @@ -433,6 +433,7 @@ async function main() { // loopback capabilities; they stay on this exclusion route, never a copy stage. // Agent edit-and-paste state is user-owned runtime data, without a copy stage. // editor-theme-assets rebuild palettes, bundled Make/CMake/Groovy grammars and notices together. + // editor-keymap-provider-build is worktree-local Cargo state and a sealed plugin, never a reusable cache. // Git-owned grammar inputs and memory-only tokenizer workers never enter a copy validator. if (excludedResources.some((resource) => resource.id === identifier)) { // Per-launch relays also own their provider admission route; they stay diff --git a/scripts/resources/test-reuse-worktree-resources.mjs b/scripts/resources/test-reuse-worktree-resources.mjs index aa171665fb..906441d263 100644 --- a/scripts/resources/test-reuse-worktree-resources.mjs +++ b/scripts/resources/test-reuse-worktree-resources.mjs @@ -8,7 +8,7 @@ import os from "node:os"; import path from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { publishDirectory } from "./reuse-worktree-resources.mjs"; +import { publishDirectory, validatorArguments } from "./reuse-worktree-resources.mjs"; const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); const reuseScript = path.join(scriptDirectory, "reuse-worktree-resources.mjs"); @@ -172,6 +172,28 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /shared-logging.*cannot be reused/); }); + await test("editor keymap provider build state cannot cross worktrees", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "editor-keymap-provider-build"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes(".artifacts/editor-keymap-provider-target/")); + assert.ok(resource.locations.includes("Plugins/mac/Official/EditorKeymapImport/target/")); + assert.ok(resource.locations.includes(".artifacts/cargo-home/git/")); + assert.match(resource.identity, /Cargo.lock.*SDK Git revision.*target architecture/); + assert.match(resource.reason, /read-only at runtime/); + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes(resource.id)); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /editor-keymap-provider-build.*cannot be reused/); + const isolated = path.join(testRoot, "keymap-lock-target"); + const lock = path.join(isolated, "Plugins/mac/Official/EditorKeymapImport/Cargo.lock"); + await fs.mkdir(path.dirname(lock), { recursive: true }); + await fs.writeFile(lock, "version = 4\n"); + assert.deepEqual(await validatorArguments({ validator: "cargo" }, "/cache", isolated), + ["--cargo-cache", "/cache", "--cargo-lock", lock]); + }); await test("shared translations are regenerated locally rather than reused", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "shared-i18n"); diff --git a/scripts/resources/verify-download-cache.mjs b/scripts/resources/verify-download-cache.mjs index 62583e25a4..a25d23cf4f 100644 --- a/scripts/resources/verify-download-cache.mjs +++ b/scripts/resources/verify-download-cache.mjs @@ -79,6 +79,7 @@ function parseArguments(argv) { options.cargoLocks.push( path.join(REPOSITORY_ROOT, "rust", "Cargo.lock"), path.join(REPOSITORY_ROOT, "windows", "tauri", "src-tauri", "Cargo.lock"), + path.join(REPOSITORY_ROOT, "Plugins", "mac", "Official", "EditorKeymapImport", "Cargo.lock"), ); } return options; diff --git a/scripts/resources/worktree-resources.json b/scripts/resources/worktree-resources.json index 4b9aae822e..c55dcb331b 100644 --- a/scripts/resources/worktree-resources.json +++ b/scripts/resources/worktree-resources.json @@ -33,6 +33,19 @@ } ], "excludedResources": [ + { + "id": "editor-keymap-provider-build", + "reusable": false, + "locations": [ + ".artifacts/editor-keymap-provider-target/", + "Plugins/mac/Official/EditorKeymapImport/target/", + ".artifacts/cargo-home/git/", + ".artifacts/keymap-plugin-assembly/", + "Lithe.app/Contents/Resources/OfficialPlugins/dev.lithe.plugin.editor-keymap-import/" + ], + "identity": "Mutable Cargo outputs and Git checkouts depend on the pinned provider Cargo.lock and SDK Git revision, rustc, platform and target architecture; they have no sealed reusable identity stamp. The installed plugin is signed as part of its app package.", + "reason": "Rebuild in each worktree using its own target and Cargo home; fetch the SDK at its pinned Git revision. Never copy or symlink mutable Cargo state, Git checkouts or an assembled plugin across worktrees. The bundled executable and licenses are written only during packaging before signing and remain read-only at runtime; import snapshots are ephemeral user-selected files outside the app bundle." + }, { "id": "ci-rust-compilation-state", "reusable": false, diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index bfcb11c952..e60e180153 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -13,6 +13,7 @@ This directory is the cross-platform source of truth: | `plugin-manifest.schema.json` | JSON shape of `plugin.json` | | `fixtures/*.plugin.json` | Valid example manifests, named after the capability they exercise rather than a published plugin: `language-*` (packaged, system-command, and host-integration language servers; `node-runtime` for a discovered runtime), `hello-lithe` and `web-panel` (UI), `settings-v2`, and `build-*` (level 3 build systems and the two forms of a build provider) | | `fixtures/tool-definition.plugin.json`, `fixtures/toolchain-provider.plugin.json` | Production-shape Core fixtures for tool definitions and a packaged toolchain provider; require explicit new host capabilities, not yet supplied by native products | +| `fixtures/keymap-import-provider.plugin.json` | Two independent shortcut import providers sharing one packaged backend; native source access is not implemented yet | | `fixtures/invalid-manifests.json` | Edits to a valid manifest and the rejection code every host must report | | `fixtures/command-plans.json` | Golden run/test command expansions and language-server launch plans | | `fixtures/runtime-versions.json` | Golden outcomes of the launch runtime version check | @@ -65,6 +66,69 @@ unsupported, and current Java/Maven/Build/Run selection is unchanged. Version-ma implementations such as mise belong to their own plugin packages. Proposal envelopes remain separate from production manifests. See [the implemented slice](toolchains.md). +## Shortcut import providers (Core foundation) + +`keymapImportProviders` contains one to eight `{id, displayName, recognition, inputs}` entries. +IDs are unique within the plugin, start with a lowercase letter, contain only +lowercase ASCII letters, digits and hyphens, and are at most 64 bytes. The +plugin requires `keymapImportProviders: 1`, `backend: 2`, `serverProcess: 1`, +`backend.launch`, `keymaps.provide` and `keymaps.readSelectedSources`. Its backend starts from a packaged +executable or packaged runtime without discovered runtime dependencies. The +host starts it for an explicit import; it is not a startup or workspace-open +activation event. The permission permits offering mappings; it grants no file +access on its own. `keymaps.readSelectedSources` authorizes only the provider's +declared inputs under the source directory the user explicitly selected for this +import, and only for the lifetime of that import. + +`recognition` lists one to sixteen distinct metadata-only probes. A +`{kind: "file" | "directory", path}` probe matches an entry of that kind +beneath the selected root, using the input path restrictions and refusing +symlinks. A `{kind: "root", base: "userConfig", components}` probe matches +the selected directory's exact path beneath the platform's user configuration +directory (macOS Application Support, Windows roaming AppData). Components +contain one to eight safe literal names and may end in one `*`, matching +exactly one directory component; the first component must be literal. The +source matches any probe. Recognition never authorizes reading file contents +or discloses the absolute path to the provider. A host may use probes without +launching backends, and must recheck them on import. + +`inputs` lists one to sixteen unique selectors. `{kind: "file", path}` selects +one relative file; `{kind: "directory", path, extension}` selects immediate +children of that directory with the exact lowercase alphanumeric extension, +never recursive descendants. Paths have at most 256 characters, contain no +empty, `.` or `..` segments, backslashes, colons, wildcards, control characters, +or segments ending in a dot or space. Directory selectors have at most 64 +matches after scanning at most 4096 directory entries. The native reader must reject symlinks and paths escaping the +selected root, bound each file to 1 MiB and the snapshot to 8 MiB, and expose +only scoped opaque handles in chunks of at most 128 KiB. A manifest cannot +authorize arbitrary paths or access an unselected source. + +Core offers `experimental.litheKeymapImport: {versions: [1], providers}` only +to a plugin-owned session with declared providers. The backend selects a +nonempty duplicate-free subset as `{version: 1, providers}`. A request to an +unselected provider is refused. `lithe/keymap/import` carries only +`{providerID, snapshotID}`. The snapshot is an opaque host-owned reference; +the provider never interprets it as a path. Core bounds the operation and +validates normalized results against the host's action catalog. Providers own +source formats and must not silently promote conditional or multi-stroke rules +to global shortcuts. The exact Core commands, limits and fixtures are in the +[Rust Core API](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/rust-core-api.md#normalized-shortcut-import-validation). + +When granted individually, protocol 3 methods `lithe/host/keymapImport/listInputs` +and `lithe/host/keymapImport/readChunk` take `snapshotID` and `providerID`. +Listing returns `{files: [{id, path, byteCount}]}`; a read additionally takes +`fileID`, a nonnegative `offset` and `maximumBytes` from 1 through 131072 and +returns `{data}` containing base64 bytes. Paths are relative to the selected +root and file IDs are opaque. The host binds snapshots to their plugin, session +and provider, revokes them when the import or session ends, and rejects files +replaced or changed after capture. A new snapshot for that provider revokes the +previous one. Errors never disclose absolute paths. + +This is a foundation, not an available product extension: macOS implements the +scoped reader, but provider discovery, the product import workflow and Windows +source access are not implemented. Hosts must not advertise this capability +until they implement the complete workflow. No existing SDK advertises it. + ## Package layout ```text @@ -99,6 +163,7 @@ symbolic links. A host treats an installed package as read-only. | `buildSystems` | Project-level build tools (level 3). | | `toolDefinitions` | Plugin-owned tool policy ([Core foundation](toolchains.md)); requires `toolDefinitions: 1` and `executableResolution: 2`; macOS hosts read-only discovery. | | `toolchainProviders` | Backend fact-provider declarations ([Core foundation](toolchains.md)); requires `toolchainProviders: 1`, backend/server-process capabilities and `toolchains.provide`. | +| `keymapImportProviders` | Independent shortcut import providers; requires `keymapImportProviders: 1`, `backend: 2`, `serverProcess: 1` and `keymaps.provide`. macOS provides access to explicitly selected sources; Windows does not yet advertise this capability. | Unknown fields are refused (`plugin.manifest.invalid`) so an older host rejects a package instead of ignoring part of it. At levels 1 and 2 a new @@ -605,6 +670,8 @@ Rules for publishing across levels and capability versions: | `ui.contribute` | `ui` is declared | | `ui.web` | a tool window shows [web content](#web-content-ui-2) | | `backend.launch` | `backend` is declared | +| `keymaps.provide` | `keymapImportProviders` is nonempty; permits offering mappings, not reading files by itself | +| `keymaps.readSelectedSources` | `keymapImportProviders` is nonempty; permits reading only declared files in an explicitly selected import source | | `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | | `workspace.metadata` | the backend uses the [workspace host service](#workspace-contexts-and-capability-discovery-protocol-version-3); refused without `backend` 2 | | `editor.actions` | the backend registers independent editor actions; requires `backend` 2 and `documents.read`, grants only `editor/registerActions`, not document edits or domain execution | diff --git a/shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json new file mode 100644 index 0000000000..0cafa02bf5 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/keymap-import-provider.plugin.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.import", + "displayName": "Shortcut import fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": {"keymapImportProviders": 1, "backend": 2, "serverProcess": 1}, + "vendor": {"id": "org.example", "displayName": "Fixture publisher", "signatureRequirement": "publisherPackage"}, + "permissions": ["backend.launch", "keymaps.provide", "keymaps.readSelectedSources"], + "keymapImportProviders": [ + {"id": "first", "displayName": "First editor", "recognition": [{"kind": "file", "path": "bindings.json"}, {"kind": "root", "base": "userConfig", "components": ["FirstEditor", "User"]}, {"kind": "root", "base": "userConfig", "components": ["FirstEditor", "User", "profiles", "*"]}], "inputs": [{"kind": "file", "path": "bindings.json"}]}, + {"id": "second", "displayName": "Second editor", "recognition": [{"kind": "directory", "path": "keymaps"}], "inputs": [ + {"kind": "file", "path": "options/map.xml"}, + {"kind": "directory", "path": "keymaps", "extension": "xml"} + ]} + ], + "backend": {"launch": {"kind": "packaged", "path": "bin/import-provider"}, "arguments": ["--stdio"]} +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 00f1a8e4cd..6c6f5727a1 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -20,6 +20,7 @@ }, {"if": {"required": ["toolDefinitions"]}, "then": {"properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["toolDefinitions", "executableResolution"], "properties": {"executableResolution": {"const": 2}}}}}}, {"if": {"required": ["toolchainProviders"]}, "then": {"required": ["backend"], "properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["toolchainProviders", "backend", "serverProcess"], "properties": {"backend": {"const": 2}}}, "permissions": {"allOf": [{"contains": {"const": "toolchains.provide"}}, {"contains": {"const": "backend.launch"}}]}}}}, + {"if": {"required": ["keymapImportProviders"]}, "then": {"required": ["backend", "permissions"], "properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["keymapImportProviders", "backend", "serverProcess"], "properties": {"backend": {"const": 2}}}, "permissions": {"allOf": [{"contains": {"const": "keymaps.provide"}}, {"contains": {"const": "keymaps.readSelectedSources"}}, {"contains": {"const": "backend.launch"}}]}}}}, {"if": {"required": ["requires"], "properties": {"requires": {"required": ["settings"], "properties": {"settings": {"const": 2}}}}}, "else": {"properties": {"settings": {"items": {"$ref": "#/$defs/settingVersion1"}}, "settingActions": {"maxItems": 0}}}}, {"if": {"required": ["requires"], "properties": {"requires": {"required": ["languageServers"], "properties": {"languageServers": {"const": 2}}}}}, "else": {"properties": {"languageServers": {"items": {"properties": {"workspaceConfiguration": false}}}}}} ], @@ -50,6 +51,7 @@ "settings": { "description": "Required exactly when settings or settingActions is non-empty. Version 1 is the API level 2 section: integer, enum, and boolean options. Version 2 adds scope, effect, page, section, the multiSelect, text, file, and directory types, and settingActions.", "enum": [1, 2] }, "toolDefinitions": { "const": 1 }, "toolchainProviders": { "const": 1 }, + "keymapImportProviders": { "const": 1 }, "ui": { "description": "Version 2 adds web content; version 3 adds native controls; version 4 adds backend-only commands.", "enum": [1, 2, 3, 4] }, "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (shared/contracts/vcs-provider-v1.md).", "enum": [1] } } @@ -80,6 +82,8 @@ "editor.open", "preview.open", "toolchains.provide", + "keymaps.provide", + "keymaps.readSelectedSources", "browser.open", "window.messages", "terminal.create"] } @@ -94,6 +98,7 @@ }, "toolDefinitions": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/toolchainToolDefinition" } }, "toolchainProviders": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "$ref": "#/$defs/toolchainProvider" } }, + "keymapImportProviders": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "$ref": "#/$defs/keymapImportProvider" } }, "languages": { "type": "array", "minItems": 0, @@ -263,6 +268,43 @@ } }, "$defs": { + "keymapImportProvider": { + "type": "object", + "additionalProperties": false, + "required": ["id", "displayName", "recognition", "inputs"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^\\u0000-\\u001f\\u007f]+$" }, + "recognition": { "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, + "items": { "$ref": "#/$defs/keymapImportRecognition" } }, + "inputs": { "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, + "items": { "$ref": "#/$defs/keymapImportInput" } } + } + }, + "keymapImportRecognition": { + "oneOf": [ + { "type": "object", "additionalProperties": false, "required": ["kind", "path"], + "properties": { "kind": { "enum": ["file", "directory"] }, "path": { "$ref": "#/$defs/keymapImportInput/properties/path" } } }, + { "type": "object", "additionalProperties": false, "required": ["kind", "base", "components"], + "properties": { "kind": { "const": "root" }, "base": { "const": "userConfig" }, + "components": { "type": "array", "minItems": 1, "maxItems": 8, + "contains": { "const": "*" }, "minContains": 0, "maxContains": 1, + "items": { "type": "string", "maxLength": 256, "pattern": "^(?:\\*|(?!.*[. ]$)[^/\\\\:*?\\u0000-\\u001f\\u007f]+)$" } } } } + ] + }, + "keymapImportInput": { + "type": "object", "additionalProperties": false, "required": ["kind", "path"], + "properties": { + "kind": { "enum": ["file", "directory"] }, + "path": { "type": "string", "minLength": 1, "maxLength": 256, + "pattern": "^(?!/)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))(?!.*(?:^|/)[^/]*[. ](?:/|$))[^/\\\\:*?\\u0000-\\u001f\\u007f]+(?:/[^/\\\\:*?\\u0000-\\u001f\\u007f]+)*$" }, + "extension": { "type": "string", "pattern": "^[a-z0-9]{1,16}$" } + }, + "allOf": [ + { "if": { "properties": { "kind": { "const": "directory" } } }, "then": { "required": ["extension"] } }, + { "if": { "properties": { "kind": { "const": "file" } } }, "then": { "not": { "required": ["extension"] } } } + ] + }, "debugLocalID": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$" diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index b76501ada1..36a367b956 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1,5 +1,88 @@ # Rust Core API +## Normalized shortcut import validation + +`keymap.decodeImport` accepts `{commandIDs: string[], reply: {bindings: +[{commandID: string, keys: [{key: string, modifiers: string[]}]}]}}` and +returns `{bindings: [...]}`. The caller supplies its current action catalog; +unknown or duplicate actions, duplicate keys or modifiers, unknown fields, +unsupported keys and unmodified printable keys fail with `invalid_request` and +no partial result. Modifiers are `control`, `alt`, `shift` and `meta`, in +that canonical order. Keys are lowercase single ASCII graphic characters, +`f1` through `f24`, or `up`, `down`, `left`, `right`, `return`, `tab`, +`space`, `backspace`, `delete`, `escape`, `pageup`, `pagedown`, `home`, `end` +and `insert`. This is the portable vocabulary; each host must still reject keys +its native input and shortcut model cannot represent. A printable character or `space` requires `control`, `alt` or +`meta`. Empty key lists represent an explicit unbinding. Actions are returned +in ID order; each action's key order is retained because the first is its +primary binding. A shortcut used by different actions is retained for the +host's final conflict check. Requests are limited to 1 MiB, 4096 catalog IDs, +4096 actions, eight keys per action and 128 UTF-8 bytes per ID. The shared +[fixtures](../fixtures/keymap/import-provider-v1.json) cover this contract. + +This validates normalized results only. It neither parses an IDE's private +format nor discovers or starts a provider; an unimplemented provider API must +not be inferred from the existence of this Core command. Conditional rules and +multi-stroke keys cannot be represented and must not be silently made global. + +`debug.startOrContinue` accepts `{active, paused, requestPending}` booleans and returns +`{action: "start" | "continue" | "none"}`. An in-flight request or an active, +non-paused session returns `none`; an inactive session starts, and an active +paused session continues. The platform executes the result against its current +session and must still guard against state changes. + +`keymap.planImportBindings` accepts `{current: [{commandID, customized, keys}], +incoming: {bindings}}` and returns `{displaced, conflicts}`. Current entries +are the host's complete action catalog and effective key-press bindings; +non-key-press bindings remain in the host and are never displaced. Incoming +bindings use the same validation as `keymap.decodeImport`. Each displacement +names an unchanged default action and only the keys to remove. A customized +owner or two imported actions claiming the same key produces a conflict naming +`commandID`, `ownerID` and `key`; a conflict clears all displacements so a +caller cannot apply a partial plan. Actions included in the import replace +their old bindings, permitting swaps. Results are deterministic. Requests are +bounded to 1 MiB, 4096 actions, eight keys per action, 4096 conflicts and +131072 ownership comparisons. Hosts must recheck the source and settings +revisions at apply time. See [plan fixtures](../fixtures/keymap/import-plan-v1.json). + +`keymap.planImportSources` accepts `{manifest, providerID}` and returns +`{inputs, recognition, limits}` for exactly that declared provider after validating the entire +manifest; an unknown provider or invalid manifest returns `invalid_request`. +The payload is bounded to 1 MiB. It performs no filesystem I/O and does not +establish package trust: callers must supply the enabled, previously accepted +plugin manifest. Selectors and native read limits are defined by the +[plugin contract](plugins/v1/README.md#shortcut-import-providers-core-foundation). +The returned limits bound selectors, recognition probes, files, bytes per file and per snapshot, +bytes per chunk, directory entries scanned and matching entries per directory; +native adapters apply these values to their I/O. +See [source plan fixtures](../fixtures/keymap/source-plans-v1.json). + +For the provider foundation, `lsp.startServer` optionally accepts +`keymapImportProviders`, an array of at most eight declared plugin-local IDs. +Only a plugin-owned session with valid IDs offers +`experimental.litheKeymapImport: {versions: [1], providers}`. A backend may +select a non-empty, duplicate-free subset using `{version: 1, providers}`; +Core emits `keymapImportProviders` with `result: {providers}` only for a valid +selection. Invalid IDs, unknown selections and unsupported versions enable no +providers. This negotiation alone does not implement the product workflow. See the +[negotiation fixtures](../fixtures/keymap/provider-negotiation-v1.json). + +`lsp.keymapImportRequest` accepts `{sessionId, operationId?, providerID, +snapshotID, timeoutMilliseconds?}` and returns `{operationId}`. The requested +provider must have been selected in that exact session. `snapshotID` is an +opaque 1–128 byte ASCII alphanumeric, hyphen or underscore token, never a +filesystem path; the native snapshot service must separately enforce ownership, +expiry and read permissions before supplying any source data. The method sends +`lithe/keymap/import` with only `{providerID, snapshotID}` and caps the request +deadline at 60 seconds. Completion returns the untrusted JSON-RPC reply as +`result.value: {id, response}`; callers must validate successful reply data via +`keymap.decodeImport` before applying it. Replies larger than 1 MiB fail with +`plugin.keymapImport.replyTooLarge`; no partial result is returned. Normal +session cancellation and teardown apply. Backend attempts to call +`lithe/keymap/*` are refused as recursive domain calls. The macOS host has a +scoped snapshot service, but native provider discovery and a product consumer +are not implemented yet. + ## Shortcut context compilation `keymap.parseContext` accepts `{expression: string}` and returns @@ -1129,6 +1212,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.hostResponse` | Answer one host-service request a plugin backend sent | | `lsp.hostNotify` | Send one host notification to a plugin backend that negotiated it | | `lsp.toolchainRequest` | Send one `toolchains/query` to a plugin backend that negotiated the provider | +| `lsp.keymapImportRequest` | Send one shortcut import request to a negotiated plugin provider | | `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | | `java.prepareLaunch` | Prepare a saved Java source through shared entry selection, serialized build and runtime path resolution | | `java.navigationMarkers` | Resolve versioned Java gutter markers from bounded JDT LS semantic requests | diff --git a/shared/fixtures/keymap/import-plan-v1.json b/shared/fixtures/keymap/import-plan-v1.json new file mode 100644 index 0000000000..b9ee4e9fe9 --- /dev/null +++ b/shared/fixtures/keymap/import-plan-v1.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "cases": [ + { + "name": "An imported key displaces only that key from an untouched default", + "request": { + "current": [ + {"commandID": "debug-resume", "customized": false, "keys": [{"key": "f9", "modifiers": []}, {"key": "r", "modifiers": ["meta", "alt"]}]}, + {"commandID": "toggle-breakpoint", "customized": false, "keys": [{"key": "f8", "modifiers": ["control"]}]} + ], + "incoming": {"bindings": [{"commandID": "toggle-breakpoint", "keys": [{"key": "f9", "modifiers": []}]}]} + }, + "expected": {"displaced": [{"commandID": "debug-resume", "keys": [{"key": "f9", "modifiers": []}]}], "conflicts": []} + }, + { + "name": "A user customized owner is not displaced", + "request": { + "current": [ + {"commandID": "debug-resume", "customized": true, "keys": [{"key": "f9", "modifiers": []}]}, + {"commandID": "toggle-breakpoint", "customized": false, "keys": []} + ], + "incoming": {"bindings": [{"commandID": "toggle-breakpoint", "keys": [{"key": "f9", "modifiers": []}]}]} + }, + "expected": {"displaced": [], "conflicts": [{"commandID": "toggle-breakpoint", "ownerID": "debug-resume", "key": {"key": "f9", "modifiers": []}}]} + }, + { + "name": "Two imported actions cannot own the same key", + "request": { + "current": [{"commandID": "one", "customized": false, "keys": []}, {"commandID": "two", "customized": false, "keys": []}], + "incoming": {"bindings": [{"commandID": "one", "keys": [{"key": "f3", "modifiers": []}]}, {"commandID": "two", "keys": [{"key": "f3", "modifiers": []}]}]} + }, + "expected": {"displaced": [], "conflicts": [{"commandID": "two", "ownerID": "one", "key": {"key": "f3", "modifiers": []}}]} + }, + { + "name": "A custom conflict prevents otherwise valid default displacements", + "request": { + "current": [ + {"commandID": "default-owner", "customized": false, "keys": [{"key": "f3", "modifiers": []}]}, + {"commandID": "custom-owner", "customized": true, "keys": [{"key": "f4", "modifiers": []}]}, + {"commandID": "imported", "customized": false, "keys": []} + ], + "incoming": {"bindings": [{"commandID": "imported", "keys": [{"key": "f3", "modifiers": []}, {"key": "f4", "modifiers": []}]}]} + }, + "expected": {"displaced": [], "conflicts": [{"commandID": "imported", "ownerID": "custom-owner", "key": {"key": "f4", "modifiers": []}}]} + }, + { + "name": "A swap does not displace either imported action", + "request": { + "current": [{"commandID": "one", "customized": true, "keys": [{"key": "f3", "modifiers": []}]}, {"commandID": "two", "customized": true, "keys": [{"key": "f4", "modifiers": []}]}], + "incoming": {"bindings": [{"commandID": "one", "keys": [{"key": "f4", "modifiers": []}]}, {"commandID": "two", "keys": [{"key": "f3", "modifiers": []}]}]} + }, + "expected": {"displaced": [], "conflicts": []} + }, + { + "name": "An unrelated native-only key does not block an import", + "request": { + "current": [{"commandID": "existing", "customized": true, "keys": [{"key": "native-special", "modifiers": []}]}, {"commandID": "imported", "customized": false, "keys": []}], + "incoming": {"bindings": [{"commandID": "imported", "keys": [{"key": "f9", "modifiers": []}]}]} + }, + "expected": {"displaced": [], "conflicts": []} + }, + { + "name": "A provider cannot name an action absent from the current catalog", + "request": {"current": [{"commandID": "one", "customized": false, "keys": []}], "incoming": {"bindings": [{"commandID": "other", "keys": []}]}} + } + ] +} diff --git a/shared/fixtures/keymap/import-provider-v1.json b/shared/fixtures/keymap/import-provider-v1.json new file mode 100644 index 0000000000..d21e6b8fd2 --- /dev/null +++ b/shared/fixtures/keymap/import-provider-v1.json @@ -0,0 +1,86 @@ +{ + "version": 1, + "cases": [ + { + "name": "Different sources supply the same normalized action and keys", + "request": { + "commandIDs": ["find-next", "toggle-breakpoint"], + "reply": {"bindings": [ + {"commandID": "toggle-breakpoint", "keys": [{"key": "b", "modifiers": ["meta", "alt"]}]}, + {"commandID": "find-next", "keys": [{"key": "f3", "modifiers": []}]} + ]} + }, + "expected": {"bindings": [ + {"commandID": "find-next", "keys": [{"key": "f3", "modifiers": []}]}, + {"commandID": "toggle-breakpoint", "keys": [{"key": "b", "modifiers": ["alt", "meta"]}]} + ]} + }, + { + "name": "An explicit empty binding is retained", + "request": {"commandIDs": ["find-next"], "reply": {"bindings": [{"commandID": "find-next", "keys": []}]}}, + "expected": {"bindings": [{"commandID": "find-next", "keys": []}]} + }, + { + "name": "Space with an action modifier remains assignable", + "request": {"commandIDs": ["find-next"], "reply": {"bindings": [{"commandID": "find-next", "keys": [{"key": "space", "modifiers": ["control"]}]}]}}, + "expected": {"bindings": [{"commandID": "find-next", "keys": [{"key": "space", "modifiers": ["control"]}]}]} + }, + { + "name": "Portable key names include common Windows navigation keys", + "request": {"commandIDs": ["navigation"], "reply": {"bindings": [{"commandID": "navigation", "keys": [ + {"key": "f24", "modifiers": []}, {"key": "pageup", "modifiers": ["control"]}, + {"key": "backspace", "modifiers": ["alt"]} + ]}]}}, + "expected": {"bindings": [{"commandID": "navigation", "keys": [ + {"key": "f24", "modifiers": []}, {"key": "pageup", "modifiers": ["control"]}, + {"key": "backspace", "modifiers": ["alt"]} + ]}]} + }, + { + "name": "Primary order and cross-action conflicts remain available to the host", + "request": {"commandIDs": ["one", "two"], "reply": {"bindings": [ + {"commandID": "one", "keys": [{"key": "f9", "modifiers": []}, {"key": "f3", "modifiers": []}]}, + {"commandID": "two", "keys": [{"key": "f9", "modifiers": []}]} + ]}}, + "expected": {"bindings": [ + {"commandID": "one", "keys": [{"key": "f9", "modifiers": []}, {"key": "f3", "modifiers": []}]}, + {"commandID": "two", "keys": [{"key": "f9", "modifiers": []}]} + ]} + }, + { + "name": "A provider cannot inject an unknown action", + "request": {"commandIDs": ["find-next"], "reply": {"bindings": [{"commandID": "other", "keys": [{"key": "f3", "modifiers": []}]}]}} + }, + { + "name": "Duplicate action entries cannot silently overwrite each other", + "request": {"commandIDs": ["find-next"], "reply": {"bindings": [{"commandID": "find-next", "keys": []}, {"commandID": "find-next", "keys": []}]}} + }, + { + "name": "Unmodified text input cannot become a global shortcut", + "request": {"commandIDs": ["find-next"], "reply": {"bindings": [{"commandID": "find-next", "keys": [{"key": "a", "modifiers": []}]}]}} + }, + { + "name": "Named space is text input even when combined with shift", + "request": {"commandIDs": ["find-next"], "reply": {"bindings": [{"commandID": "find-next", "keys": [{"key": "space", "modifiers": ["shift"]}]}]}} + }, + { + "name": "Repeated modifiers are invalid", + "request": {"commandIDs": ["find-next"], "reply": {"bindings": [{"commandID": "find-next", "keys": [{"key": "g", "modifiers": ["meta", "meta"]}]}]}} + }, + { + "name": "Modifier order cannot disguise a duplicate shortcut", + "request": {"commandIDs": ["find-next"], "reply": {"bindings": [{"commandID": "find-next", "keys": [ + {"key": "g", "modifiers": ["meta", "alt"]}, + {"key": "g", "modifiers": ["alt", "meta"]} + ]}]}} + }, + { + "name": "Conditions cannot be silently discarded", + "request": {"commandIDs": ["find-next"], "reply": {"bindings": [{"commandID": "find-next", "keys": [{"key": "g", "modifiers": ["meta"], "when": "editorFocus"}]}]}} + }, + { + "name": "Multi-stroke keys cannot be silently truncated", + "request": {"commandIDs": ["find-next"], "reply": {"bindings": [{"commandID": "find-next", "keys": [{"key": "g g", "modifiers": ["meta"]}]}]}} + } + ] +} diff --git a/shared/fixtures/keymap/provider-negotiation-v1.json b/shared/fixtures/keymap/provider-negotiation-v1.json new file mode 100644 index 0000000000..57c974d759 --- /dev/null +++ b/shared/fixtures/keymap/provider-negotiation-v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "cases": [ + {"name": "one selected provider", "offered": ["first", "second"], "answer": {"version": 1, "providers": ["second"]}, "expected": ["second"]}, + {"name": "selection is ordered deterministically", "offered": ["first", "second"], "answer": {"version": 1, "providers": ["second", "first"]}, "expected": ["first", "second"]}, + {"name": "future optional answer fields do not prevent negotiation", "offered": ["first"], "answer": {"version": 1, "providers": ["first"], "future": true}, "expected": ["first"]}, + {"name": "unsupported version", "offered": ["first"], "answer": {"version": 2, "providers": ["first"]}, "expected": null}, + {"name": "unknown provider", "offered": ["first"], "answer": {"version": 1, "providers": ["first", "other"]}, "expected": null}, + {"name": "duplicate provider", "offered": ["first"], "answer": {"version": 1, "providers": ["first", "first"]}, "expected": null}, + {"name": "empty selection", "offered": ["first"], "answer": {"version": 1, "providers": []}, "expected": null}, + {"name": "non-string provider", "offered": ["first"], "answer": {"version": 1, "providers": [3]}, "expected": null}, + {"name": "no providers offered", "offered": [], "answer": {"version": 1, "providers": ["first"]}, "expected": null} + ] +} diff --git a/shared/fixtures/keymap/source-plans-v1.json b/shared/fixtures/keymap/source-plans-v1.json new file mode 100644 index 0000000000..0b0de22418 --- /dev/null +++ b/shared/fixtures/keymap/source-plans-v1.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "limits": { + "selectors": 16, + "probes": 16, + "files": 128, + "fileBytes": 1048576, + "totalBytes": 8388608, + "chunkBytes": 131072, + "directoryEntries": 4096, + "directoryMatches": 64 + }, + "cases": [ + {"providerID": "first", "expected": {"recognition": [{"kind": "file", "path": "bindings.json"}, {"kind": "root", "base": "userConfig", "components": ["FirstEditor", "User"]}, {"kind": "root", "base": "userConfig", "components": ["FirstEditor", "User", "profiles", "*"]}], "inputs": [{"kind": "file", "path": "bindings.json"}]}}, + {"providerID": "second", "expected": {"recognition": [{"kind": "directory", "path": "keymaps"}], "inputs": [{"kind": "file", "path": "options/map.xml"}, {"kind": "directory", "path": "keymaps", "extension": "xml"}]}}, + {"providerID": "unlisted", "expected": null} + ] +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index fc5e4c011b..5bde09af8c 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -2,6 +2,10 @@ "messages": { "aiCommit.outputTokenField": "Output token field", "aiCommit.outputTokenFieldInfo": "Lithe uses this field for commit requests. Choose max_tokens for older gateways.", + "native.keymapImportAmbiguous": "More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again.", + "native.keymapImportSourceReadPermission": "Read declared shortcut files from the editor you select for import", + "native.keymapImportProviderPermission": "Offer shortcut mappings for import", + "native.keymapImportRuntimeUnavailable": "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again.", "markdown.renderFailed": "Markdown preview could not be rendered. Edit or reopen the document to retry.", "agent.actions.close": "Close the Agent panel", "agent.actions.closeSearch": "Close search", @@ -4145,6 +4149,8 @@ "native.database": "Database", "native.date": "Date", "native.debug": "Debug", + "native.debugStartOrContinue": "Debug: Start or Continue", + "native.startDebuggingOrContinuePausedSession": "Start debugging or continue the paused session", "native.debugResume": "Debug: Resume", "native.debugStepInto": "Debug: Step Into", "native.debugStepOut": "Debug: Step Out", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 361d5cd7a9..4c38e6cc56 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -2,6 +2,10 @@ "messages": { "aiCommit.outputTokenField": "输出额度字段", "aiCommit.outputTokenFieldInfo": "Lithe 使用此字段发送提交信息请求。旧网关请选择 max_tokens。", + "native.keymapImportAmbiguous": "多个插件都可以从此来源导入快捷键。请停用重复的提供方后重新扫描。", + "native.keymapImportSourceReadPermission": "读取所选编辑器中声明的快捷键配置文件以供导入", + "native.keymapImportProviderPermission": "提供可导入的快捷键映射", + "native.keymapImportRuntimeUnavailable": "快捷键导入不可用。请更新 Lithe 和编辑器导入插件后重试。", "markdown.renderFailed": "无法渲染 Markdown 预览,请编辑或重新打开文档重试。", "agent.actions.close": "关闭 Agent 面板", "agent.actions.closeSearch": "关闭查找", @@ -4145,6 +4149,8 @@ "native.database": "数据库连接", "native.date": "日期", "native.debug": "调试", + "native.debugStartOrContinue": "调试:启动或继续", + "native.startDebuggingOrContinuePausedSession": "启动调试或继续已暂停的会话", "native.debugResume": "调试:继续", "native.debugStepInto": "调试:步入", "native.debugStepOut": "调试:步出", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index d627b6ab46..89bd246c76 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -13,6 +13,42 @@ "platforms": ["macos"], "macos": {"key": "Lithe uses this field for commit requests. Choose max_tokens for older gateways.", "arguments": []} }, + { + "id": "native.keymapImportAmbiguous", + "description": "More than one plugin matched a selected shortcut source; ask the user to disable duplicates before retrying.", + "platforms": ["macos"], + "macos": { + "key": "More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again.", + "arguments": [] + } + }, + { + "id": "native.keymapImportSourceReadPermission", + "description": "Plugin permission for scoped reads of the selected shortcut import source.", + "platforms": ["macos"], + "macos": { + "key": "Read declared shortcut files from the editor you select for import", + "arguments": [] + } + }, + { + "id": "native.keymapImportProviderPermission", + "description": "Plugin permission for offering shortcut mappings during import.", + "platforms": ["macos"], + "macos": { + "key": "Offer shortcut mappings for import", + "arguments": [] + } + }, + { + "id": "native.keymapImportRuntimeUnavailable", + "description": "Shortcut import is unavailable in this runtime; suggest updating the application and plugin.", + "platforms": ["macos"], + "macos": { + "key": "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again.", + "arguments": [] + } + }, { "id": "debugger.breakpointRelocationFailed", "description": "Breakpoint relocation failed; reset unsafe source anchors before debugging.", @@ -34393,6 +34429,28 @@ "arguments": [] } }, + { + "id": "native.debugStartOrContinue", + "description": "Native UI: Debug: Start or Continue", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debug: Start or Continue", + "arguments": [] + } + }, + { + "id": "native.startDebuggingOrContinuePausedSession", + "description": "Native UI: Start debugging or continue the paused session", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start debugging or continue the paused session", + "arguments": [] + } + }, { "id": "native.debugResume", "description": "Native UI: Debug: Resume", diff --git a/shared/platform-feature-matrix/features/editor-ide-settings-import.json b/shared/platform-feature-matrix/features/editor-ide-settings-import.json index 6ff05a9b1c..01ab6edaba 100644 --- a/shared/platform-feature-matrix/features/editor-ide-settings-import.json +++ b/shared/platform-feature-matrix/features/editor-ide-settings-import.json @@ -14,7 +14,10 @@ "macos/Sources/Lithe/Views/App/WelcomeView.swift", "macos/Sources/Lithe/Views/App/SettingsView.swift", "macos/Tests/LitheTests/EditorSettingsImportTests.swift", - "macos/Sources/Lithe/Platform/MacOS/MacImportedKeybindings.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacEditorKeymapImportService.swift", + "Plugins/mac/Official/EditorKeymapImport/src/vscode.rs", + "Plugins/mac/Official/EditorKeymapImport/src/idea.rs", + "rust/lithe-core/src/plugins/keymap_import.rs", "frontend/editor/src/workbench.ts", "macos/Sources/Lithe/Models/Settings/ImportedEditorTheme.swift", "macos/Sources/Lithe/Platform/MacOS/MacImportedIDEATheme.swift", @@ -29,6 +32,6 @@ ] }, "owner": "Editor / Settings", - "notes": "仅 macOS。只读发现 Code/Cursor/IntelliJ IDEA 用户目录及 Profile,首次启动先导入或跳过再显示欢迎页。共享产品面板固定 640×467,两页选择,来源图标、左下跳过、正文右下蓝色重扫链接保留。按外观设置/快捷键/代码设置/编辑器偏好分组并支持整组选取;接入深浅色/跟随系统、配色、字体排版和既有偏好。源文件缺失时提供标注的参考外观;Cursor/Profile/JBR 参考值及主题近似映射不会宣称来源实际生效值。IDEA 读取 laf.xml 和有界 colors/*.icls,支持应用字体及方案覆盖、字体列表首个已安装候选,配色父方案不作为字体继承。未知方案版本、重复名、损坏文件或越界链接拒绝读取;每文件1MiB、来源8MiB、最多64个方案。保留来源摘要/目标基线冲突、回读、失败恢复和单次撤销;已有自定义值初始不勾选,扫描/翻页不写设置。展示真实导入状态和本次应用/未选择数量;设置一致不等于导入历史。工作区/语言覆盖、完整 Profile 继承、未映射自定义 token 与主题包、完整继承键位、格式化器、扩展和账号数据不在范围。无运行时 bundle 写入。 第二页左侧显示来源 IDE 图标、名称及路径,右侧显示带图标的设置分组;右侧按来源可用字段显示类别,图标、名称和说明在左,唯一的类别复选框在右,不再嵌套子项。同值类别照常可勾选、取消和确认导入,不显示一致状态;实际写入仅处理差异,全同值成功不生成撤销记录。差异及默认/近似标识通过悬停提示展示;含自定义目标值的类别默认不选。左侧保持图标名称横排、路径单行省略和完整悬停提示。 自定义快捷键仅白名单映射,保留当前预设及无关覆盖,冲突拒绝写入;带条件/连续键/移除规则跳过。基础代码设置为全局缩进宽度、Tab/空格、自动检测,通过现有桥接应用文档模型,不改文本或格式化器参数。 已补已知 IDEA 内置默认键位及有界父方案解析,保留 Control/Command 父级转换;缺失显式配置时提供标注的默认全局缩进。只映射已实现命令,未知方案不猜测。 支持 IDEA .icls 父方案及 baseAttributes 引用的已映射颜色/字体样式覆盖;复用持久化和桥接,已有/新编辑器生效,设置页可移除,支持撤销。Java 行/块/文档注释分离,未知效果提示跳过;内置父配色仍为近似映射,未支持任意 VS Code 扩展主题包。 VS Code/Cursor 空配置补参考默认快捷键、缩进和显示偏好,显示四类可导入内容;覆盖规则阻止错误恢复默认键。已知内置配色可读取 settings.json 的通用颜色覆盖;完整扩展主题、按主题覆盖和语义规则仍未支持。 缩进状态栏与设置统一读取 editorAppearance,删除重复旧 Tab 宽度控件;旧存储仅在新字段缺席时迁移,导入/撤销不会被旧值覆盖。 状态栏使用当前文档的 Monaco 实际缩进回报,自动检测与固定模式均按模型值显示,不再回退全局默认值。 连续按键规则按首键遮蔽参考默认值及此前的用户映射,整条连续按键仍跳过;后续受支持的无条件单键规则可按来源顺序覆盖。 手动导入弹窗保持原生阴影,以保留系统绘制的完整窗口外缘;撤销关闭阴影的方案,标题栏、关闭生命周期和拖动优化不变。 手动导入及共享带标题表单在标题栏拖动时临时解除父子关系,松手原位挂回;拖动期间仍隔离父窗口输入,并在父窗口关闭时清理。", - "verification": "EditorSettingsImportTests 和 AppSettingsTests 验证 JSONC、XML、字体/单位、来源只读、配置版本/Profile、冲突、持久化、恢复和撤销。完整欢迎页/设置页交互、不同真实 IDE 版本以及多窗口/重启视觉验收仍待完成。 EditorSettingsImportTests 增加入口覆盖检查和深浅色真实窗口呈现检查,LitheCenteredPopupTests 验证共享生命周期。 原生深浅色测试验证启动时欢迎页未挂载且无 child window/sheet,跳过后才显示欢迎页;覆盖无来源、读取失败、成功导入和无变化字段。 WindowFullScreenCapabilityGuardTests 验证导入窗口实际尺寸及切回欢迎页;导入启动测试生成可选深浅色布局截图。 choosingSourceNavigatesWithoutWritingSettings 验证翻页、返回、无效来源和实际导入边界。 回归测试覆盖损坏 JSON/XML 修复重读,以及无来源重启后通过原生按钮撤销并核对持久化。 回归覆盖 Code/Cursor 无设置文件、Profile 发现、手选身份一致性、保存后重扫并实际导入 Cursor 字号行距、源文件不变和任意空目录不误识别。 显式开启的本机只读集成检查确认真实扫描器发现无 settings.json 的 VS Code 和已有 settings.json 的 Cursor;不代表新版产品面板已完成人工视觉验收。 原生无障碍回归检查重扫位置、空状态隐藏导入、重新读取后恢复导入按钮、返回候选保留,以及手动/启动跳过的写入边界;深浅色空状态截图可由 LITHE_POPUP_CAPTURE_DIR 输出。 新增分阶段回归:外观模式持久化/回滚,参考默认值与非法显式值隔离,IDEA 应用字体与方案覆盖、字体列表、未知/循环配色父方案、同名冲突、未来版本、目录越界及来源变更;本次结果计数与重开状态,原生整组选取不污染其他组。真实打包应用全流程、多版本/Profile 的人工验收仍待完成,不以测试宿主截图冒充整包验收。 新增空配置默认方案、键位继承与遮蔽、未知方案及循环拒绝、导入撤销回归。 WebKit 回归核对固定 Tab 8、自动检测空格 2及新文档默认 4的原生消息;Swift 验证每文档状态隔离及非法宽度拒绝。 快捷键顺序回归确认后续扩展或条件规则遮蔽此前用户映射,并覆盖参考默认值开关和反向顺序。 laterChordShadowsItsFirstKeyWithoutBecomingASingleKeyBinding 覆盖连续按键首键遮蔽、参考默认值开关、此前的用户映射、未知扩展/条件/白名单命令,以及反向顺序与不产生单键动作。 importPanelRetainsNativeShadowChromeAndCloses 覆盖深浅色原生阴影面板的标题栏、关闭按钮、内容尺寸和父子窗口清理;整包视觉验收仍待完成。 titledPanelDetachesDuringDragAndCleansUp 覆盖拖动解除/恢复、内容点击、移动结束兜底与拖动期间关闭;macOS 27 实机拖动流畅度待验证。" + "notes": "仅 macOS。只读发现 Code/Cursor/IntelliJ IDEA 用户目录及 Profile,首次启动先导入或跳过再显示欢迎页。共享产品面板固定 640×467,两页选择,来源图标、左下跳过、正文右下蓝色重扫链接保留。按外观设置/快捷键/代码设置/编辑器偏好分组并支持整组选取;接入深浅色/跟随系统、配色、字体排版和既有偏好。源文件缺失时提供标注的参考外观;Cursor/Profile/JBR 参考值及主题近似映射不会宣称来源实际生效值。IDEA 读取 laf.xml 和有界 colors/*.icls,支持应用字体及方案覆盖、字体列表首个已安装候选,配色父方案不作为字体继承。未知方案版本、重复名、损坏文件或越界链接拒绝读取;每文件1MiB、来源8MiB、最多64个方案。保留来源摘要/目标基线冲突、回读、失败恢复和单次撤销;已有自定义值初始不勾选,扫描/翻页不写设置。展示真实导入状态和本次应用/未选择数量;设置一致不等于导入历史。工作区/语言覆盖、完整 Profile 继承、未映射自定义 token 与主题包、完整继承键位、格式化器、扩展和账号数据不在范围。无运行时 bundle 写入。 第二页左侧显示来源 IDE 图标、名称及路径,右侧显示带图标的设置分组;右侧按来源可用字段显示类别,图标、名称和说明在左,唯一的类别复选框在右,不再嵌套子项。同值类别照常可勾选、取消和确认导入,不显示一致状态;实际写入仅处理差异,全同值成功不生成撤销记录。差异及默认/近似标识通过悬停提示展示;含自定义目标值的类别默认不选。左侧保持图标名称横排、路径单行省略和完整悬停提示。 自定义快捷键仅白名单映射,保留当前预设及无关覆盖;来源键占用未自定义的目标默认键时仅让出该键,自定义键或来源内冲突拒绝整批写入。带条件或连续键规则不会提升为全局键;移除规则仅作用于指定命令的参考默认键;用户覆盖仍按来源顺序处理。基础代码设置为全局缩进宽度、Tab/空格、自动检测,通过现有桥接应用文档模型,不改文本或格式化器参数。 已补已知 IDEA 内置默认键位及有界父方案解析,保留 Control/Command 父级转换;缺失显式配置时提供标注的默认全局缩进。只映射已实现命令,未知方案不猜测。 支持 IDEA .icls 父方案及 baseAttributes 引用的已映射颜色/字体样式覆盖;复用持久化和桥接,已有/新编辑器生效,设置页可移除,支持撤销。Java 行/块/文档注释分离,未知效果提示跳过;内置父配色仍为近似映射,未支持任意 VS Code 扩展主题包。 VS Code/Cursor 空配置补参考默认快捷键、缩进和显示偏好,显示四类可导入内容;覆盖规则阻止错误恢复默认键。已知内置配色可读取 settings.json 的通用颜色覆盖;完整扩展主题、按主题覆盖和语义规则仍未支持。 缩进状态栏与设置统一读取 editorAppearance,删除重复旧 Tab 宽度控件;旧存储仅在新字段缺席时迁移,导入/撤销不会被旧值覆盖。 状态栏使用当前文档的 Monaco 实际缩进回报,自动检测与固定模式均按模型值显示,不再回退全局默认值。 连续按键规则按首键遮蔽参考默认值及此前的用户映射,整条连续按键仍跳过;后续受支持的无条件单键规则可按来源顺序覆盖。 IDEA 旧版 macOS 方案同时导入查找前后键位,避免与跳转到行冲突;扩展 IDEA 和 VS Code 常用查找及导航命令映射。快捷键来源解析由随包插件负责;Core 规划只读快照、验证结果与冲突,显式选中来源后才启动插件。VS Code F5 按调试状态启动或继续。 手动导入弹窗保持原生阴影,以保留系统绘制的完整窗口外缘;撤销关闭阴影的方案,标题栏、关闭生命周期和拖动优化不变。 手动导入及共享带标题表单在标题栏拖动时临时解除父子关系,松手原位挂回;拖动期间仍隔离父窗口输入,并在父窗口关闭时清理。", + "verification": "EditorSettingsImportTests 和 AppSettingsTests 验证 JSONC、XML、字体/单位、来源只读、配置版本/Profile、冲突、持久化、恢复和撤销。完整欢迎页/设置页交互、不同真实 IDE 版本以及多窗口/重启视觉验收仍待完成。 EditorSettingsImportTests 增加入口覆盖检查和深浅色真实窗口呈现检查,LitheCenteredPopupTests 验证共享生命周期。 原生深浅色测试验证启动时欢迎页未挂载且无 child window/sheet,跳过后才显示欢迎页;覆盖无来源、读取失败、成功导入和无变化字段。 WindowFullScreenCapabilityGuardTests 验证导入窗口实际尺寸及切回欢迎页;导入启动测试生成可选深浅色布局截图。 choosingSourceNavigatesWithoutWritingSettings 验证翻页、返回、无效来源和实际导入边界。 回归测试覆盖损坏 JSON/XML 修复重读,以及无来源重启后通过原生按钮撤销并核对持久化。 回归覆盖 Code/Cursor 无设置文件、Profile 发现、手选身份一致性、保存后重扫并实际导入 Cursor 字号行距、源文件不变和任意空目录不误识别。 显式开启的本机只读集成检查确认真实扫描器发现无 settings.json 的 VS Code 和已有 settings.json 的 Cursor;不代表新版产品面板已完成人工视觉验收。 原生无障碍回归检查重扫位置、空状态隐藏导入、重新读取后恢复导入按钮、返回候选保留,以及手动/启动跳过的写入边界;深浅色空状态截图可由 LITHE_POPUP_CAPTURE_DIR 输出。 新增分阶段回归:外观模式持久化/回滚,参考默认值与非法显式值隔离,IDEA 应用字体与方案覆盖、字体列表、未知/循环配色父方案、同名冲突、未来版本、目录越界及来源变更;本次结果计数与重开状态,原生整组选取不污染其他组。真实打包应用全流程、多版本/Profile 的人工验收仍待完成,不以测试宿主截图冒充整包验收。 新增空配置默认方案、键位继承与遮蔽、未知方案及循环拒绝、导入撤销回归。 WebKit 回归核对固定 Tab 8、自动检测空格 2及新文档默认 4的原生消息;Swift 验证每文档状态隔离及非法宽度拒绝。 快捷键顺序回归确认后续扩展或条件规则遮蔽此前用户映射,并覆盖参考默认值开关和反向顺序。 插件回归测试覆盖连续按键首键遮蔽、参考默认值开关、此前的用户映射、未知扩展/条件/白名单命令,以及反向顺序与不产生单键动作。 插件测试覆盖 IDEA 旧方案、快捷键注册输入,以及 VS Code 常用键位和用户覆盖;隔离存储回归验证冲突与撤销;真实打包产品快捷键验收仍待完成。 2026-11-03 隔离 macOS Release 应用从本机 IDEA/VS Code 配置导入,并验证 IDEA 新旧方案跳行与查找、VS Code 跳行、替换、项目搜索、自定义键和 Java 定义/引用;重启后验证常用键。终端焦点下 VS Code 的 Control-反引号不能关闭终端,其他版本与布局仍待验收。 importPanelRetainsNativeShadowChromeAndCloses 覆盖深浅色原生阴影面板的标题栏、关闭按钮、内容尺寸和父子窗口清理;整包视觉验收仍待完成。 titledPanelDetachesDuringDragAndCleansUp 覆盖拖动解除/恢复、内容点击、移动结束兜底与拖动期间关闭;macOS 27 实机拖动流畅度待验证。 插件版 Release 包已验证 IDEA/VS Code 导入、断点、导航、终端、F5 调试启动和继续、自定义键、源变化保护及撤销;其他版本和键盘布局仍待验收。" } diff --git a/shared/platform-feature-matrix/features/terminal-interaction.json b/shared/platform-feature-matrix/features/terminal-interaction.json index 6230d59e21..1b42d3d23c 100644 --- a/shared/platform-feature-matrix/features/terminal-interaction.json +++ b/shared/platform-feature-matrix/features/terminal-interaction.json @@ -2,7 +2,7 @@ "id": "terminal-interaction", "area": "工作台", "group": "终端", - "capability": "复制粘贴、搜索、调整大小、Control 组合键直达终端及 macOS 终端 Nerd Font 优先选择", + "capability": "复制粘贴、搜索、调整大小、终端 Control 输入与切换快捷键及 macOS 终端 Nerd Font 优先选择", "macos": { "evidence": [ "macos/Sources/Lithe/Views/Terminal", @@ -35,5 +35,5 @@ "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 macOS 安装原有支持列表中的 Nerd Font Mono,使用 Starship pastel-powerline 核对圆弧、Git/Bun 图标和拼接效果;注册打包 UI 字体后终端选择与 12.5pt 字号不变,未安装 Nerd Font 时回退到 Menlo 或系统等宽字体。 安装包必须包含完整 JetBrains Mono Nerd Font Mono 四个字型及许可;在未安装 Nerd Font 的机器上仍能直接显示完整提示符,粗斜体符号与文字使用相同字体度量,发行 bundle 字节不变。 Windows 终端点击识别出的 localhost、127.0.0.1 和 HTTPS 网页链接时直接打开系统默认浏览器,不显示外部链接确认;文件路径继续打开 IDE 文件,浏览器打开失败记录错误。真实 Windows 产品交互验收 pending。 macOS 保留宿主:在明暗主题切换无背景图→有图→无图,真实终端标题栏和正文随之透明/恢复,宿主及输入控件身份保持。隐藏终端后聚焦编辑器,再从活动栏重开已有会话,first responder 回到同一终端输入控件;连续调整面板尺寸和环境值不抢焦点,重开后立即隐藏会取消待交付的激活请求。运行 TerminalPlacementFeatureModelTests.retainedTerminalRestoresFocusAndWallpaperWithoutRecreatingItsSurface,并通过实际应用截图及交互验收。 隐藏保留的工具终端后,从工具会话 B 切到编辑器会话 C,确认工具会话回退到 A 并重新挂载时不请求焦点、窗口 first responder 仍为 C;同步出现任务和延迟原生回调都遵守隐藏祖先检查。重开工具窗口后才显式激活 A,缩放不抢焦点。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 终端持有键盘焦点时,纯 Control 组合键(Ctrl-D 结束输入、Ctrl-R 历史搜索、Ctrl-W 删词)原样交回终端写入 PTY,不再被应用键位的 Debug/Run 截获;带 Command、Shift 或 Option 的组合键仍走应用键位,Ctrl+Shift+F 等项目级命令不受影响(TerminalShortcutInputPolicyTests)。 Windows 聚焦终端或终端搜索框后按 Ctrl+Shift+F,须打开项目搜索而不弹出终端内搜索;Ctrl+F 仍搜索当前终端输出,Ctrl+Alt+F/AltGr 输入及已被其他命令处理的按键不能误开终端搜索。使用 terminal-keyboard.test.ts 验证快捷键边界,WebView2 实机验收保持 pending。 macOS #1542:显示保留的 Git Log/终端时,横纵分隔条中央及两侧偏移 4pt 仍经窗口事件完成拖动且只提交一次,宿主身份不变;根命中查询深度始终为 1,面板内部连续 100 次查询不产生额外根查询,鼠标移动不回查祖先 SwiftUI。验证禁用、收起(含嵌套分隔条)、恢复、卸载、跨窗口与登记表隔离、实时移动/裁剪、隐藏/透明和弱引用释放;保留宿主内部仍正常命中,外层边缘优先。Diff 两端收起后在普通与保留侧栏下均能经窗口拖回,不触发外层拖动。运行 GitLogReferenceResizeTests、SplitHandleHitTestTests 和 DiffScrollSynchronizationTests;旧实现先出现嵌套命中断言失败,新实现通过。较新 macOS 原生组件回归不替代 macOS 13 Intel 原报告崩溃验收,目标环境验证保持 pending。" + "verification": "验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 macOS 安装原有支持列表中的 Nerd Font Mono,使用 Starship pastel-powerline 核对圆弧、Git/Bun 图标和拼接效果;注册打包 UI 字体后终端选择与 12.5pt 字号不变,未安装 Nerd Font 时回退到 Menlo 或系统等宽字体。 安装包必须包含完整 JetBrains Mono Nerd Font Mono 四个字型及许可;在未安装 Nerd Font 的机器上仍能直接显示完整提示符,粗斜体符号与文字使用相同字体度量,发行 bundle 字节不变。 Windows 终端点击识别出的 localhost、127.0.0.1 和 HTTPS 网页链接时直接打开系统默认浏览器,不显示外部链接确认;文件路径继续打开 IDE 文件,浏览器打开失败记录错误。真实 Windows 产品交互验收 pending。 macOS 保留宿主:在明暗主题切换无背景图→有图→无图,真实终端标题栏和正文随之透明/恢复,宿主及输入控件身份保持。隐藏终端后聚焦编辑器,再从活动栏重开已有会话,first responder 回到同一终端输入控件;连续调整面板尺寸和环境值不抢焦点,重开后立即隐藏会取消待交付的激活请求。运行 TerminalPlacementFeatureModelTests.retainedTerminalRestoresFocusAndWallpaperWithoutRecreatingItsSurface,并通过实际应用截图及交互验收。 隐藏保留的工具终端后,从工具会话 B 切到编辑器会话 C,确认工具会话回退到 A 并重新挂载时不请求焦点、窗口 first responder 仍为 C;同步出现任务和延迟原生回调都遵守隐藏祖先检查。重开工具窗口后才显式激活 A,缩放不抢焦点。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 终端持有键盘焦点时,除用户配置为切换终端的快捷键外,纯 Control 组合键(Ctrl-D、Ctrl-R、Ctrl-W)交回终端,不被其他应用命令截获;切换终端的绑定可从终端内关闭面板,其他修饰键组合仍走应用键位。TerminalShortcutInputPolicyTests 覆盖真实 AppKit 监听器及焦点策略;隔离的 macOS Release 包验证导入的 Control 加反引号可在终端内关闭,Control-D 仍可结束测试 shell,流程前后包内文件哈希一致。 Windows 聚焦终端或终端搜索框后按 Ctrl+Shift+F,须打开项目搜索而不弹出终端内搜索;Ctrl+F 仍搜索当前终端输出,Ctrl+Alt+F/AltGr 输入及已被其他命令处理的按键不能误开终端搜索。使用 terminal-keyboard.test.ts 验证快捷键边界,WebView2 实机验收保持 pending。 macOS #1542:显示保留的 Git Log/终端时,横纵分隔条中央及两侧偏移 4pt 仍经窗口事件完成拖动且只提交一次,宿主身份不变;根命中查询深度始终为 1,面板内部连续 100 次查询不产生额外根查询,鼠标移动不回查祖先 SwiftUI。验证禁用、收起(含嵌套分隔条)、恢复、卸载、跨窗口与登记表隔离、实时移动/裁剪、隐藏/透明和弱引用释放;保留宿主内部仍正常命中,外层边缘优先。Diff 两端收起后在普通与保留侧栏下均能经窗口拖回,不触发外层拖动。运行 GitLogReferenceResizeTests、SplitHandleHitTestTests 和 DiffScrollSynchronizationTests;旧实现先出现嵌套命中断言失败,新实现通过。较新 macOS 原生组件回归不替代 macOS 13 Intel 原报告崩溃验收,目标环境验证保持 pending。" } From c48ea97276491ec18d64bd7d588eb75f311acee0 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Tue, 3 Nov 2026 14:21:54 -0800 Subject: [PATCH 2/4] Complete shortcut import acceptance fixes --- ...-10-31-macos-ide-editor-settings-import.md | 6 ++ .../Official/EditorKeymapImport/Cargo.lock | 2 +- .../Official/EditorKeymapImport/Cargo.toml | 2 +- .../Official/EditorKeymapImport/src/idea.rs | 18 +++++ .../Official/EditorKeymapImport/src/vscode.rs | 9 +++ .../Official/EditorKeymapImport/tests/idea.rs | 48 +++++++++++++ .../EditorKeymapImport/tests/vscode.rs | 19 +++++ macos/Resources/en.lproj/Localizable.strings | 4 +- .../zh-Hans.lproj/Localizable.strings | 4 +- .../Application/Composition/AppServices.swift | 2 +- .../EditorSettingsImportFeatureModel.swift | 20 +++++- .../AppModel/AppModel+DebugShortcut.swift | 22 ++++++ .../Models/AppModel/AppModel+Debugging.swift | 12 ---- .../Settings/EditorSettingsImport.swift | 21 ++++++ .../MacOS/MacEditorSettingsImportReader.swift | 7 +- .../MacEditorKeymapImportService.swift | 2 +- .../Plugins/MacKeymapImportHostService.swift | 1 + .../Plugins/MacKeymapImportRecognition.swift | 1 + .../Logging/LogEvents.generated.swift | 71 +++++++++++++++++++ .../EditorSettingsImportTests.swift | 33 +++++++++ .../LitheTests/KeyboardShortcutTests.swift | 2 +- .../src/diagnostics/catalog_generated.rs | 38 ++++++++++ shared/i18n/locales/en-US.json | 2 +- shared/i18n/locales/zh-CN.json | 2 +- shared/i18n/messages.json | 2 +- shared/logging/events/debug.json | 15 ++++ shared/logging/events/editor.json | 40 +++++++++++ 27 files changed, 378 insertions(+), 27 deletions(-) create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+DebugShortcut.swift diff --git a/.agents/notes/implemented/feature/2026-10-31-macos-ide-editor-settings-import.md b/.agents/notes/implemented/feature/2026-10-31-macos-ide-editor-settings-import.md index 3ace7f2d10..94a1064da3 100644 --- a/.agents/notes/implemented/feature/2026-10-31-macos-ide-editor-settings-import.md +++ b/.agents/notes/implemented/feature/2026-10-31-macos-ide-editor-settings-import.md @@ -215,3 +215,9 @@ VS Code 的 F5 是结合状态的启动或继续调试动作:Core 在未启动 2026-11-03 插件版 Release 包复验:主机固定官方非语言插件的签名策略,随包副本只在宿主签名封装内可用,外部副本仍需发布者签名。本机 VS Code 与 IDEA 来源均显示并成功导入键位,VS Code 的 F9 断点、F12 定义、Shift-F12 引用、Control 加反引号终端开关和 Control-D shell 输入、F5 启动并在断点暂停后继续,以及 IDEA 的 Command-L 跳行、Command-F8 断点、Command-G 查找下一个均经实际操作;自定义 VS Code 键位与源文件变化后拒绝旧预览、重新扫描及撤销也经实际操作。包文件哈希和签名在验收前后不变。覆盖范围是本机所装版本和隔离测试配置,不代表任意 IDE 扩展、键盘布局或 Windows 产品已经验收。 VS Code 移除规则按上游解析器的两阶段行为处理:只移除指定命令匹配的参考默认键,不清除其他命令的同键绑定或用户新增绑定;再依来源顺序处理单键覆盖及不能转成全局按键的条件/连续键。该修正有先失败后通过的插件回归,插件版打包应用仍需再次复验该边界。 + +2026-11-03 继续对照固定版本的 JetBrains Community 键位表,补齐 `$default` 和旧 `Mac OS X` 方案继承的文件内查找、替换、定义、实现、引用、命令搜索和断点键。VS Code 带上下文条件的移除规则不能安全推广为无条件移除;除明确为 `true` 的规则外跳过,并在导入提示中说明。两类修复均有先失败再通过的插件回归,覆盖源规则和应用后的完整插件测试。 + +性能对照使用同一台 macOS、500 KiB 文件和三轮连续输入:稳定 RSS 中位数从 237,072 KiB 到 237,152 KiB;单次采样波动较大,不能据此推断精确内存变化。采样脚本两侧均未采到 `editor.input` 延迟事件,不能据此声称输入延迟改善,也不能代替聚焦终端时 Control 组合键的专项性能测量。性能包、开发版偏好及其数据均使用隔离测试位置。 + +最终重打包复验:隔离开发版从本机 VS Code 和 IDEA 2025.3 实际导入快捷键,分别验证 VS Code `Ctrl+G`、IDEA `Command+L` 与 `Command+F8`;隔离 VS Code 配置验证用户自定义跳行、重启持久化、撤销、`F9` 断点、`F12` 跳转定义,以及不相关命令和条件移除规则不会误删 `Command+S`。隔离旧版 IDEA 方案预览确认上述常用键,实际验证 `Ctrl+F` 查找和 `Ctrl+F8` 断点。终端先执行测试命令确认焦点,再验证 Control 加反引号关闭和 `Ctrl+D` 结束 shell。修改来源后应用旧预览被拒绝,日志实际记录 `editor.settings_import.failed`、`stage=apply` 和 `changed`,未记录来源内容;本次核对的是日志文件,未完成日志窗口中的逐项查看。验收前后签名有效,1,433 个普通包文件的 SHA-256 清单逐项一致;应用、插件及语言服务退出,隔离偏好已恢复。前文要求再次复验 VS Code 移除边界的状态由本段更新。 diff --git a/Plugins/mac/Official/EditorKeymapImport/Cargo.lock b/Plugins/mac/Official/EditorKeymapImport/Cargo.lock index 1181f22fa6..21e5d94f1a 100644 --- a/Plugins/mac/Official/EditorKeymapImport/Cargo.lock +++ b/Plugins/mac/Official/EditorKeymapImport/Cargo.lock @@ -1552,7 +1552,7 @@ dependencies = [ [[package]] name = "lithe-plugin-sdk" version = "0.1.0" -source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs.git?rev=6e9bd1df0fd0f62fc653b10855f0353382d20354#6e9bd1df0fd0f62fc653b10855f0353382d20354" +source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs.git?rev=fd994411b19962dde66c1ac18edb16ad244be4bd#fd994411b19962dde66c1ac18edb16ad244be4bd" dependencies = [ "base64 0.22.1", "serde_json", diff --git a/Plugins/mac/Official/EditorKeymapImport/Cargo.toml b/Plugins/mac/Official/EditorKeymapImport/Cargo.toml index 7bf12fa762..2ccb472f20 100644 --- a/Plugins/mac/Official/EditorKeymapImport/Cargo.toml +++ b/Plugins/mac/Official/EditorKeymapImport/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" license = "Apache-2.0" [dependencies] -lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs.git", rev = "6e9bd1df0fd0f62fc653b10855f0353382d20354" } +lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs.git", rev = "fd994411b19962dde66c1ac18edb16ad244be4bd" } serde_json = "1" jsonc-parser = { version = "0.34", features = ["serde"] } quick-xml = "0.37" diff --git a/Plugins/mac/Official/EditorKeymapImport/src/idea.rs b/Plugins/mac/Official/EditorKeymapImport/src/idea.rs index 934bc6daa6..172ff176dd 100644 --- a/Plugins/mac/Official/EditorKeymapImport/src/idea.rs +++ b/Plugins/mac/Official/EditorKeymapImport/src/idea.rs @@ -226,6 +226,14 @@ fn defaults(name: &str) -> BTreeMap> { ("debug-step-over", &["F8"]), ("find-next", &["F3", "control L"]), ("find-previous", &["shift F3", "control shift L"]), + ("find-in-file", &["control F", "alt F3"]), + ("replace-in-file", &["control R"]), + ("go-to-definition", &["control B"]), + ("go-to-implementation", &["control alt B"]), + ("find-usages", &["alt F7"]), + ("find-action", &["control shift A"]), + ("toggle-breakpoint", &["control F8"]), + ("view-breakpoints", &["control shift F8"]), ("go-to-line", &["control G"]), ("navigate-back", &["control alt LEFT"]), ("navigate-forward", &["control alt RIGHT"]), @@ -243,6 +251,16 @@ fn defaults(name: &str) -> BTreeMap> { ("debug-step-over", &["F8"]), ("find-next", &["F3", "control L"]), ("find-previous", &["shift F3", "control shift L"]), + // The classic Mac scheme inherits these actions from $default, + // except for its own declaration shortcut. + ("find-in-file", &["control F", "alt F3"]), + ("replace-in-file", &["control R"]), + ("go-to-definition", &["meta B"]), + ("go-to-implementation", &["control alt B"]), + ("find-usages", &["alt F7"]), + ("find-action", &["control shift A"]), + ("toggle-breakpoint", &["control F8"]), + ("view-breakpoints", &["control shift F8"]), ("go-to-line", &["meta G"]), ("navigate-back", &["meta alt LEFT"]), ("navigate-forward", &["meta alt RIGHT"]), diff --git a/Plugins/mac/Official/EditorKeymapImport/src/vscode.rs b/Plugins/mac/Official/EditorKeymapImport/src/vscode.rs index eef0c45bcd..4c73e02383 100644 --- a/Plugins/mac/Official/EditorKeymapImport/src/vscode.rs +++ b/Plugins/mac/Official/EditorKeymapImport/src/vscode.rs @@ -100,6 +100,15 @@ pub fn import(source: &str) -> Result { else { continue; }; + // Conditional removals only target matching upstream context rules. + // Without their when-clause semantics we cannot treat them as global. + if object.get("when").is_some_and(|when| { + !when + .as_str() + .is_some_and(|expression| expression.trim() == "true") + }) { + continue; + } let key = match object.get("key") { None => None, Some(Value::String(value)) => match keypress(value) { diff --git a/Plugins/mac/Official/EditorKeymapImport/tests/idea.rs b/Plugins/mac/Official/EditorKeymapImport/tests/idea.rs index 9b5b786908..52cca0a29b 100644 --- a/Plugins/mac/Official/EditorKeymapImport/tests/idea.rs +++ b/Plugins/mac/Official/EditorKeymapImport/tests/idea.rs @@ -97,3 +97,51 @@ fn classic_scheme_keeps_its_own_line_and_find_bindings() { .contains(&json!({"commandID":"go-to-line", "keys":[{"key":"g", "modifiers":["meta"]}]}))); assert!(entries.contains(&json!({"commandID":"find-next", "keys":[{"key":"f3", "modifiers":[]}, {"key":"l", "modifiers":["control"]}]}))); } + +#[test] +fn classic_and_default_schemes_include_inherited_common_actions() { + for (scheme, declaration_key, declaration_modifiers) in [ + ("$default", "b", vec!["control"]), + ("Mac OS X", "b", vec!["meta"]), + ] { + let options = format!(""); + let result = import(&files(&[("options/keymap.xml", &options)])).unwrap(); + let entries = result["bindings"].as_array().unwrap(); + for (id, keys) in [ + ( + "find-in-file", + json!([{ "key":"f", "modifiers":["control"] }, { "key":"f3", "modifiers":["alt"] }]), + ), + ( + "replace-in-file", + json!([{ "key":"r", "modifiers":["control"] }]), + ), + ( + "go-to-definition", + json!([{ "key":declaration_key, "modifiers":declaration_modifiers }]), + ), + ( + "go-to-implementation", + json!([{ "key":"b", "modifiers":["control", "alt"] }]), + ), + ("find-usages", json!([{ "key":"f7", "modifiers":["alt"] }])), + ( + "find-action", + json!([{ "key":"a", "modifiers":["control", "shift"] }]), + ), + ( + "toggle-breakpoint", + json!([{ "key":"f8", "modifiers":["control"] }]), + ), + ( + "view-breakpoints", + json!([{ "key":"f8", "modifiers":["control", "shift"] }]), + ), + ] { + assert!( + entries.contains(&json!({"commandID":id,"keys":keys})), + "{scheme} missing {id}" + ); + } + } +} diff --git a/Plugins/mac/Official/EditorKeymapImport/tests/vscode.rs b/Plugins/mac/Official/EditorKeymapImport/tests/vscode.rs index 208aa40d11..95e8310b3e 100644 --- a/Plugins/mac/Official/EditorKeymapImport/tests/vscode.rs +++ b/Plugins/mac/Official/EditorKeymapImport/tests/vscode.rs @@ -103,6 +103,25 @@ fn removal_only_targets_the_named_default_and_never_user_bindings() { .contains(&json!({"commandID":"find-action", "keys":[]}))); } +#[test] +fn conditional_removal_does_not_remove_an_unconditional_default() { + let result = import( + r#"[{"key":"cmd+s","command":"-workbench.action.files.save","when":"editorTextFocus"}]"#, + ) + .unwrap(); + let bindings = result["bindings"].as_array().unwrap(); + assert!( + bindings.contains(&json!({"commandID":"save","keys":[{"key":"s","modifiers":["meta"]}]})) + ); + let unconditional = + import(r#"[{"key":"cmd+s","command":"-workbench.action.files.save","when":"true"}]"#) + .unwrap(); + assert!(unconditional["bindings"] + .as_array() + .unwrap() + .contains(&json!({"commandID":"save","keys":[]}))); +} + #[test] fn default_common_commands_match_the_published_macos_reference() { let result = import("[]").unwrap(); diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 61ddb14500..113edb82ae 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -3181,7 +3181,7 @@ "Reference changes could not be inspected" = "Reference changes could not be inspected"; "Reference default" = "Reference default"; "Reference list truncated; total updated: %lld, deleted: %lld" = "Reference list truncated; total updated: %lld, deleted: %lld"; -"Reference shortcuts are merged with supported user overrides. Conditional rules, chords and removals suppress the affected mappings; extension keymaps are skipped." = "Reference shortcuts are merged with supported user overrides. Conditional rules, chords and removals suppress the affected mappings; extension keymaps are skipped."; +"Reference shortcuts are merged with supported user overrides. Conditional bindings and chords may suppress mappings; conditional removals and extension keymaps are skipped." = "Reference shortcuts are merged with supported user overrides. Conditional bindings and chords may suppress mappings; conditional removals and extension keymaps are skipped."; "Refresh" = "Refresh"; "Refresh All" = "Refresh All"; "Refresh Files" = "Refresh Files"; @@ -5238,7 +5238,7 @@ "mac.editorImport.extended1" = "Insert spaces"; "mac.editorImport.extended2" = "Detect indentation"; "mac.editorImport.extended3" = "Code settings"; -"mac.editorImport.extended4" = "Reference shortcuts are merged with supported user overrides. Conditional rules, chords and removals suppress the affected mappings; extension keymaps are skipped."; +"mac.editorImport.extended4" = "Reference shortcuts are merged with supported user overrides. Conditional bindings and chords may suppress mappings; conditional removals and extension keymaps are skipped."; "mac.editorImport.extended5" = "Only global indentation is imported. Language-specific code styles and formatter rules are skipped."; "mac.editorImport.extended6" = "Keyboard shortcut conflicts with %1$@. Adjust your keymap before importing."; "mac.editorImport.extended7" = "Appearance defaults are labeled separately. Workspace and language overrides, formatters, extensions and account data are skipped."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 80663b96d0..586668f358 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3181,7 +3181,7 @@ "Reference changes could not be inspected" = "无法读取引用变更"; "Reference default" = "参考默认值"; "Reference list truncated; total updated: %lld, deleted: %lld" = "引用列表已截断;更新总数:%lld,删除总数:%lld"; -"Reference shortcuts are merged with supported user overrides. Conditional rules, chords and removals suppress the affected mappings; extension keymaps are skipped." = "参考快捷键与支持的用户覆盖合并。带条件、连续按键和移除规则会阻止相关映射;跳过扩展提供的键位方案。"; +"Reference shortcuts are merged with supported user overrides. Conditional bindings and chords may suppress mappings; conditional removals and extension keymaps are skipped." = "参考快捷键与支持的用户覆盖合并。带条件的绑定和连续按键可能阻止相关映射;跳过带条件的移除规则及扩展键位方案。"; "Refresh" = "刷新"; "Refresh All" = "全部刷新"; "Refresh Files" = "刷新文件"; @@ -5238,7 +5238,7 @@ "mac.editorImport.extended1" = "使用空格缩进"; "mac.editorImport.extended2" = "自动检测缩进"; "mac.editorImport.extended3" = "代码设置"; -"mac.editorImport.extended4" = "参考快捷键与支持的用户覆盖合并。带条件、连续按键和移除规则会阻止相关映射;跳过扩展提供的键位方案。"; +"mac.editorImport.extended4" = "参考快捷键与支持的用户覆盖合并。带条件的绑定和连续按键可能阻止相关映射;跳过带条件的移除规则及扩展键位方案。"; "mac.editorImport.extended5" = "仅导入全局缩进。跳过语言专属代码样式和格式化规则。"; "mac.editorImport.extended6" = "快捷键与“%1$@”冲突,请调整键位后再导入。"; "mac.editorImport.extended7" = "外观默认值已单独标注。跳过工作区与语言覆盖、格式化器、扩展和账号数据。"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index ec7e891772..5cfe567caf 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -23,7 +23,7 @@ final class AppServices { platformUI.chooseDirectory(title: settings.language.localizedString("Choose IDE configuration directory"), prompt: settings.language.localizedString("Choose")) }, scan: editorSettingsImportScan, planKeymap: editorSettingsImportKeymapPlan, - loadKeymapOnSelection: editorSettingsImportLoadKeymapOnSelection) + loadKeymapOnSelection: editorSettingsImportLoadKeymapOnSelection, logSink: logSink) } var ideCapabilityRegistry: IdeCapabilityRegistry? diff --git a/macos/Sources/Lithe/Application/Features/EditorSettingsImportFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorSettingsImportFeatureModel.swift index f31ac946ac..56b0e960b5 100644 --- a/macos/Sources/Lithe/Application/Features/EditorSettingsImportFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorSettingsImportFeatureModel.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import LitheModuleAPI @MainActor final class EditorSettingsImportFeatureModel: ObservableObject { @@ -19,16 +20,18 @@ final class EditorSettingsImportFeatureModel: ObservableObject { private let scanSources: ((URL?) async throws -> [EditorSettingsImportSource])? private let planKeymap: (([KeymapImportCurrent], [KeymapImportBinding]) throws -> KeymapImportBindingPlan)? private let loadKeymapOnSelection: Bool + private let logSink: (any LogSink)? init(settings: AppSettings, chooseDirectory: @escaping () -> URL?, scan: ((URL?) async throws -> [EditorSettingsImportSource])?, planKeymap: (([KeymapImportCurrent], [KeymapImportBinding]) throws -> KeymapImportBindingPlan)? = nil, - loadKeymapOnSelection: Bool = false) { + loadKeymapOnSelection: Bool = false, logSink: (any LogSink)? = nil) { self.settings = settings startupFinished = settings.didOfferEditorSettingsImport chooseSourceDirectory = chooseDirectory scanSources = scan self.planKeymap = planKeymap self.loadKeymapOnSelection = loadKeymapOnSelection + self.logSink = logSink } var source: EditorSettingsImportSource? { sources.first { $0.id == sourceID } } @@ -95,6 +98,7 @@ final class EditorSettingsImportFeatureModel: ObservableObject { if let (plan, _) = try keymapPlan(incoming: incoming, current: current), let conflict = plan.conflicts.first { return LitheCommandCatalog.command(id: conflict.ownerID)?.title ?? conflict.ownerID } + // log-exempt: 随输入实时重算的校验提示:预览失败会阻止用户提交,提交边界记录实际失败 } catch { return "Keyboard shortcuts" } } guard let map = importValues["keymap"].flatMap(EditorImportKeymap.decode) else { return nil } @@ -135,6 +139,11 @@ final class EditorSettingsImportFeatureModel: ObservableObject { do { let scanned = try await scan(directory) try Task.checkCancellation() + let warningSources = scanned.filter { !$0.warnings.isEmpty } + if !warningSources.isEmpty { + logSink?.emit(EditorSettingsImportWarningsShown(sourceCount: Int64(warningSources.count), + warningCount: Int64(warningSources.reduce(0) { $0 + $1.warnings.count }))) + } if isChoosingSettings, let directory { // Refresh this preview without losing the other choices when navigating back. sources = sources.flatMap { $0.directory == directory ? scanned : [$0] } @@ -144,9 +153,11 @@ final class EditorSettingsImportFeatureModel: ObservableObject { select(scanned.first?.id ?? "") if scanned.isEmpty { message = "No supported IDE configuration was found. You can choose a configuration directory." } return true + // log-exempt: 用户取消:关闭导入界面会取消尚未应用的扫描 } catch is CancellationError { return false } catch { + logSink?.emit(EditorSettingsImportFailed(error: EditorSettingsImportError.logFailure(error), stage: "scan")) message = (error as? EditorSettingsImportError)?.errorDescription ?? "The configuration could not be read. Check the selected directory and file permissions." return false @@ -231,9 +242,11 @@ final class EditorSettingsImportFeatureModel: ObservableObject { selected.removeAll() importResult = (changes.count, unselected) return true + // log-exempt: 用户取消:取消导入时不会提交尚未完成的结果 } catch is CancellationError { return false } catch { + logSink?.emit(EditorSettingsImportFailed(error: EditorSettingsImportError.logFailure(error), stage: "apply")) message = (error as? EditorSettingsImportError)?.errorDescription ?? "The configuration could not be read. Check the selected directory and file permissions." return false } @@ -247,6 +260,9 @@ final class EditorSettingsImportFeatureModel: ObservableObject { baseline = settings.editorImportValues() selected.removeAll() message = "The previous editor settings have been restored." - } catch { message = (error as? EditorSettingsImportError)?.errorDescription } + } catch { + logSink?.emit(EditorSettingsImportFailed(error: EditorSettingsImportError.logFailure(error), stage: "undo")) + message = (error as? EditorSettingsImportError)?.errorDescription + } } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+DebugShortcut.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+DebugShortcut.swift new file mode 100644 index 0000000000..559152759e --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+DebugShortcut.swift @@ -0,0 +1,22 @@ +import LitheModuleAPI + +@MainActor +extension AppModel { + func startOrContinueDebugging() { + let feature = genericDebugFeatureIfActive + guard let decide = services.debugStartOrContinue else { return } + let action: DebugStartOrContinueAction + do { + action = try decide(feature?.isSessionActive == true, feature?.state == .paused, + feature?.isExecutionRequestPending == true) + } catch { + services.logSink?.emit(DebugShortcutDecisionFailed(error: error)) + return + } + switch action { + case .start: startDebugging() + case .continue: resumeDebugging() + case .none: break + } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift index 4782262dd7..7aa896c939 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift @@ -78,18 +78,6 @@ extension AppModel { runWorkflowCoordinator.startModuleOperation { [weak self] in await self?.startDebuggingAfterActivation() } } - func startOrContinueDebugging() { - let feature = genericDebugFeatureIfActive - guard let decide = services.debugStartOrContinue, - let action = try? decide(feature?.isSessionActive == true, feature?.state == .paused, - feature?.isExecutionRequestPending == true) else { return } - switch action { - case .start: startDebugging() - case .continue: resumeDebugging() - case .none: break - } - } - func startOrRestartDebugging() { if let target = activeTestTarget, target.name != genericDebugFeatureIfActive?.targetTitle { startDebugging() diff --git a/macos/Sources/Lithe/Models/Settings/EditorSettingsImport.swift b/macos/Sources/Lithe/Models/Settings/EditorSettingsImport.swift index 15794e2a6e..ef69671676 100644 --- a/macos/Sources/Lithe/Models/Settings/EditorSettingsImport.swift +++ b/macos/Sources/Lithe/Models/Settings/EditorSettingsImport.swift @@ -96,6 +96,27 @@ enum EditorSettingsImportError: Error, LocalizedError { case changed, invalid, storage, read, fontSizeDependency case ambiguousShortcutProviders case shortcutConflict(String) + /// Keep source paths and provider-supplied prose out of diagnostic fields. + static func logFailure(_ error: any Error) -> LogFailure { + if let failure = error as? EditorSettingsImportError { + let code: String + switch failure { + case .changed: code = "changed" + case .invalid: code = "invalid" + case .storage: code = "storage" + case .read: code = "read" + case .fontSizeDependency: code = "font_size_dependency" + case .ambiguousShortcutProviders: code = "ambiguous_providers" + case .shortcutConflict: code = "shortcut_conflict" + } + return LogFailure(domain: "editor.settings_import", code: code) + } + let native = error as NSError + if [NSCocoaErrorDomain, NSPOSIXErrorDomain].contains(native.domain) { + return LogFailure(domain: native.domain, code: String(native.code)) + } + return LogFailure(domain: "editor.settings_import", code: "source_failure") + } var errorDescription: String? { if case .shortcutConflict(let command) = self { return String(format: L10n.string("Keyboard shortcut conflicts with %@. Adjust your keymap before importing."), L10n.string(key: command)) diff --git a/macos/Sources/Lithe/Platform/MacOS/MacEditorSettingsImportReader.swift b/macos/Sources/Lithe/Platform/MacOS/MacEditorSettingsImportReader.swift index 633b76af48..bacc897237 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacEditorSettingsImportReader.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacEditorSettingsImportReader.swift @@ -56,7 +56,12 @@ enum MacEditorSettingsImportReader { guard vscode || idea else { continue } var source = EditorSettingsImportSource(directory: root, title: title, revision: "", values: [:], warnings: [], isVSCode: vscode) do { source = try read(root: root, title: title, vscode: vscode, fonts: fonts, logSink: logSink) } - catch { source.readFailed = true } + // log-exempt: 用户取消:退出后不再读取或报告该来源 + catch is CancellationError { throw CancellationError() } + catch { + logSink?.emit(EditorSettingsImportFailed(error: EditorSettingsImportError.logFailure(error), stage: "source-read")) + source.readFailed = true + } source.isCursor = product?.0 == "Cursor" if source.isCursor || root.deletingLastPathComponent().lastPathComponent == "profiles" { source.approximateFields.formUnion(source.defaultFields) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacEditorKeymapImportService.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacEditorKeymapImportService.swift index 272dc4a6e8..8ab7565c68 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacEditorKeymapImportService.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacEditorKeymapImportService.swift @@ -50,7 +50,7 @@ final class MacEditorKeymapImportService { result.values["keymap"] = EditorImportKeymap(preset: "", commands: commands).encoded result.approximateFields.insert("keymap") if imported.sourceFileCount == 0 { result.defaultFields.insert("keymap") } - let warning = "Reference shortcuts are merged with supported user overrides. Conditional rules, chords and removals suppress the affected mappings; extension keymaps are skipped." + let warning = "Reference shortcuts are merged with supported user overrides. Conditional bindings and chords may suppress mappings; conditional removals and extension keymaps are skipped." if !result.warnings.contains(warning) { result.warnings.append(warning) } } return result diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportHostService.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportHostService.swift index efbbd78ab2..82f8086bff 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportHostService.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportHostService.swift @@ -19,6 +19,7 @@ final class MacKeymapImportHostService: PluginHostServiceHandling { case let .string(providerID) = call.params["providerID"] else { return .failure(invalid) } let snapshot: MacKeymapImportSnapshot do { snapshot = try snapshots.snapshot(token, pluginID: call.pluginID, sessionID: call.sessionID, providerID: providerID) } + // log-exempt: 预期的缺失:会话结束或快照撤销后迟到的读取返回过期 catch { return .failure(PluginHostServiceError(.contextExpired, L10n.string("The plugin host request could not be completed."))) } switch call.request.method { diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportRecognition.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportRecognition.swift index 00b033ae94..b9890d8eb0 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportRecognition.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacKeymapImportRecognition.swift @@ -36,6 +36,7 @@ enum MacKeymapImportRecognition { let kind = status.st_mode & S_IFMT guard kind != S_IFLNK else { throw MacKeymapImportSnapshot.Failure.invalidPlan } if kind == expectedKind { return true } + // log-exempt: 预期的缺失:可选识别标记不存在时继续检查其他标记 } catch PluginWorkspaceFileError.notFound { continue } diff --git a/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift b/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift index 0bbe946345..58b1202a3f 100644 --- a/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift +++ b/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift @@ -1174,6 +1174,29 @@ public struct DebugSessionCleanupFailed: LogEvent { } } +public struct DebugShortcutDecisionFailed: LogEvent { + public static let id = "debug.shortcut.decision_failed" + public static let level: LogLevel = .error + public static let messageTemplate = "Debug shortcut state decision failed" + public static let ratePerMinute = 60 + public static let owner = "Lithe" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`error`: any Error, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.debug, segments: []) + var _logValues: [String: LogFieldValue] = [:] + _logValues["error"] = .error(`error`) + self.fields = _logValues + } +} + public struct EditorDocumentHeaderFailed: LogEvent { public static let id = "editor.document.header_failed" public static let level: LogLevel = .warn @@ -1389,6 +1412,54 @@ public struct EditorPreviewUpdateFailed: LogEvent { } } +public struct EditorSettingsImportFailed: LogEvent { + public static let id = "editor.settings_import.failed" + public static let level: LogLevel = .error + public static let messageTemplate = "Editor settings import failed at {stage}" + public static let ratePerMinute = 60 + public static let owner = "Lithe" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`error`: any Error, `stage`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.editor, segments: []) + var _logValues: [String: LogFieldValue] = [:] + _logValues["error"] = .error(`error`) + _logValues["stage"] = .token(`stage`) + self.fields = _logValues + } +} + +public struct EditorSettingsImportWarningsShown: LogEvent { + public static let id = "editor.settings_import.warnings_shown" + public static let level: LogLevel = .warn + public static let messageTemplate = "Editor settings import has unsupported source settings" + public static let ratePerMinute = 60 + public static let owner = "Lithe" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`sourceCount`: Int64, `warningCount`: Int64, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.editor, segments: []) + var _logValues: [String: LogFieldValue] = [:] + _logValues["source_count"] = .int(`sourceCount`) + _logValues["warning_count"] = .int(`warningCount`) + self.fields = _logValues + } +} + public struct EditorShortcutsUpdateFailed: LogEvent { public static let id = "editor.shortcuts.update_failed" public static let level: LogLevel = .error diff --git a/macos/Tests/LitheTests/EditorSettingsImportTests.swift b/macos/Tests/LitheTests/EditorSettingsImportTests.swift index 1e9ad44788..088c6f6914 100644 --- a/macos/Tests/LitheTests/EditorSettingsImportTests.swift +++ b/macos/Tests/LitheTests/EditorSettingsImportTests.swift @@ -2,10 +2,43 @@ import Foundation import AppKit import SwiftUI import Testing +import LitheModuleAPI @testable import Lithe @Suite("Editor settings import") struct EditorSettingsImportTests { + @Test @MainActor func importLogsFailuresAndWarningCountsWithoutSourceContents() async throws { + let sink = TypedLogRecorder() + let settings = AppSettings(store: ImportTestStore()) + let source = EditorSettingsImportSource(directory: URL(fileURLWithPath: "/fixture/private"), + title: "Private source", revision: "one", values: ["tabSize": "2"], warnings: ["private warning"]) + var shouldFail = false + let feature = EditorSettingsImportFeatureModel(settings: settings, chooseDirectory: { nil }, scan: { _ in + if shouldFail { throw EditorSettingsImportError.changed } + return [source] + }, logSink: sink) + #expect(await feature.scan()) + let warning = try #require(sink.events.first) + #expect(type(of: warning).id == "editor.settings_import.warnings_shown") + #expect(Set(warning.fields.keys) == ["source_count", "warning_count"]) + shouldFail = true + #expect(await feature.apply() == false) + let failure = try #require(sink.events.last) + #expect(type(of: failure).id == "editor.settings_import.failed") + if case .token(let stage) = failure.fields["stage"] { #expect(stage == "apply") } + else { Issue.record("The failed import did not retain its operation stage") } + if case .error(let error) = failure.fields["error"], let safe = error as? LogFailure { + #expect(safe.domain == "editor.settings_import" && safe.code == "changed") + } else { Issue.record("The import error classification is missing") } + #expect(sink.events.count == 2) + let privateError = NSError(domain: "plugin.private-content", code: 9, + userInfo: [NSLocalizedDescriptionKey: "/private/source.json contents"]) + let classified = EditorSettingsImportError.logFailure(privateError) + #expect(classified.domain == "editor.settings_import" && classified.code == "source_failure") + let native = EditorSettingsImportError.logFailure(NSError(domain: NSPOSIXErrorDomain, code: 13)) + #expect(native.domain == NSPOSIXErrorDomain && native.code == "13") + } + @Test @MainActor func detectedIndentationBelongsToEachDocumentAndRejectsInvalidWidths() { let first = EditorDocument(url: URL(fileURLWithPath: "/fixture/first.js"), text: "", modificationDate: nil) let second = EditorDocument(url: URL(fileURLWithPath: "/fixture/second.js"), text: "", modificationDate: nil) diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index d3556d8ff4..1146c2dd21 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,7 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() throws { let commands = LitheCommandCatalog.commands - #expect(commands.count == 40) + #expect(commands.count == 41) let startOrContinue = try #require(LitheCommandCatalog.command(id: "debug-start-or-continue")) #expect(startOrContinue.defaultBindings.isEmpty) #expect(LitheCommandCatalog.moduleOwner(for: startOrContinue.id) == .debug) diff --git a/rust/lithe-core/src/diagnostics/catalog_generated.rs b/rust/lithe-core/src/diagnostics/catalog_generated.rs index 423dc305ed..f618b73fce 100644 --- a/rust/lithe-core/src/diagnostics/catalog_generated.rs +++ b/rust/lithe-core/src/diagnostics/catalog_generated.rs @@ -640,6 +640,18 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "LitheDebugModule", }, + EventMetadata { + id: "debug.shortcut.decision_failed", + channel: "debug", + level: LogLevel::Error, + message: "Debug shortcut state decision failed", + fields: &[ + FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Core failure while choosing whether the requested debug shortcut should start or continue", reason: None }, + ], + span: None, + rate: 60, + owner: "Lithe", + }, EventMetadata { id: "editor.document.header_failed", channel: "editor", @@ -756,6 +768,32 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 30, owner: "Lithe", }, + EventMetadata { + id: "editor.settings_import.failed", + channel: "editor", + level: LogLevel::Error, + message: "Editor settings import failed at {stage}", + fields: &[ + FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Import or native I/O error classification; source paths, configuration contents and provider prose are omitted", reason: None }, + FieldMetadata { name: "stage", field_type: FieldType::Token, required: true, description: "Import workflow boundary that failed: source-read, scan, apply, or undo", reason: None }, + ], + span: None, + rate: 60, + owner: "Lithe", + }, + EventMetadata { + id: "editor.settings_import.warnings_shown", + channel: "editor", + level: LogLevel::Warn, + message: "Editor settings import has unsupported source settings", + fields: &[ + FieldMetadata { name: "source_count", field_type: FieldType::Int, required: true, description: "Number of discovered sources with warnings shown in this scan", reason: None }, + FieldMetadata { name: "warning_count", field_type: FieldType::Int, required: true, description: "Total number of source warnings shown in this scan", reason: None }, + ], + span: None, + rate: 60, + owner: "Lithe", + }, EventMetadata { id: "editor.shortcuts.update_failed", channel: "editor", diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 5bde09af8c..4e6b7ab395 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9918,7 +9918,7 @@ "mac.editorImport.extended1": "Insert spaces", "mac.editorImport.extended2": "Detect indentation", "mac.editorImport.extended3": "Code settings", - "mac.editorImport.extended4": "Reference shortcuts are merged with supported user overrides. Conditional rules, chords and removals suppress the affected mappings; extension keymaps are skipped.", + "mac.editorImport.extended4": "Reference shortcuts are merged with supported user overrides. Conditional bindings and chords may suppress mappings; conditional removals and extension keymaps are skipped.", "mac.editorImport.extended5": "Only global indentation is imported. Language-specific code styles and formatter rules are skipped.", "mac.editorImport.extended6": "Keyboard shortcut conflicts with {command}. Adjust your keymap before importing.", "mac.editorImport.extended7": "Appearance defaults are labeled separately. Workspace and language overrides, formatters, extensions and account data are skipped.", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 4c38e6cc56..92b346e6a8 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9918,7 +9918,7 @@ "mac.editorImport.extended1": "使用空格缩进", "mac.editorImport.extended2": "自动检测缩进", "mac.editorImport.extended3": "代码设置", - "mac.editorImport.extended4": "参考快捷键与支持的用户覆盖合并。带条件、连续按键和移除规则会阻止相关映射;跳过扩展提供的键位方案。", + "mac.editorImport.extended4": "参考快捷键与支持的用户覆盖合并。带条件的绑定和连续按键可能阻止相关映射;跳过带条件的移除规则及扩展键位方案。", "mac.editorImport.extended5": "仅导入全局缩进。跳过语言专属代码样式和格式化规则。", "mac.editorImport.extended6": "快捷键与“{command}”冲突,请调整键位后再导入。", "mac.editorImport.extended7": "外观默认值已单独标注。跳过工作区与语言覆盖、格式化器、扩展和账号数据。", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 89bd246c76..ae072277ff 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -95332,7 +95332,7 @@ "macos" ], "macos": { - "key": "Reference shortcuts are merged with supported user overrides. Conditional rules, chords and removals suppress the affected mappings; extension keymaps are skipped.", + "key": "Reference shortcuts are merged with supported user overrides. Conditional bindings and chords may suppress mappings; conditional removals and extension keymaps are skipped.", "arguments": [] } }, diff --git a/shared/logging/events/debug.json b/shared/logging/events/debug.json index e9762d3462..be17c607b0 100644 --- a/shared/logging/events/debug.json +++ b/shared/logging/events/debug.json @@ -46,6 +46,21 @@ }, "rate": 60, "owner": "LitheDebugModule" + }, + { + "id": "debug.shortcut.decision_failed", + "channel": "debug", + "level": "error", + "message": "Debug shortcut state decision failed", + "fields": { + "error": { + "type": "error", + "required": true, + "description": "Core failure while choosing whether the requested debug shortcut should start or continue" + } + }, + "rate": 60, + "owner": "Lithe" } ] } diff --git a/shared/logging/events/editor.json b/shared/logging/events/editor.json index 3421f620f8..75d652e1fe 100644 --- a/shared/logging/events/editor.json +++ b/shared/logging/events/editor.json @@ -2,6 +2,46 @@ "version": 1, "root": "editor", "events": [ + { + "id": "editor.settings_import.failed", + "channel": "editor", + "level": "error", + "message": "Editor settings import failed at {stage}", + "fields": { + "stage": { + "type": "token", + "required": true, + "description": "Import workflow boundary that failed: source-read, scan, apply, or undo" + }, + "error": { + "type": "error", + "required": true, + "description": "Import or native I/O error classification; source paths, configuration contents and provider prose are omitted" + } + }, + "rate": 60, + "owner": "Lithe" + }, + { + "id": "editor.settings_import.warnings_shown", + "channel": "editor", + "level": "warn", + "message": "Editor settings import has unsupported source settings", + "fields": { + "source_count": { + "type": "int", + "required": true, + "description": "Number of discovered sources with warnings shown in this scan" + }, + "warning_count": { + "type": "int", + "required": true, + "description": "Total number of source warnings shown in this scan" + } + }, + "rate": 60, + "owner": "Lithe" + }, { "id": "editor.document.header_failed", "channel": "editor", From e50899eeb46edde45c5d3c336ddddccfb8912651 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Tue, 3 Nov 2026 15:33:51 -0800 Subject: [PATCH 3/4] Fix official plugin package verification --- .../OfficialPluginVerifier.swift | 68 ++++++++++++++----- scripts/plugins/verify-official-plugins.sh | 31 ++++++++- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift b/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift index ea4b455a57..943f5cfd68 100644 --- a/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift +++ b/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift @@ -6,12 +6,22 @@ import LitheModuleAPI /// Checks a built official declarative plugin package before release. Rust /// Core tests validate the declaration semantics; this verifier checks what /// only the assembled package can prove: the host trust policy matches, every -/// packaged executable exists, native code appears only inside a runtime the -/// plugin declares, and the synthesized modules join the built-in graph. +/// packaged executable exists, native code is declared by the plugin, and +/// the synthesized modules join the built-in graph. @main struct OfficialPluginVerifier { @MainActor - static func main() async throws { + static func main() async { + do { + try await verify() + } catch { + FileHandle.standardError.write(Data("\(error)\n".utf8)) + exit(EXIT_FAILURE) + } + } + + @MainActor + private static func verify() async throws { guard CommandLine.arguments.count == 2 else { throw VerificationError.usage } @@ -19,13 +29,12 @@ struct OfficialPluginVerifier { fileURLWithPath: CommandLine.arguments[1], isDirectory: true ).standardizedFileURL.resolvingSymlinksInPath() - let descriptor = try DeclarativePluginDescriptor( - normalizedManifest: Data(contentsOf: packageURL.appendingPathComponent("plugin.json")) - ) + let manifestData = try Data(contentsOf: packageURL.appendingPathComponent("plugin.json")) + let descriptor = try DeclarativePluginDescriptor(normalizedManifest: manifestData) let manifest = descriptor.pluginManifest - guard let entry = OfficialPluginCatalog.entry(for: manifest.id), - entry.signatureRequirement == manifest.vendor.signatureRequirement, - entry.bundledSignatureRequirement == manifest.vendor.bundledSignatureRequirement, + guard let policy = OfficialPluginCatalog.trustPolicy(for: manifest.id), + policy.signatureRequirement == manifest.vendor.signatureRequirement, + policy.bundledSignatureRequirement == manifest.vendor.bundledSignatureRequirement, packageURL.lastPathComponent == manifest.id.rawValue else { throw VerificationError.trustPolicyMismatch(manifest.id.rawValue) } @@ -55,7 +64,16 @@ struct OfficialPluginVerifier { } } try rejectSymbolicLinks(in: packageURL) - try rejectNativeCode(in: packageURL, exceptIn: runtimeDirectories) + var backendExecutables: Set = [] + let artifact = try JSONDecoder().decode(BackendArtifact.self, from: manifestData) + if let launch = artifact.backend?.launch, launch.kind == "packaged" { + guard let path = launch.path, isSafeRelativePath(path) else { + throw VerificationError.missingLaunch("backend") + } + try requireFile(path, in: packageURL, executable: true) + backendExecutables.insert(path) + } + try rejectNativeCode(in: packageURL, exceptIn: runtimeDirectories, exceptFiles: backendExecutables) _ = try ValidatedPluginCatalog( manifests: BuiltInPluginCatalog.manifests + [manifest], @@ -75,6 +93,21 @@ struct OfficialPluginVerifier { print("Verified \(manifest.id) as a declarative package") } + /// The verifier checks packaged artifacts only; Core owns launch planning. + private struct BackendArtifact: Decodable { + struct Backend: Decodable { + struct Launch: Decodable { let kind: String; let path: String? } + let launch: Launch + } + let backend: Backend? + } + + private static func isSafeRelativePath(_ path: String) -> Bool { + !path.isEmpty && !path.contains("\\") && !path.contains("\0") + && path.split(separator: "/", omittingEmptySubsequences: false) + .allSatisfy { !$0.isEmpty && $0 != "." && $0 != ".." } + } + private static func requireFile(_ relativePath: String, in packageURL: URL, executable: Bool) throws { let url = packageURL.appendingPathComponent(relativePath) let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) @@ -99,11 +132,11 @@ struct OfficialPluginVerifier { } } - /// The host never loads plugin code, so Mach-O files are allowed only in - /// the top-level directory of a runtime the plugin declares with - /// `packagedRuntime`. Bundled builds use the application's seal; independently - /// installed builds require a publisher signature over the final runtime. - private static func rejectNativeCode(in packageURL: URL, exceptIn runtimeDirectories: [String]) throws { + /// Permit only a declared backend executable or a declared packaged runtime. + /// Bundled builds use the application's seal; independent packages require + /// a publisher signature over the final artifacts. + private static func rejectNativeCode(in packageURL: URL, exceptIn runtimeDirectories: [String], + exceptFiles: Set) throws { let machOMagics: Set = [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca] guard let enumerator = FileManager.default.enumerator( at: packageURL, @@ -117,7 +150,8 @@ struct OfficialPluginVerifier { let magic = header.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) } if machOMagics.contains(magic) || machOMagics.contains(magic.byteSwapped) { let relativePath = fileURL.path.replacingOccurrences(of: packageURL.path + "/", with: "") - if runtimeDirectories.contains(where: { relativePath.hasPrefix($0 + "/") }) { continue } + if exceptFiles.contains(relativePath) + || runtimeDirectories.contains(where: { relativePath.hasPrefix($0 + "/") }) { continue } throw VerificationError.nativeCode(relativePath) } } @@ -138,7 +172,7 @@ private enum VerificationError: Error, CustomStringConvertible { case .usage: "Usage: LitheOfficialPluginVerifier " case .trustPolicyMismatch(let id): "\(id) does not match the host's official plugin trust policy." case .missingLaunch(let path): "A declared launch file is missing or not executable: \(path)" - case .nativeCode(let path): "Native code is only allowed inside a declared packaged runtime: \(path)" + case .nativeCode(let path): "Native code is only allowed in a declared backend or packaged runtime: \(path)" case .symbolicLink(let path): "Plugin packages must not contain symbolic links: \(path)" case .unreadablePackage: "The plugin package could not be read." case .factoryMustNotBeInvoked: "A module factory was invoked during static validation." diff --git a/scripts/plugins/verify-official-plugins.sh b/scripts/plugins/verify-official-plugins.sh index fa977c7936..776ea05a07 100755 --- a/scripts/plugins/verify-official-plugins.sh +++ b/scripts/plugins/verify-official-plugins.sh @@ -35,7 +35,7 @@ PLUGIN_ROOT=$(scripts/plugins/build-official-plugins.sh "${BUILD_ARGS[@]}") exit 1 } # A verification that sees zero packages must fail, not pass vacuously. -for official_plugin in dev.lithe.plugin.java-support; do +for official_plugin in dev.lithe.plugin.java-support dev.lithe.plugin.editor-keymap-import; do [[ -d "$PLUGIN_ROOT/$official_plugin" ]] || { print -u2 -- "Official plugin package was not built: $official_plugin" exit 1 @@ -66,4 +66,33 @@ for plugin in "${plugins[@]}"; do "$package_signer_binary" --verify "$plugin" fi done + +# The import provider is the first official package with a packaged native +# backend. Verify the declared binary is required and no sibling binary is +# accidentally admitted by the verifier's native-code policy. +fixture_root=$(mktemp -d "${TMPDIR:-/tmp}/lithe-official-plugin-verifier.XXXXXX") +trap 'rm -rf "$fixture_root"' EXIT +fixture="$fixture_root/dev.lithe.plugin.editor-keymap-import" +cp -R "$PLUGIN_ROOT/dev.lithe.plugin.editor-keymap-import" "$fixture" +verifier_bin_dir=$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path) +verifier_binary="$verifier_bin_dir/LitheOfficialPluginVerifier" +cp "$fixture/bin/editor-keymap-import" "$fixture/bin/undeclared-native" +if "$verifier_binary" "$fixture" > "$fixture_root/unexpected.log" 2>&1; then + print -u2 -- "Official plugin verifier accepted undeclared native code" + exit 1 +fi +grep -Fq 'Native code is only allowed in a declared backend or packaged runtime:' "$fixture_root/unexpected.log" +rm "$fixture/bin/undeclared-native" "$fixture/bin/editor-keymap-import" +if "$verifier_binary" "$fixture" > "$fixture_root/missing.log" 2>&1; then + print -u2 -- "Official plugin verifier accepted a missing backend executable" + exit 1 +fi +grep -Fq 'A declared launch file is missing or not executable:' "$fixture_root/missing.log" +cp "$PLUGIN_ROOT/dev.lithe.plugin.editor-keymap-import/bin/editor-keymap-import" "$fixture_root/outside" +node -e 'const fs = require("node:fs"); const path = process.argv[1]; const manifest = JSON.parse(fs.readFileSync(path, "utf8")); manifest.backend.launch.path = "../outside"; fs.writeFileSync(path, JSON.stringify(manifest));' "$fixture/plugin.json" +if "$verifier_binary" "$fixture" > "$fixture_root/traversal.log" 2>&1; then + print -u2 -- "Official plugin verifier accepted a backend outside its package" + exit 1 +fi +grep -Fq 'A declared launch file is missing or not executable:' "$fixture_root/traversal.log" print "Verified ${#plugins[@]} official declarative plugin package(s)" From e8b2a76dd9eaccee630956ea1ae580d2613bfa97 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Tue, 3 Nov 2026 18:20:37 -0800 Subject: [PATCH 4/4] Pin keymap importer to released plugin SDK --- Plugins/mac/Official/EditorKeymapImport/Cargo.lock | 2 +- Plugins/mac/Official/EditorKeymapImport/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/mac/Official/EditorKeymapImport/Cargo.lock b/Plugins/mac/Official/EditorKeymapImport/Cargo.lock index 21e5d94f1a..e631038653 100644 --- a/Plugins/mac/Official/EditorKeymapImport/Cargo.lock +++ b/Plugins/mac/Official/EditorKeymapImport/Cargo.lock @@ -1552,7 +1552,7 @@ dependencies = [ [[package]] name = "lithe-plugin-sdk" version = "0.1.0" -source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs.git?rev=fd994411b19962dde66c1ac18edb16ad244be4bd#fd994411b19962dde66c1ac18edb16ad244be4bd" +source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs.git?rev=5041540ced80a57d6a2b0207a142fff1871eff2f#5041540ced80a57d6a2b0207a142fff1871eff2f" dependencies = [ "base64 0.22.1", "serde_json", diff --git a/Plugins/mac/Official/EditorKeymapImport/Cargo.toml b/Plugins/mac/Official/EditorKeymapImport/Cargo.toml index 2ccb472f20..98d8ed35e0 100644 --- a/Plugins/mac/Official/EditorKeymapImport/Cargo.toml +++ b/Plugins/mac/Official/EditorKeymapImport/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" license = "Apache-2.0" [dependencies] -lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs.git", rev = "fd994411b19962dde66c1ac18edb16ad244be4bd" } +lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs.git", rev = "5041540ced80a57d6a2b0207a142fff1871eff2f" } serde_json = "1" jsonc-parser = { version = "0.34", features = ["serde"] } quick-xml = "0.37"