From 4fd2a4087f606fee73ec6e06dbcc697471d34c5b Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 22 Oct 2026 21:45:42 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(vcs):=20Core=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20VCS=20Provider=20=E5=BA=95=E5=B1=82?= =?UTF-8?q?=EF=BC=88#827=20P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 插件清单 API 3 新增 vcsProviders 能力、process.vcs 权限和 onVcsRoot 激活事件,git 为保留 ID;安装审查以 vcsTool 列出 VCS 命令行 - 新增 vcs.detectRoots(静态标记识别根、歧义目录与用户映射、祖先工作副本)、vcs.ownerOf(最深根归属)、vcs.decodeReply(Provider 回复校验与归一化) - LSP 会话:只向插件会话提供 litheVcs 协商,成功后发出 vcsProvider 事件;lsp.vcsRequest 按操作生成 lithe/vcs/* 请求并检查已协商能力;Provider 错误码只保留契约白名单 - lithe/vcs/execution 转为会话日志事件,lithe/vcs/authenticate 暂按未知请求回绝 - 契约、插件 README/schema、rust-core-api、fixtures、功能矩阵与提议 Note 同步 - 修复 unregistering_a_running_function_revokes_the_call 的竞态:改为有界等待 $/cancelRequest 写出 --- .../2026-10-22-unified-vcs-provider-model.md | 83 +- rust/lithe-core/src/lib.rs | 1 + rust/lithe-core/src/lsp/interface/client.rs | 30 + rust/lithe-core/src/lsp/interface/engine.rs | 464 ++++++++++- rust/lithe-core/src/lsp/interface/mod.rs | 1 + rust/lithe-core/src/lsp/interface/types.rs | 5 + .../src/lsp/interface/vcs_provider.rs | 183 +++++ rust/lithe-core/src/plugins/capabilities.rs | 4 + rust/lithe-core/src/plugins/declarative.rs | 90 ++- rust/lithe-core/src/protocol/command.rs | 13 + rust/lithe-core/src/runtime/dispatcher.rs | 24 + rust/lithe-core/src/tests/mod.rs | 1 + rust/lithe-core/src/tests/vcs.rs | 401 ++++++++++ rust/lithe-core/src/vcs/commands.rs | 263 +++++++ rust/lithe-core/src/vcs/manifest.rs | 286 +++++++ rust/lithe-core/src/vcs/mod.rs | 29 + rust/lithe-core/src/vcs/model.rs | 726 ++++++++++++++++++ rust/lithe-core/src/vcs/requests.rs | 503 ++++++++++++ rust/lithe-core/src/vcs/roots.rs | 357 +++++++++ shared/contracts/plugins/v1/README.md | 56 +- .../v1/fixtures/svn-provider.plugin.json | 43 ++ .../plugins/v1/plugin-manifest.schema.json | 77 +- shared/contracts/rust-core-api.md | 43 +- shared/contracts/vcs-provider-v1.md | 446 +++++++---- shared/fixtures/vcs/model-v1.json | 624 +++++++++++++-- .../vcs-subversion-and-plugin-providers.json | 14 +- 26 files changed, 4488 insertions(+), 279 deletions(-) create mode 100644 rust/lithe-core/src/lsp/interface/vcs_provider.rs create mode 100644 rust/lithe-core/src/tests/vcs.rs create mode 100644 rust/lithe-core/src/vcs/commands.rs create mode 100644 rust/lithe-core/src/vcs/manifest.rs create mode 100644 rust/lithe-core/src/vcs/mod.rs create mode 100644 rust/lithe-core/src/vcs/model.rs create mode 100644 rust/lithe-core/src/vcs/requests.rs create mode 100644 rust/lithe-core/src/vcs/roots.rs create mode 100644 shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json diff --git a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md index f21db215d2..18cff763b0 100644 --- a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md +++ b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md @@ -6,9 +6,11 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提交面板、项目树文件颜色、编辑器行标记(gutter)都直接使用 Git 类型。[Issue #827](https://github.com/1lck/Lithe-IDEA/issues/827) 要求支持 SVN,以后还可能有 Mercurial、Perforce 等其他 VCS(Version Control System,版本控制系统)。 -提案照搬[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)已经验证过的做法:Rust Core 定义一份与具体 VCS 无关的统一模型和 `vcs.*` 命令,每种 VCS 是一个 Provider(回答“这个目录现在是什么状态、怎样提交、怎样回滚”的实现)。Git 是内置 Provider,只在 Core 里加一层转换,现有 `git.*` 代码和契约不动;SVN 作为官方插件,以插件进程的形式接入,后续其他 VCS 走同一条路。 +提案照搬[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)已经验证过的做法:Rust Core 定义一份与具体 VCS 无关的统一模型和 `vcs.*` 命令,每种 VCS 是一个 Provider(回答“这个目录现在是什么状态、怎样提交、怎样回滚”的实现)。SVN 作为第一个官方插件 Provider,后端用官方 Node SDK 编写,以插件进程的形式接入,后续其他 VCS 走同一条路。 -对日常开发的影响:在统一层落地之前,新的 Git 功能照常写在 Git 模块里;但凡是“任何 VCS 都会有”的界面(文件状态颜色、gutter、提交、回滚、文件历史、Annotate),不要再新增对 Git 类型的直接依赖,等统一层就绪后改读 `vcs.*`。 +2026-10-22 评审决定:**Git 暂不接入统一层**。现有 Git 代码约 8 万行(Rust、Swift、TypeScript 合计,不含测试),两端还有重复的确定性逻辑,直接“包一层”会把这些债一起带进统一层。所以第一期统一层只服务插件 Provider,Git 保持现有 `git.*` 和 Git 模块不变;Git 的精简、迁移和改成内置官方插件另起提案(第 10 节)。 + +对日常开发的影响:新的 Git 功能照常写在 Git 模块里;但凡是“任何 VCS 都会有”的界面(文件状态颜色、gutter、提交、回滚、文件历史、Annotate),不要再新增对 Git 类型的直接依赖,以免 Git 迁移时多一处要拆。 ## 问题 @@ -34,11 +36,11 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提 | 层 | 位置(计划) | 负责 | 不负责 | | --- | --- | --- | --- | -| Rust Core 的 `vcs` 模块 | `rust/lithe-core/src/vcs/`(新) | 统一模型、`vcs.*` 命令、Provider 注册表(Registry,按 VCS ID 登记和查找实现的目录)、根目录识别、插件回复的解码和校验 | 启动进程、弹窗、存储 | -| 宿主 | macOS 新的 VCS 工作区模块;Windows 的 VCS 状态 store | 根映射与用户覆盖、文件监听触发刷新、Changelist、Shelve、凭据交互、Provider 进程生命周期、项目控制台 | 解析任何 VCS 的输出 | -| Provider | Git:Core 内置;其他 VCS:插件进程 | 回答状态、基线内容、历史、Annotate、提交、回滚、更新等事实 | 界面和跨根聚合 | +| Rust Core 的 `vcs` 模块 | `rust/lithe-core/src/vcs/`(新) | 统一模型、`vcs.*` 命令、根目录识别、请求计划、插件回复的解码和校验 | 启动进程、弹窗、存储 | +| 宿主 | macOS 新的 VCS 工作区模块;Windows 的 VCS 状态 store | 根映射与用户覆盖、文件监听触发刷新、凭据交互、Provider 进程生命周期、项目控制台 | 解析任何 VCS 的输出 | +| Provider | 插件进程(第一期只有 SVN);Git 暂不接入 | 回答状态、基线内容、历史、Annotate、提交、回滚、更新等事实 | 界面和跨根聚合 | -内置 Git Provider 在 Core 内部直接调用现有 `git::*` 函数,再把结果转换成统一形状。`git.*` 命令保持原样,现有 Git 界面和老调用方不受影响。插件 Provider 的回复先由 Core 校验(路径必须在根内、排序稳定、数量和大小有上限),再转换成和 Git 完全相同的形状。所以宿主拿到的 `vcs.status` 结果不区分来自哪个 Provider。 +插件 Provider 的回复先由 Core 校验(路径必须在根内、排序稳定、数量和大小有上限),再转换成统一形状。所以宿主拿到的结果不区分来自哪个 Provider,以后 Git 接入时也只需要产出同一形状。 ### 2. 统一模型只收所有 VCS 都成立的概念 @@ -58,7 +60,7 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提 ### 3. 界面看能力,不看 VCS 的名字 -每个 Provider 声明自己支持哪些能力,界面只按能力出现入口。必备能力是状态、基线内容、提交和回滚;其余都可选:历史、文件历史、Annotate、更新、推送、暂存区(staging)、分支、冲突处理、文件操作跟踪、锁、属性、清理等。 +每个 Provider 声明自己支持哪些能力,界面只按能力出现入口。必备能力是状态、基线内容、提交和回滚;协议版本 1 的可选能力是历史、文件历史、Annotate、更新、推送、暂存区(staging)、文件操作跟踪、冲突处理和清理。分支、锁、属性、忽略规则等到有第二个 Provider 需要时,再按能力版本加入。 集中式与分布式 VCS 的差别也表达成能力: @@ -90,31 +92,49 @@ Git Log 图、Rebase、Worktree、Stash、Push 和 Fetch 对话框、历史改 - 进程启动后,宿主在 `initialize` 里通过 `capabilities.experimental.litheVcs` 协商版本,Provider 在回复里给出自己的能力集。能力集描述的是运行中的进程,所以不写进清单,这遵守[声明式插件按能力分版本](../../implemented/architecture/2026-10-17-plugin-capability-versions.md)第 4 节的规则。 - 请求方法统一以 `lithe/vcs/` 开头。宿主不按 Provider ID 写分支;进程按“插件 ID + VCS ID + 工作区”隔离,生命周期规则与构建 Provider 相同。 -字段和方法的草案见 [VCS Provider 契约草案](../../../../shared/contracts/vcs-provider-v1.md),示例数据见 [`shared/fixtures/vcs/model-v1.json`](../../../../shared/fixtures/vcs/model-v1.json)。草案在 P1 落地前都可以修改,任何 Core 命令、清单校验和宿主能力表都还不接受这些名字。 +字段和方法见 [VCS Provider 契约](../../../../shared/contracts/vcs-provider-v1.md),示例数据见 [`shared/fixtures/vcs/model-v1.json`](../../../../shared/fixtures/vcs/model-v1.json)。Core 已按契约实现(见第 9 节的进度),但还没有宿主声明 `vcsProviders`,所以插件市场不会列出这类插件;宿主接入前契约仍可以修改,改动要同时更新 fixtures 和 Core 测试。 ### 7. SVN 作为官方插件 SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` 输出。IDEA 的 svn4idea 也是这样做的(`plugins/svn4idea/src/org/jetbrains/idea/svn/status/CmdStatusClient.kt` 使用 `svn status --xml`)。这和 Lithe 使用系统 Git 的方式一致,不需要打包 JVM 或跨平台编译 libsvn。 -凭据规则沿用[Git 执行与项目控制台](../../implemented/architecture/2026-09-12-git-execution-and-project-console.md):SVN 自己的认证缓存优先回答;需要交互时,Provider 通过协议向宿主发出认证挑战,宿主复用现有认证界面。命令始终带 `--non-interactive`,密码通过 `--password-from-stdin` 传入,不出现在命令参数、控制台和日志里,Lithe 也不保存密码。 +后端进程用官方 Node SDK(`sdk/node`)编写,以 `runtime` 方式启动(`tool: node`)。SDK 已经负责线协议、协商、取消、子进程清理和错误映射,Cargo 社区插件也走这条路;同一份后端在 macOS 和 Windows 上都能运行,插件包里没有原生二进制,不涉及按架构编译和 Gatekeeper。代价是用户除了 `svn` 还需要 Node 20.3 以上,安装审查和缺少运行时的提示要说清楚。SDK 新增 `litheVcs` 支持,和 `litheBuild` 并列。 + +凭据规则沿用[Git 执行与项目控制台](../../implemented/architecture/2026-09-12-git-execution-and-project-console.md):命令始终带 `--non-interactive`,SVN 自己的认证缓存优先回答。第一版只依赖这份缓存,认证失败时返回 `vcs.authentication.failed`,宿主提示原因;交互式认证(宿主弹窗收集用户名和密码,经 `--password-from-stdin` 传给 `svn`)在宿主阶段实现。密码不出现在命令参数、控制台和日志里,Lithe 也不保存密码。 ### 8. 宿主接手与 VCS 无关的状态 -- **根映射**:一个工作区可以同时有 Git 和 SVN 根。文件归属最深的那个根;同一目录同时出现两种标记时,要求用户在设置里选择,不能猜。IDEA 用 `VcsDirectoryMapping` 保存这种映射。 -- **Changelist 和 Shelve**:从 `LitheGitModule` 上移到宿主的 VCS 层,存储键从“Git 仓库路径”改为“VCS 根 ID”,迁移时继续读取旧格式。IDEA 的 `ChangeListManagerImpl` 同样在 `platform/vcs-impl`,不属于任何一种 VCS。 +- **根映射**:一个工作区可以同时有 Git 和 SVN 根。文件归属最深的那个根;同一目录同时出现两种标记时,要求用户在设置里选择,不能猜。IDEA 用 `VcsDirectoryMapping` 保存这种映射。Git 暂不接入统一层,但 Core 识别根时仍认得 `.git` 标记,这样嵌套和歧义的判断对 Git 也成立,Git 根继续交给 Git 模块。 +- **Changelist 和 Shelve**:长期应从 `LitheGitModule` 上移到宿主的 VCS 层(IDEA 的 `ChangeListManagerImpl` 在 `platform/vcs-impl`,不属于任何一种 VCS)。这一步和 Git 迁移一起做;第一期 SVN 根的变更不分组,也不支持 Shelve。 - **监听刷新**:[单仓库 Git 状态监听提案](../bug-fix/2026-09-13-single-repository-git-status-observation.md)里要求的监听范围,扩展成“每个 Provider 声明的元数据目录”,例如 SVN 的 `.svn/wc.db`。 -- **项目控制台**:现有 Git 执行事件多带一个 VCS ID 字段,插件 Provider 用通知上报实际执行的命令和输出。展示、折叠和脱敏规则不变。 +- **项目控制台**:现有 Git 执行事件多带一个 VCS ID 字段。插件 Provider 用 `lithe/vcs/execution` 通知上报实际执行的命令和输出,Core 把它转成会话日志事件交给宿主。展示、折叠和脱敏规则不变。 ### 9. 分阶段落地 +2026-10-22 起按“先底层、再 SVN、Git 最后”的顺序: + | 阶段 | 内容 | 完成标志 | | --- | --- | --- | | P0 | 本提案、契约草案和 fixtures | 评审通过 | -| P1 | Core `vcs` 模块:类型、注册表、根识别;内置 Git Provider 的读路径(状态、基线内容、历史、文件历史、Annotate) | 同一批测试仓库上 `vcs.*` 与 `git.*` 的结果等价 | -| P2 | macOS 只读的通用界面改读 `vcs.*`:项目树颜色、gutter、状态栏 | Git 项目的行为和外观没有变化 | -| P3 | 插件 `vcsProviders` 能力、`litheVcs` 协商、宿主 Provider 会话(复用构建 Provider 的会话代码);Changelist/Shelve 上移;通用提交面板 | 测试用的假 Provider 插件跑通完整流程 | -| P4 | SVN 官方插件 MVP | 见验收标准 | -| P5 | Windows 按同一契约和 fixtures 接入 | Windows 通过同一组 fixtures | +| P1 | Core:插件清单 `vcsProviders` 能力、根识别、请求计划与插件回复解码、`litheVcs` 协商和 `lsp.vcsRequest`;Node SDK 支持 `litheVcs` | fixtures 和用 SDK 写的假 Provider 经真实 Core 会话跑通 | +| P2 | SVN 官方插件(Node SDK 后端) | 录制的 `svn --xml` 样例解析测试;本地 `file://` 仓库上的端到端测试 | +| P3 | macOS 宿主:Provider 会话(复用构建 Provider 的会话代码)、项目树颜色和 gutter 合并展示插件根、通用“版本控制”工具窗口(变更、提交、回滚、更新)、文件操作跟踪、交互式认证 | 见验收标准 | +| P4 | Windows 按同一契约和 fixtures 接入 | Windows 通过同一组 fixtures | +| 之后 | Git 精简并接入统一层,见第 10 节 | 另起提案 | + +P1 的 Core 部分已完成(2026-10-22),Node SDK 和假 Provider 端到端测试还没做: + +- 插件清单:API 级别 3 的 `vcsProviders`、`process.vcs` 权限和 `onVcsRoot:` 激活事件;`git` 是保留 ID。安装审查列出 Provider 进程,SVN 这类工具路径另放在 `vcsTool` 字段,不覆盖运行时的 `tool`。 +- 根识别 `vcs.detectRoots`:只读目录项,不读文件、不启动进程;工作区自己没有标记时向上查祖先目录,所以“工作区开在 SVN 工作副本的子目录里”也能识别。新增 `vcs.ownerOf`,让各宿主用同一条“最深根”规则给文件找归属,不必各写一份。 +- 会话:`litheVcs` 只提供给插件启动的会话;`lsp.vcsRequest` 按操作名生成 `lithe/vcs/*` 请求,未协商的能力在写出前就拒绝;Provider 只能使用契约列出的错误码,其余一律变成 `vcs.provider.failed`。`lithe/vcs/authenticate` 在宿主实现交互式认证前按未知请求回绝。 + +### 10. Git 什么时候、怎样接入 + +Git 接入不在本提案的范围内,这里只记下评审时已经确认的方向,供以后的提案使用: + +- **先精简再接入。** 提交图投影在 Core 里已有一份(`git/graph.rs` 的 `project_graph`),但生产代码没有调用,macOS 和 Windows 又各写了一份布局;执行日志、Changelist、状态模型等确定性逻辑也是两端各一份。接入时按切片迁移,每一片两端同时切换,并在同一个 PR 里删掉旧路径。 +- **Git 改成随应用内置的官方插件**,和 SVN 走同一个注册入口;通用界面不再直接依赖 `LitheGitModule`。是否搬到独立进程是传输方式问题,可以单独决定。 +- **平台层功能先做在统一层**:三方合并工具、行级历史、Changelist、Shelf 在 IDEA 里都属于平台而不属于 git4idea,补齐时直接给所有 VCS 用。 ## 考虑过的备选方案 @@ -126,9 +146,17 @@ SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` 界面只需要面对一套 API,看起来最“统一”。但 Rebase、Worktree、暂存区这些概念在 SVN 里不存在,要么让通用层变成 Git 的翻版,要么为了照顾最弱的 VCS 削掉 Git 的能力。IDEA 也没有这样做,因此只统一跨 VCS 成立的概念。 +### 第一期就把 Git 包成内置 Provider + +最初的方案是在 Core 里给现有 `git::*` 加一层转换,让 Git 和插件同时产出统一形状。评审时发现这样会把两端重复的逻辑和 Git 专属的结果类型原样带进统一层,以后再精简反而更难,因此改为第一期不接 Git,先用 SVN 把底层跑通(第 10 节)。 + ### Git 也改成插件进程 -这样内置和插件完全对称。但 Git 是 Lithe 的核心体验,状态读取在热路径上,认证、取消和执行事件链路也刚稳定;搬到进程外对用户没有收益,迁移风险却很大。内置 Provider 和插件 Provider 共用 Core 里同一套转换和校验,结果形状已经对称,因此不搬。 +这样内置和插件完全对称。但 Git 是 Lithe 的核心体验,状态读取在热路径上,认证、取消和执行事件链路也刚稳定;第一期不动 Git。以后 Git 改成内置官方插件时,进程内还是进程外只是传输方式,可以单独决定。 + +### SVN 后端用 Rust 写成原生可执行文件 + +不需要用户安装 Node,启动也更快。但插件包要为每个平台和架构各编译一份、单独签名,macOS 上下载来的原生可执行文件还要处理 Gatekeeper 隔离;而 Node SDK 已经实现协议、取消和子进程清理,社区构建插件也在用。SVN 后端的工作主要是调用 `svn` 和解析 XML,不是性能热点,因此选 Node SDK。 ### SVN 内置到宿主 @@ -144,23 +172,26 @@ SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` ## 验收标准 -- P1:在同一批 Git 测试仓库上,`vcs.status`、基线内容、历史、文件历史和 Annotate 与对应 `git.*` 结果等价;大仓库上 `vcs.status` 比 `git.status` 只多出转换开销,不额外启动进程。 -- P2:macOS 项目树颜色、gutter 和状态栏改读统一模型后,Git 项目的现有测试和截图检查全部通过,行为没有变化。 -- P3:一个只用于测试的假 VCS Provider 插件完成安装审查、协商、状态、提交、回滚和进程清理;停用或卸载插件后进程退出,它的根从界面消失;未协商成功的进程收不到 `lithe/vcs/*` 请求。 -- P4:SVN 官方插件在本地 `svnadmin create` 建立的 `file://` 仓库上完成状态、提交、更新、回滚、add/move/delete 跟踪、Annotate、文件历史和冲突显示;密码不出现在进程参数、控制台和日志中。 +- P1:清单校验接受和拒绝的情况都有 fixture;根识别覆盖嵌套、歧义和 Git worktree 文件标记;插件回复的校验用例全部通过;用 Node SDK 写的假 Provider 经真实 Core 会话完成协商和每种请求,未协商成功的进程收不到 `lithe/vcs/*` 请求。 +- P2:SVN 插件对录制的 `svn status/info/log/blame --xml` 样例解析正确;在本地 `svnadmin create` 建立的 `file://` 仓库上完成状态、提交、更新、回滚、add/move/delete、Annotate、文件历史和冲突显示。 +- P3:macOS 上安装 SVN 插件后完成安装审查、根识别、文件颜色、gutter、提交、回滚、更新和进程清理;停用或卸载插件后进程退出,它的根从界面消失;Git 项目的行为没有变化;密码不出现在进程参数、控制台和日志中。 - 每个阶段都运行对应的 Core、契约、Note 和平台验证脚本;Windows 的结论只来自 Windows 上的实际运行。 ## 风险 -- **Git 仍在快速变化。** Issue 评论里提到 Git 还没稳定。缓解办法:统一层只包装稳定的概念,`git.*` 不冻结,Git 专属功能继续在 Git 模块里演进。 -- **状态读取是热路径。** 内置 Git Provider 在 Core 内直接转换,不额外起进程。SVN 在大工作副本上 `svn status` 较慢,由 Provider 按变化路径做增量查询,宿主合并刷新事件。 -- **Changelist 和 Shelve 的存储迁移。** 改变存储键可能丢失用户已有分组。迁移时保留读取旧格式,并用旧数据做回归测试。 +- **统一层只被一种 VCS 验证过。** 第一期只有 SVN 用统一层,模型里可能藏着 SVN 的偏见。缓解办法:模型只收第 2 节表里 Git 和 SVN 都成立的概念,Git 专属的暂存维度已经作为可选能力进入 fixtures;Git 接入时如果发现缺口,按能力版本加字段。 +- **Git 仍在快速变化。** Issue 评论里提到 Git 还没稳定。第一期不碰 Git,`git.*` 不冻结。 +- **状态读取是热路径。** SVN 在大工作副本上 `svn status` 较慢,由 Provider 按变化路径做增量查询,宿主合并刷新事件。 - **混合根和嵌套根。** Git 仓库里嵌套 SVN 工作副本时,文件必须只归属一个根,否则会重复显示或重复提交。识别规则和用户映射都要有 fixture 覆盖。 - **插件进程常驻的成本。** 只在识别到对应根时启动,关闭工作区即回收,规则与构建 Provider 相同。 -- **回退方式。** 每个阶段都只新增路径。P2 切换后保留旧读取路径一个版本,出问题可以切回。 +- **回退方式。** 每个阶段都只新增路径,停用 SVN 插件即回到现状。 ## 适用范围 +- `rust/lithe-core/src/vcs/` +- `rust/lithe-core/src/plugins/` +- `rust/lithe-core/src/lsp/interface/` +- `sdk/node/` - `rust/lithe-core/src/git/` - `rust/lithe-git-host/` - `macos/Sources/LitheGitModule/` diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index a29c869700..fa8b0118ae 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod process; mod project; mod protocol; mod runtime; +mod vcs; pub use protocol::{ CoreCommand, CoreError, CoreEvent, CoreRequest, CoreResponse, ErrorCode, ResponseData, diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 348be598d6..eff141681c 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -593,6 +593,30 @@ pub(super) fn client_apply_server_message_for_plugin( .server_capabilities .retain(|feature| feature != plugin_function::FUNCTIONS_FEATURE); } + // Only a plugin's own process may act as a VCS provider; a + // host-owned server that happens to answer is ignored. + state.vcs_provider = plugin_owned + .then(|| { + super::vcs_provider::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + ) + }) + .flatten(); + match &state.vcs_provider { + Some(negotiation) => events.push(LspClientEvent { + kind: super::vcs_provider::NEGOTIATED_EVENT.to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!(negotiation)), + error: None, + }), + None => state + .server_capabilities + .retain(|feature| feature != super::vcs_provider::VCS_PROVIDER_FEATURE), + } state.text_document_sync = text_document_sync_kind(result.get("capabilities").unwrap_or(&Value::Null)); if let Some(options) = result @@ -619,6 +643,7 @@ pub(super) fn client_apply_server_message_for_plugin( state.diagnostics.clear(); state.diagnostic_versions.clear(); state.plugin_functions.clear(); + state.vcs_provider = None; responses.push(json_rpc_message_without_params(None, "exit")?); } let result = if pending.as_deref() == Some("textDocument/semanticTokens/full") { @@ -724,6 +749,8 @@ fn experimental_capabilities(plugin_owned: bool) -> Value { if plugin_owned { let (functions_key, functions_offer) = plugin_function::client_experimental_capabilities(); experimental[functions_key] = functions_offer; + let (vcs_key, vcs_offer) = super::vcs_provider::client_experimental_capabilities(); + experimental[vcs_key] = vcs_offer; } experimental } @@ -1671,6 +1698,9 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if plugin_function::negotiate(capabilities).is_some() { insert_unique(&mut values, plugin_function::FUNCTIONS_FEATURE); } + if super::vcs_provider::negotiate(capabilities).is_some() { + insert_unique(&mut values, super::vcs_provider::VCS_PROVIDER_FEATURE); + } values } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 32eb50caa4..aba4a00837 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -59,6 +59,10 @@ const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 2_000; /// large build, so it may outlive the ordinary request timeout, but never /// stays pending without a deadline. const MAX_BUILD_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; +/// Upper bound of one VCS-provider request. An update or check-in of a large +/// working copy over a slow link can take long, but never stays pending +/// without a deadline. +const MAX_VCS_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; const MONITOR_INTERVAL_MS: u64 = 10; /// JSON-RPC method carrying Java Debug Server build commands. const JAVA_BUILD_METHOD: &str = "workspace/executeCommand"; @@ -390,6 +394,37 @@ pub struct BuildProviderRequest { 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 +/// operation and its arguments, so the host never writes a raw method. The +/// result arrives as a `requestCompleted` event whose `result.value` is the +/// provider's reply, which the host passes to `vcs.decodeReply`; a failure +/// carries a `vcs.*` code. +pub struct VcsProviderRequest { + pub session_id: String, + #[serde(default)] + pub operation_id: Option, + /// The VCS the host launched the session for; a session that negotiated + /// another VCS is refused. + pub vcs: String, + /// Operation name, such as `status` or `checkin`. + pub operation: String, + /// Native absolute path of the working-copy root. + pub root: String, + /// Absolute path of the VCS client the host resolved, when the provider + /// declares one. + #[serde(default)] + pub tool: Option, + /// Operation arguments; see `crate::vcs` for each operation's shape. + #[serde(default)] + pub arguments: Value, + /// Deadline of this request, in milliseconds, capped at + /// `MAX_VCS_PROVIDER_TIMEOUT_MS`; the session's request timeout when absent. + #[serde(default)] + pub timeout_milliseconds: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] /// One plugin-function invocation. The result arrives as a `requestCompleted` @@ -1222,6 +1257,18 @@ pub fn build_provider_request( 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 + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .vcs_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + /// Invokes one plugin function on a session of the function's plugin. pub fn plugin_function_request( request: PluginFunctionRequest, @@ -2021,6 +2068,84 @@ impl RuntimeSession { 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 vcs_request( + &self, + request: VcsProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + let operation = crate::vcs::Operation::parse(&request.operation).ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") + .with_details(request.operation.clone()) + })?; + 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, + "Language server 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 language-server operation ID is already pending.", + )); + } + let negotiation = state + .client + .vcs_provider + .as_ref() + .filter(|negotiation| negotiation.vcs == request.vcs) + .ok_or_else(|| { + CoreError::new( + ErrorCode::NotSupported, + format!("The process is not a negotiated {} provider.", request.vcs), + ) + .with_details("vcs.provider.unavailable") + })?; + let planned = crate::vcs::plan( + operation, + &request.root, + request.tool.as_deref(), + request.arguments, + &negotiation.capabilities, + )?; + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_VCS_PROVIDER_TIMEOUT_MS)) + }) + .unwrap_or(state.request_timeout); + let response = + allocate_raw_request(state.client.clone(), planned.method, planned.params)?; + 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: planned.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 `lithe/function/invoke` request. The plan comes from /// `plugin_function::plan_invocation`, so plugin identity, negotiation, /// and registration are checked before anything reaches the process. @@ -2904,6 +3029,40 @@ impl RuntimeSession { ); } } + Some(PendingKind::Feature) + if pending_before + .as_ref() + .is_some_and(|pending| crate::vcs::is_vcs_method(&pending.method)) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let (result, error) = match super::vcs_provider::reply_outcome(&value) { + Ok(result) => (Some(result), None), + Err(failure) => ( + None, + Some(runtime_error( + self, + failure.code, + "request", + Some(&pending.method), + None, + &failure.message, + None, + None, + )), + ), + }; + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + result, + error, + ); + } + } + } Some(PendingKind::Feature) => { if let Some(pending) = pending_before.as_ref() { if let Some(operation_id) = &pending.operation_id { @@ -3526,6 +3685,8 @@ impl RuntimeSession { push_functions_event(self, &mut state, result); } else if event.kind == "functionProgress" { push_function_progress_locked(self, &mut state, event.result); + } 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 == "diagnostics" { if let Some(uri) = event.uri.as_deref() { push_diagnostics_event( @@ -5276,6 +5437,37 @@ fn push_function_progress_locked( ); } +/// Publishes the VCS and capabilities a provider negotiated, so the host can +/// check the VCS against the manifest entry and decide which actions to show. +fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "vcsProvider".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: Some(result), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); +} + /// Publishes the session's registered functions after a registry change. fn push_functions_event(session: &RuntimeSession, state: &mut SessionState, result: Value) { let sequence = take_sequence(state); @@ -5860,6 +6052,7 @@ fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { state.request_by_operation.clear(); state.function_by_operation.clear(); clear_function_registry(session, state); + state.client.vcs_provider = None; state.linked_document_paths = LinkedDocumentPaths::default(); state.java_navigation_marker_batches.clear(); state.java_navigation_marker_cache.clear(); @@ -10826,6 +11019,260 @@ public class Main { assert!(harness.server.await_notification("$/cancelRequest")); } + // VCS providers against a scripted backend. Only a plugin-owned session + // is offered `litheVcs`, and only one whose process answered with a VCS + // ID and every required capability receives requests, each planned by + // Core from an operation name. + + const VCS_PLUGIN: &str = "dev.lithe.fixture.svn-provider"; + + fn vcs_capabilities() -> Value { + json!({ "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "update": 1 }) + } + + fn vcs_backend(answer: Value) -> Harness { + let mut harness = Harness::start(|request| { + request.plugin_id = Some(VCS_PLUGIN.to_string()); + }); + harness + .server + .complete_initialize(json!({ "experimental": { "litheVcs": answer } })); + harness.await_state(LspLifecycleState::Ready); + harness + } + + fn negotiated_vcs_provider() -> Harness { + vcs_backend(json!({ "version": 1, "vcs": "svn", "capabilities": vcs_capabilities() })) + } + + /// A native absolute path on every platform the tests run on. + fn working_copy() -> String { + std::env::temp_dir() + .join("lithe-vcs-working-copy") + .to_string_lossy() + .into_owned() + } + + fn vcs_request( + harness: &Harness, + vcs: &str, + operation: &str, + arguments: Value, + ) -> Result { + let operation_id = harness.engine.next_operation_id(); + harness + .session() + .vcs_request( + VcsProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + vcs: vcs.to_string(), + operation: operation.to_string(), + root: working_copy(), + tool: None, + arguments, + timeout_milliseconds: None, + }, + operation_id.clone(), + ) + .map(|()| operation_id) + } + + /// Feature names the session recorded from the provider's answer. + fn vcs_features(harness: &Harness) -> Vec { + let session = harness.session(); + let state = session.lock_state().expect("state"); + state.client.server_capabilities.clone() + } + + fn no_vcs_request_written(harness: &Harness) -> bool { + harness.server.messages().iter().all(|message| { + !message["method"] + .as_str() + .is_some_and(|method| method.starts_with("lithe/vcs/")) + }) + } + + #[test] + fn host_sessions_are_not_offered_the_vcs_protocol() { + let harness = Harness::ready(); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert!(initialize["params"]["capabilities"]["experimental"] + .get("litheVcs") + .is_none()); + let error = vcs_request(&harness, "svn", "status", json!({})) + .expect_err("a host session is never a provider"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + assert!(no_vcs_request_written(&harness)); + } + + /// The negotiated VCS and capabilities reach the host as one event, so + /// it can check the VCS against the manifest and pick the actions to show. + #[test] + fn a_negotiated_provider_is_announced_with_its_capabilities() { + let mut harness = negotiated_vcs_provider(); + let event = harness + .await_event(|event| event.kind == "vcsProvider") + .clone(); + assert_eq!( + event.result, + Some(json!({ "vcs": "svn", "capabilities": vcs_capabilities() })) + ); + assert!(vcs_features(&harness) + .iter() + .any(|feature| feature == "litheVcs")); + } + + /// Version 1 has no host side for credentials or console routing yet: an + /// authentication request is refused so the provider fails with + /// `vcs.authentication.failed`, and an execution report is passed on as a + /// session log entry. + #[test] + fn provider_authentication_is_refused_and_execution_reports_pass_through() { + let mut harness = negotiated_vcs_provider(); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": "auth-1", + "method": "lithe/vcs/authenticate", + "params": { "realm": " Example" } + })); + assert_eq!(await_reply(&harness, "auth-1")["error"]["code"], -32601); + harness.server.send(json!({ + "jsonrpc": "2.0", + "method": "lithe/vcs/execution", + "params": { "kind": "started", "arguments": ["status", "--xml"] } + })); + let event = harness + .await_event(|event| { + event.kind == "log" && event.message.as_deref() == Some("lithe/vcs/execution") + }) + .clone(); + assert!(event + .detail + .as_deref() + .is_some_and(|detail| detail.contains("--xml"))); + } + + /// An answer without a required capability makes the process a plain + /// plugin session: no announcement, no feature, and no request. + #[test] + fn an_incomplete_vcs_answer_is_not_a_provider() { + let mut harness = vcs_backend(json!({ + "version": 1, "vcs": "svn", "capabilities": { "status": 1, "baseContent": 1 } + })); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "vcsProvider")); + assert!(vcs_features(&harness) + .iter() + .all(|feature| feature != "litheVcs")); + let error = vcs_request(&harness, "svn", "status", json!({})).expect_err("refused"); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + assert!(no_vcs_request_written(&harness)); + } + + #[test] + fn a_vcs_request_is_planned_from_its_operation_and_returns_the_reply() { + let mut harness = negotiated_vcs_provider(); + let operation = vcs_request( + &harness, + "svn", + "checkin", + json!({ "paths": ["src/b.txt", "src/a.txt"], "message": "Fix" }), + ) + .expect("accepted"); + let request_id = harness + .server + .await_request("lithe/vcs/checkin") + .expect("the request should reach the provider"); + assert_eq!( + harness.written_request(&request_id)["params"], + json!({ + "paths": ["src/a.txt", "src/b.txt"], + "message": "Fix", + "root": { "path": working_copy() }, + "tool": null + }) + ); + let reply = json!({ "revision": { "id": "12", "display": "r12", "kind": "number" } }); + harness + .server + .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); + let completed = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert!(completed.error.is_none(), "{:?}", completed.error); + assert_eq!(completed.result, Some(json!({ "value": reply }))); + assert!(harness.snapshot().pending_operation_ids.is_empty()); + } + + /// The host names an operation, never a method, so the session cannot + /// be used as a tunnel; another VCS, an unknown operation, a capability + /// the provider lacks, or a path leaving the root never reach the wire. + #[test] + fn vcs_requests_are_refused_before_they_are_written() { + let harness = negotiated_vcs_provider(); + let error = vcs_request(&harness, "hg", "status", json!({})).expect_err("wrong VCS"); + assert_eq!(error.details.as_deref(), Some("vcs.provider.unavailable")); + let error = + vcs_request(&harness, "svn", "shutdown", json!({})).expect_err("no such operation"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + let error = vcs_request(&harness, "svn", "annotate", json!({ "path": "a.txt" })) + .expect_err("not negotiated"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.capability.unsupported")); + let error = vcs_request(&harness, "svn", "rollback", json!({ "paths": ["../x"] })) + .expect_err("outside the root"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert!(no_vcs_request_written(&harness)); + } + + /// A provider may name one of the contract's failure codes; any other + /// code, including one of Lithe's own, becomes the generic failure. + #[test] + fn vcs_failures_keep_only_contract_codes() { + let mut harness = negotiated_vcs_provider(); + for (index, (data, expected)) in [ + (json!({ "code": "vcs.outOfDate" }), "vcs.outOfDate"), + ( + json!({ "code": "plugin.vcs.invalidOutput" }), + "vcs.provider.failed", + ), + ] + .into_iter() + .enumerate() + { + let operation = vcs_request(&harness, "svn", "update", json!({})).expect("sent"); + let request_id = harness + .server + .await_request_at("lithe/vcs/update", index) + .expect("the request should reach the provider"); + harness.server.send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { "code": -32000, "message": "Working copy is out of date.", "data": data } + })); + let error = harness + .await_event(|event| event.operation_id.as_deref() == Some(operation.as_str())) + .error + .clone() + .expect("an error reply fails the request"); + assert_eq!(error.code, expected); + assert_eq!(error.message, "Working copy is out of date."); + } + } + // Plugin functions against a scripted backend. The scripted server plays // the plugin process, so every outcome (result, failure, cancellation, // deadline, late reply, revocation, and compatibility with a backend that @@ -10992,7 +11439,11 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"], - json!({ "litheBuild": { "versions": [1] }, "litheFunctions": { "versions": [1] } }) + json!({ + "litheBuild": { "versions": [1] }, + "litheFunctions": { "versions": [1] }, + "litheVcs": { "versions": [1] } + }) ); // Another plugin's reference never reaches this process. @@ -11234,12 +11685,13 @@ public class Main { revoked.error.as_ref().map(|error| error.code.as_str()), Some("plugin.function.revoked") ); - assert!(harness + // The revocation is published before the cancellation is written, so + // the write is awaited rather than expected to be there already. + let cancel = harness .server - .messages() - .iter() - .any(|message| message["method"] == "$/cancelRequest" - && message["params"]["id"] == request_id)); + .await_notification_at("$/cancelRequest", 0) + .expect("the revoked call should be cancelled on the wire"); + assert_eq!(cancel["params"]["id"], request_id); let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.clear", None) .expect_err("a withdrawn function is unknown"); assert_eq!(error.details.as_deref(), Some("plugin.function.unknown")); diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index 7fe3b90fab..7214d5a168 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -9,6 +9,7 @@ mod process; mod scripted; mod transport; mod types; +mod vcs_provider; pub(crate) use client::*; pub(crate) use engine::*; diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index 95f5083d6f..5f1cd4e3dd 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -139,6 +139,10 @@ pub struct LspClientState { /// negotiated; empty otherwise. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub plugin_functions: BTreeSet, + /// The VCS and capabilities a plugin process answered under + /// `litheVcs`; `None` when the protocol was not negotiated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_provider: Option, } impl Default for LspClientState { @@ -156,6 +160,7 @@ impl Default for LspClientState { diagnostics: BTreeMap::new(), diagnostic_versions: BTreeMap::new(), plugin_functions: BTreeSet::new(), + vcs_provider: None, } } } diff --git a/rust/lithe-core/src/lsp/interface/vcs_provider.rs b/rust/lithe-core/src/lsp/interface/vcs_provider.rs new file mode 100644 index 0000000000..23a6f6266c --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/vcs_provider.rs @@ -0,0 +1,183 @@ +//! The VCS-provider protocol a plugin's LSP base-protocol process speaks. +//! +//! The manifest says how to start the process and which VCS it provides; +//! the protocol version, the VCS ID, and the provider's capabilities are +//! negotiated in `initialize` under `capabilities.experimental.litheVcs`. +//! Only plugin-owned sessions are offered the protocol, and requests are +//! written only to a session whose answer named a version this client +//! offered, a valid VCS ID, and every required capability. The rules live in +//! `shared/contracts/vcs-provider-v1.md`. + +use crate::vcs::{negotiated_capabilities, Capabilities}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +/// Key under `capabilities.experimental` on both sides of the exchange. +const EXPERIMENTAL_KEY: &str = "litheVcs"; + +/// VCS-provider protocol versions this client speaks, in preference order. +const SUPPORTED_VERSIONS: [u64; 1] = [1]; + +/// Feature name recorded in the session's server capabilities once a +/// provider answered a supported version. +pub(crate) const VCS_PROVIDER_FEATURE: &str = "litheVcs"; + +/// Client event that carries the negotiated provider to the engine. +pub(crate) const NEGOTIATED_EVENT: &str = "vcsProviderNegotiated"; + +/// Longest provider error message passed to the host, in UTF-8 bytes. +const MAX_MESSAGE_BYTES: usize = 4096; + +/// Contract codes a provider may put in `error.data.code`. Any other code, +/// or none, becomes [`codes::FAILED`]. +const PROVIDER_ERROR_CODES: &[&str] = &[ + "vcs.authentication.failed", + "vcs.conflict", + "vcs.outOfDate", + "vcs.tool.unavailable", + "vcs.workingCopy.locked", + "vcs.workingCopy.unsupported", +]; + +/// Error codes Core assigns to a failed VCS request. +pub(crate) mod codes { + /// The provider failed without one of the contract's specific codes. + pub const FAILED: &str = "vcs.provider.failed"; + /// The reply is not a JSON-RPC result. + pub const INVALID_OUTPUT: &str = "plugin.vcs.invalidOutput"; +} + +/// What a provider answered in `initialize`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VcsNegotiation { + /// The VCS the process provides; the host checks it against the + /// manifest entry it launched. + pub vcs: String, + /// Capabilities of protocol version 1 the provider supports. + pub capabilities: Capabilities, +} + +/// The client half of the negotiation, placed under `capabilities.experimental`. +pub(crate) fn client_experimental_capabilities() -> (&'static str, Value) { + (EXPERIMENTAL_KEY, json!({ "versions": SUPPORTED_VERSIONS })) +} + +/// The provider's answer, or `None` when it chose no offered version, named +/// no valid VCS ID, or lacks a required capability. Unknown fields and +/// capabilities are ignored, as the contract requires. +pub(crate) fn negotiate(capabilities: &Value) -> Option { + let answer = capabilities + .get("experimental") + .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY))?; + let version = answer.get("version").and_then(Value::as_u64)?; + if !SUPPORTED_VERSIONS.contains(&version) { + return None; + } + let vcs = answer + .get("vcs") + .and_then(Value::as_str) + .filter(|vcs| crate::vcs::is_vcs_id(vcs))?; + Some(VcsNegotiation { + vcs: vcs.to_string(), + capabilities: negotiated_capabilities(answer.get("capabilities")?)?, + }) +} + +/// A failed VCS request: a contract code and the provider's message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VcsFailure { + pub(crate) code: &'static str, + pub(crate) message: String, +} + +fn bounded_message(value: &str) -> String { + let mut end = value.len().min(MAX_MESSAGE_BYTES); + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +/// Normalizes the provider's JSON-RPC response. A success is wrapped as +/// `{ "value": result }` like build-provider replies, so the host passes +/// `value` to `vcs.decodeReply` unchanged. +pub(crate) fn reply_outcome(response: &Value) -> Result { + if let Some(error) = response.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .map(bounded_message) + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| "The VCS provider failed.".to_string()); + let code = error + .get("data") + .and_then(|data| data.get("code")) + .and_then(Value::as_str) + .and_then(|code| PROVIDER_ERROR_CODES.iter().find(|known| **known == code)) + .copied() + .unwrap_or(codes::FAILED); + return Err(VcsFailure { code, message }); + } + match response.get("result") { + Some(result) => Ok(json!({ "value": result })), + None => Err(VcsFailure { + code: codes::INVALID_OUTPUT, + message: "The VCS provider returned no result.".to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn answer(value: Value) -> Value { + json!({ "experimental": { "litheVcs": value } }) + } + + const REQUIRED: &str = r#"{ "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1 }"#; + + #[test] + fn only_a_complete_answer_to_an_offered_version_counts() { + let capabilities: Value = serde_json::from_str(REQUIRED).expect("JSON"); + let negotiation = negotiate(&answer(json!({ + "version": 1, "vcs": "svn", "capabilities": capabilities, "future": true + }))) + .expect("negotiated"); + assert_eq!(negotiation.vcs, "svn"); + assert_eq!(negotiation.capabilities.len(), 4); + // A version the host never offered, a malformed VCS ID, a missing + // capability map, or a missing required capability must keep VCS + // requests off the session. + for rejected in [ + json!({ "version": 2, "vcs": "svn", "capabilities": capabilities }), + json!({ "version": 1, "vcs": "SVN", "capabilities": capabilities }), + json!({ "version": 1, "vcs": "svn" }), + json!({ "version": 1, "vcs": "svn", "capabilities": { "status": 1 } }), + ] { + assert_eq!(negotiate(&answer(rejected.clone())), None, "{rejected}"); + } + assert_eq!(negotiate(&json!({})), None); + } + + #[test] + fn provider_errors_keep_only_contract_codes() { + let failure = reply_outcome(&json!({ + "error": { "code": -32000, "message": "Out of date", "data": { "code": "vcs.outOfDate" } } + })) + .expect_err("failed"); + assert_eq!(failure.code, "vcs.outOfDate"); + // A plugin cannot make its own code look like one of Lithe's. + let failure = reply_outcome(&json!({ + "error": { "code": -32000, "message": "", "data": { "code": "plugin.vcs.invalidOutput" } } + })) + .expect_err("failed"); + assert_eq!(failure.code, codes::FAILED); + assert_eq!(failure.message, "The VCS provider failed."); + assert_eq!( + reply_outcome(&json!({ "result": null })).expect("ok"), + json!({ "value": null }) + ); + } +} diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index cfae7aad21..e634fbb396 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -41,6 +41,9 @@ pub(crate) const UI: &str = "ui"; pub(crate) const BUILD_SYSTEMS: &str = "buildSystems"; /// `buildSystems[].provider`, whether it owns or shares a language server process. pub(crate) const BUILD_PROVIDERS: &str = "buildProviders"; +/// `vcsProviders[]`: version control systems implemented by a plugin +/// process; see `crate::vcs`. +pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; /// Capability versions this Core build validates and plans. Version 1 of a /// capability keeps the meaning its section had at API level 2. @@ -53,6 +56,7 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (SERVER_PROCESS, &[1]), (SETTINGS, &[1, 2]), (UI, &[1, 2]), + (VCS_PROVIDERS, &[1]), ]; /// Longest capability ID, so a hostile index cannot grow host state. diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 5dabae94f1..304ad62122 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -44,6 +44,9 @@ const PERMISSION_UI_WEB: &str = "ui.web"; /// Running a declared build system's executable: model, task, test, and /// dependency commands. API level 3. const PERMISSION_BUILD: &str = "process.build"; +/// Letting a VCS provider change the working copy and contact its server. +/// API level 3. +const PERMISSION_VCS: &str = "process.vcs"; const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_BUILD, PERMISSION_LANGUAGE_SERVER, @@ -51,6 +54,7 @@ const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_TEST, PERMISSION_UI, PERMISSION_UI_WEB, + PERMISSION_VCS, ]; /// Runtime tools the host knows how to discover and pass to a language /// server. The host exports each as `LITHE__PATH` and puts its @@ -156,6 +160,10 @@ pub struct DeclarativePluginManifest { /// shape and its validation live in `crate::build`. #[serde(default, skip_serializing_if = "Vec::is_empty")] build_systems: Vec, + /// Version control systems the plugin implements. API level 3; the + /// shape and its validation live in `crate::vcs`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + vcs_providers: Vec, } /// UI supplied by plugins. The host owns every window shell: docking, title, @@ -416,6 +424,7 @@ pub(crate) struct ServerProcess { pub(crate) enum ServerRole { LanguageServer, BuildProvider, + VcsProvider, } impl ServerRole { @@ -423,6 +432,7 @@ impl ServerRole { match self { Self::LanguageServer => "language server", Self::BuildProvider => "build provider", + Self::VcsProvider => "VCS provider", } } } @@ -758,7 +768,11 @@ impl DeclarativePluginManifest { if self.plugin_api_version >= CAPABILITIES_API_VERSION { capabilities::check_used(&self.requires, &self.used_capabilities())?; } - if self.languages.is_empty() && self.ui.is_none() && self.build_systems.is_empty() { + if self.languages.is_empty() + && self.ui.is_none() + && self.build_systems.is_empty() + && self.vcs_providers.is_empty() + { return Err(invalid("languages", "The plugin declares no languages.")); } @@ -884,6 +898,7 @@ impl DeclarativePluginManifest { && self.execution_providers.is_empty() && self.ui.is_none() && self.build_systems.is_empty() + && self.vcs_providers.is_empty() { return Err(invalid( "languages", @@ -900,13 +915,20 @@ impl DeclarativePluginManifest { language_servers: &server_languages, }, )?; + crate::vcs::validate_declarations( + &mut self.vcs_providers, + &crate::vcs::DeclarationContext { + plugin_api_version: self.plugin_api_version, + declared_settings: &declared_settings, + }, + )?; self.validate_permissions()?; self.validate_activation_events()?; Ok(self) } /// Capabilities whose sections the manifest declares. Every language - /// server and every own build provider is a `serverProcess`. + /// server, own build provider, and VCS provider is a `serverProcess`. fn used_capabilities(&self) -> BTreeSet<&'static str> { let sections = [ (capabilities::LANGUAGES, !self.languages.is_empty()), @@ -917,6 +939,7 @@ impl DeclarativePluginManifest { ( capabilities::SERVER_PROCESS, !self.language_servers.is_empty() + || !self.vcs_providers.is_empty() || self .build_systems .iter() @@ -938,6 +961,7 @@ impl DeclarativePluginManifest { .iter() .any(|system| system.provider.is_some()), ), + (capabilities::VCS_PROVIDERS, !self.vcs_providers.is_empty()), ]; sections .into_iter() @@ -1060,6 +1084,7 @@ impl DeclarativePluginManifest { // A provider process of its own is a server process like a language // server, so it needs the same consent. if !self.language_servers.is_empty() + || !self.vcs_providers.is_empty() || self .build_systems .iter() @@ -1078,6 +1103,9 @@ impl DeclarativePluginManifest { if !self.build_systems.is_empty() { required.insert(PERMISSION_BUILD); } + if !self.vcs_providers.is_empty() { + required.insert(PERMISSION_VCS); + } for permission in required { if !self .permissions @@ -1110,6 +1138,9 @@ impl DeclarativePluginManifest { for build_system in &self.build_systems { expected.insert(crate::build::activation_event(build_system)); } + for provider in &self.vcs_providers { + expected.insert(crate::vcs::activation_event(provider)); + } if let Some(ui) = &self.ui { for window in &ui.tool_windows { expected.insert(format!("onView:{}", window.id)); @@ -1172,6 +1203,11 @@ impl DeclarativePluginManifest { &self.build_systems } + /// Validated VCS providers, sorted by VCS ID. + pub(crate) fn vcs_providers(&self) -> &[crate::vcs::VcsProviderDeclaration] { + &self.vcs_providers + } + /// Checks the declared settings, sorts them by ID, and returns the IDs /// launch templates may refer to. fn validate_settings(&mut self) -> Validation { @@ -1301,6 +1337,9 @@ impl DeclarativePluginManifest { for build_system in &self.build_systems { programs.extend(crate::build::programs(build_system)); } + for provider in &self.vcs_providers { + programs.extend(crate::vcs::programs(provider)); + } // Order by kind, then language or tool, then the full serialized // value as a total tie-breaker; duplicates (two servers needing the // same runtime) collapse to one entry. @@ -1726,6 +1765,7 @@ impl ServerProcess { let suffix = match role { ServerRole::LanguageServer => "LanguageServer", ServerRole::BuildProvider => "BuildProvider", + ServerRole::VcsProvider => "VcsProvider", }; let mut program = match &self.launch { LanguageServerLaunch::Packaged { path } => json!({ @@ -2596,6 +2636,29 @@ fn build_provider_launch_plan( Ok(plan) } +/// The launch plan of a VCS provider. `vcsProvider` repeats what the host +/// needs to resolve the client tool and to check the provider's answer in +/// `initialize`, which must name the same VCS. +fn vcs_provider_launch_plan( + manifest: &DeclarativePluginManifest, + vcs_id: &str, + request: &LanguageServerLaunchPlanRequest, +) -> Validation { + let provider = manifest + .vcs_providers + .iter() + .find(|provider| provider.id == vcs_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.launch.unavailable", + format!("The plugin declares no VCS provider {vcs_id}."), + ) + })?; + let mut plan = server_launch_plan(manifest, &provider.process, None, request)?; + plan["vcsProvider"] = provider.descriptor(&manifest.id); + Ok(plan) +} + fn server_launch_plan( manifest: &DeclarativePluginManifest, process: &ServerProcess, @@ -3130,8 +3193,8 @@ struct PlanPayload { request: Request, } -/// A launch plan names the process by the language whose server it is, or -/// by the build system whose provider it is. +/// A launch plan names the process by the language whose server it is, by +/// the build system whose provider it is, or by the VCS it provides. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct LaunchPlanPayload { @@ -3140,12 +3203,15 @@ struct LaunchPlanPayload { language_id: Option, #[serde(default, rename = "buildSystemID")] build_system_id: Option, + #[serde(default, rename = "vcsProviderID")] + vcs_provider_id: Option, request: LanguageServerLaunchPlanRequest, } enum LaunchSelection<'a> { Language(&'a str), BuildSystem(&'a str), + VcsProvider(&'a str), } pub(crate) fn decode_payload Deserialize<'de>>( @@ -3228,15 +3294,20 @@ pub fn test_plan_command(payload: Value) -> Result { pub fn language_server_launch_plan_command(payload: Value) -> Result { const COMMAND: &str = "plugins.declarative.languageServerLaunchPlan"; let payload: LaunchPlanPayload = decode_payload(payload, COMMAND)?; - let selected = match (&payload.language_id, &payload.build_system_id) { - (Some(language_id), None) => LaunchSelection::Language(language_id), - (None, Some(build_system_id)) => LaunchSelection::BuildSystem(build_system_id), + let selected = match ( + &payload.language_id, + &payload.build_system_id, + &payload.vcs_provider_id, + ) { + (Some(language_id), None, None) => LaunchSelection::Language(language_id), + (None, Some(build_system_id), None) => LaunchSelection::BuildSystem(build_system_id), + (None, None, Some(vcs_id)) => LaunchSelection::VcsProvider(vcs_id), _ => { return Err(CoreError::new( ErrorCode::InvalidRequest, format!("Invalid {COMMAND} payload"), ) - .with_details("Name exactly one of languageID and buildSystemID.")) + .with_details("Name exactly one of languageID, buildSystemID, and vcsProviderID.")) } }; Ok(outcome(validate_manifest_value(payload.manifest).and_then( @@ -3248,6 +3319,9 @@ pub fn language_server_launch_plan_command(payload: Value) -> Result { build_provider_launch_plan(&manifest, build_system_id, &payload.request) } + LaunchSelection::VcsProvider(vcs_id) => { + vcs_provider_launch_plan(&manifest, vcs_id, &payload.request) + } } .map(|plan| ("plan", plan)) }, diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 5a76d00f19..5af138fd59 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -94,6 +94,12 @@ pub enum CoreCommand { BuildSyncFingerprint, /// Maps build options to the Java language server's project import (`build.languageImport`). BuildLanguageImport, + /// Detects VCS roots and registered providers of a workspace (`vcs.detectRoots`). + VcsDetectRoots, + /// Assigns workspace paths to detected VCS roots (`vcs.ownerOf`). + VcsOwnerOf, + /// Validates and normalizes a VCS provider's reply (`vcs.decodeReply`). + VcsDecodeReply, /// Validates and filters a marketplace index (`plugins.marketplace.validateIndex`). PluginsMarketplaceValidateIndex, /// Resolves a user-entered marketplace source URL (`plugins.marketplace.resolveSource`). @@ -258,6 +264,8 @@ pub enum CoreCommand { LspRequest, /// Queues one build-provider request (`lsp.buildRequest`). LspBuildRequest, + /// Queues one VCS-provider request (`lsp.vcsRequest`). + LspVcsRequest, /// Invokes one plugin function (`lsp.functionRequest`). LspFunctionRequest, /// Queues shared Java gutter marker resolution (`java.navigationMarkers`). @@ -454,6 +462,9 @@ impl CoreCommand { "build.decodeDependencies" => Some(Self::BuildDecodeDependencies), "build.syncFingerprint" => Some(Self::BuildSyncFingerprint), "build.languageImport" => Some(Self::BuildLanguageImport), + "vcs.detectRoots" => Some(Self::VcsDetectRoots), + "vcs.ownerOf" => Some(Self::VcsOwnerOf), + "vcs.decodeReply" => Some(Self::VcsDecodeReply), "plugins.marketplace.validateIndex" => Some(Self::PluginsMarketplaceValidateIndex), "plugins.marketplace.resolveSource" => Some(Self::PluginsMarketplaceResolveSource), "plugins.marketplace.installReview" => Some(Self::PluginsMarketplaceInstallReview), @@ -534,6 +545,7 @@ impl CoreCommand { "lsp.closeDocument" => Some(Self::LspCloseDocument), "lsp.request" => Some(Self::LspRequest), "lsp.buildRequest" => Some(Self::LspBuildRequest), + "lsp.vcsRequest" => Some(Self::LspVcsRequest), "lsp.functionRequest" => Some(Self::LspFunctionRequest), "java.resolveNavigation" => Some(Self::JavaResolveNavigation), "lsp.cancelOperation" => Some(Self::LspCancelOperation), @@ -631,6 +643,7 @@ mod tests { "lsp.closeDocument", "lsp.request", "lsp.buildRequest", + "lsp.vcsRequest", "lsp.functionRequest", "lsp.cancelOperation", "lsp.pollEvents", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 24c0fb1c3a..44b86dec0e 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -255,6 +255,15 @@ fn execute(request: &str) -> CoreResponse { parsed.payload, crate::build::build_language_import_command, ), + CoreCommand::VcsDetectRoots => { + plugin_response(id, parsed.payload, crate::vcs::vcs_detect_roots_command) + } + CoreCommand::VcsOwnerOf => { + plugin_response(id, parsed.payload, crate::vcs::vcs_owner_of_command) + } + CoreCommand::VcsDecodeReply => { + plugin_response(id, parsed.payload, crate::vcs::vcs_decode_reply_command) + } CoreCommand::PluginsMarketplaceValidateIndex => plugin_response( id, parsed.payload, @@ -1584,6 +1593,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspVcsRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid VCS-provider request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::vcs_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("VCS-provider response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspFunctionRequest => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index eecd05e983..5592927215 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -24,3 +24,4 @@ mod protocol; mod run_configuration; mod spring; pub(crate) mod support; +mod vcs; diff --git a/rust/lithe-core/src/tests/vcs.rs b/rust/lithe-core/src/tests/vcs.rs new file mode 100644 index 0000000000..5a2968c1c5 --- /dev/null +++ b/rust/lithe-core/src/tests/vcs.rs @@ -0,0 +1,401 @@ +//! VCS providers declared by API level 3 plugins: manifest validation, +//! install review, launch plans, root detection over real directories, and +//! reply decoding, checked against `shared/fixtures/vcs/model-v1.json` and +//! the Subversion provider manifest fixture. + +use super::support::temporary_root; +use crate::plugins::declarative::{language_server_launch_plan_command, validate_command}; +use crate::plugins::marketplace::install_review_command; +use crate::protocol::ErrorCode; +use crate::vcs::{ + negotiated_capabilities, vcs_decode_reply_command, vcs_detect_roots_command, + vcs_owner_of_command, +}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const MODEL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/vcs/model-v1.json" +)); +const SVN_PROVIDER: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json" +)); + +fn model() -> Value { + serde_json::from_str(MODEL).expect("the VCS model fixture is JSON") +} + +fn svn_provider() -> Value { + serde_json::from_str(SVN_PROVIDER).expect("the provider fixture is JSON") +} + +fn validate(manifest: Value) -> Value { + validate_command(json!({ "manifest": manifest })).expect("well-formed payload") +} + +/// Removes its directory when dropped, so a failed assertion cleans up too. +struct Sandbox(PathBuf); + +impl Sandbox { + fn new(label: &str) -> Self { + let root = temporary_root(label); + fs::create_dir_all(&root).unwrap(); + // Detection reports resolved paths, and the temporary directory is + // itself behind a link on macOS. + Self(root.canonicalize().unwrap()) + } + + fn directory(&self, relative: &str) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(&path).unwrap(); + path + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn the_provider_fixture_validates_and_is_reviewed_with_its_tool() { + let outcome = validate(svn_provider()); + assert_eq!(outcome["status"], "ok", "{outcome}"); + + let review = install_review_command(json!({ "manifest": svn_provider() })).unwrap(); + assert_eq!(review["status"], "ok", "{review}"); + let programs = review["review"]["programs"].as_array().unwrap(); + let provider = programs + .iter() + .find(|program| program["kind"] == "runtimeVcsProvider") + .unwrap_or_else(|| panic!("no provider program: {programs:?}")); + assert_eq!(provider["vcs"], "svn"); + assert_eq!(provider["tool"], "node", "{provider}"); + assert_eq!(provider["entry"], "provider/svn-provider.mjs"); + assert_eq!( + provider["vcsTool"], + json!({ "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }) + ); + assert_eq!(provider["mayUseNetwork"], true); + assert!( + programs.contains(&json!({ "kind": "runtime", "tool": "node", "minimumVersion": "20.3" })), + "{programs:?}" + ); +} + +/// A provider changes the working copy and starts its own process, so the +/// manifest must ask for both, name its activation event, and say which +/// contract versions it needs; Git stays reserved for the built-in support. +#[test] +fn provider_manifests_state_permissions_activation_and_requirements() { + let rejected = |mutate: fn(&mut Value)| { + let mut manifest = svn_provider(); + mutate(&mut manifest); + let outcome = validate(manifest); + assert_eq!(outcome["status"], "rejected", "{outcome}"); + outcome["error"].clone() + }; + let missing_permission = rejected(|manifest| { + manifest["permissions"] = json!(["languageServer.launch"]); + }); + assert_eq!( + missing_permission["field"], "permissions", + "{missing_permission}" + ); + let missing_launch = rejected(|manifest| manifest["permissions"] = json!(["process.vcs"])); + assert_eq!(missing_launch["field"], "permissions", "{missing_launch}"); + let missing_activation = rejected(|manifest| manifest["activationEvents"] = json!([])); + assert_eq!( + missing_activation["field"], "activationEvents", + "{missing_activation}" + ); + let missing_requirement = rejected(|manifest| { + manifest["requires"] = json!({ "serverProcess": 1 }); + }); + assert!( + missing_requirement.to_string().contains("vcsProviders"), + "{missing_requirement}" + ); + let reserved = rejected(|manifest| { + manifest["vcsProviders"][0]["id"] = json!("git"); + manifest["activationEvents"] = json!(["onVcsRoot:git"]); + }); + assert_eq!(reserved["code"], "plugin.vcs.reserved", "{reserved}"); + let level_two = rejected(|manifest| manifest["pluginAPIVersion"] = json!(2)); + assert_ne!(level_two["code"], "plugin.vcs.reserved", "{level_two}"); + let escaping = rejected(|manifest| { + manifest["vcsProviders"][0]["metadataPaths"] = json!(["../wc.db"]); + }); + assert_eq!( + escaping["field"], "vcsProviders[0].metadataPaths[0]", + "{escaping}" + ); + let path_tool = rejected(|manifest| { + manifest["vcsProviders"][0]["tool"]["commands"] = json!(["/usr/bin/svn"]); + }); + assert_eq!( + path_tool["field"], "vcsProviders[0].tool.commands", + "{path_tool}" + ); +} + +#[test] +fn a_launch_plan_names_the_vcs_provider_it_starts() { + let request = json!({ + "pluginDirectory": "/plugins/svn", + "stateDirectory": "/state/svn", + "workspaceStateDirectory": "/state/svn/workspace", + "architecture": "arm64" + }); + let plan = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "vcsProviderID": "svn", + "request": request, + })) + .unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + let plan = &plan["plan"]; + assert_eq!(plan["vcsProvider"]["vcs"], "svn", "{plan}"); + assert_eq!( + plan["vcsProvider"]["origin"], + json!({ "kind": "plugin", "pluginID": "dev.lithe.fixture.svn-provider" }) + ); + assert_eq!(plan["vcsProvider"]["metadataPaths"], json!([".svn/wc.db"])); + + let unknown = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "vcsProviderID": "hg", + "request": request, + })) + .unwrap(); + assert_eq!(unknown["status"], "rejected", "{unknown}"); + let both = language_server_launch_plan_command(json!({ + "manifest": svn_provider(), + "languageID": "java", + "vcsProviderID": "svn", + "request": request, + })) + .unwrap_err(); + assert!(matches!(both.code, ErrorCode::InvalidRequest), "{both:?}"); +} + +fn under(path: &Value, root: &Path) -> bool { + path.as_str() + .is_some_and(|path| Path::new(path).starts_with(root)) +} + +/// Builds each case's markers in a fresh directory and checks the roots, +/// ambiguous directories, unused mappings, and file ownership. Roots found +/// above the case's directory belong to the machine running the test and +/// are left out. +#[test] +fn root_detection_matches_the_contract_cases() { + let model = model(); + for case in model["rootDetectionCases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let sandbox = Sandbox::new("vcs-roots"); + for marker in case["markers"].as_array().unwrap() { + let directory = sandbox.directory(marker["directory"].as_str().unwrap()); + let path = directory.join(marker["name"].as_str().unwrap()); + match marker["type"].as_str().unwrap() { + "directory" => fs::create_dir_all(path).unwrap(), + _ => fs::write(path, "gitdir: elsewhere\n").unwrap(), + } + } + let workspace = sandbox.directory(case["workspace"].as_str().unwrap_or("")); + let outcome = vcs_detect_roots_command(json!({ + "workspaceRoot": workspace.to_string_lossy(), + "plugins": [{ "manifest": svn_provider() }], + "mappings": case.get("mappings").cloned().unwrap_or(json!([])), + })) + .unwrap(); + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + let detection = &outcome["detection"]; + assert_eq!(detection["truncated"], false, "{name}"); + + let roots: Vec<&Value> = detection["roots"] + .as_array() + .unwrap() + .iter() + .filter(|root| under(&root["path"], &sandbox.0)) + .collect(); + let found: Vec = roots + .iter() + .map(|root| json!({ "id": root["id"], "vcs": root["vcs"], "mapped": root["mapped"] })) + .collect(); + let expected: Vec = case["expectedRoots"] + .as_array() + .unwrap() + .iter() + .map(|root| { + json!({ + "id": root["id"], + "vcs": root["vcs"], + "mapped": root.get("mapped").cloned().unwrap_or(json!(false)), + }) + }) + .collect(); + assert_eq!(found, expected, "{name}: {detection}"); + assert_eq!(detection["ambiguous"], case["expectedAmbiguous"], "{name}"); + assert_eq!( + detection["unusedMappings"], + case.get("expectedUnusedMappings") + .cloned() + .unwrap_or(json!([])), + "{name}" + ); + + let ownership = case["expectedOwnership"].as_array().unwrap(); + let owners = vcs_owner_of_command(json!({ + "roots": roots.iter().map(|root| root["id"].clone()).collect::>(), + "ambiguous": detection["ambiguous"] + .as_array() + .unwrap() + .iter() + .map(|directory| directory["id"].clone()) + .collect::>(), + "paths": ownership.iter().map(|entry| entry["path"].clone()).collect::>(), + })) + .unwrap(); + assert_eq!(owners["status"], "ok", "{name}: {owners}"); + let expected: Vec = ownership + .iter() + .map(|entry| json!({ "path": entry["path"], "root": entry["root"] })) + .collect(); + assert_eq!(owners["owners"], json!(expected), "{name}"); + } +} + +/// The built-in Git entry and the plugin's descriptor are what a host shows; +/// a second plugin claiming the same VCS loses to the official namespace +/// whatever order the host lists plugins in, and a malformed manifest is +/// reported by its position without hiding the others. +#[test] +fn detection_lists_providers_and_reports_conflicts_and_rejections() { + let model = model(); + let sandbox = Sandbox::new("vcs-providers"); + // `com.` sorts before `dev.`, so only the namespace rule lets the + // official fixture win. + let mut community = svn_provider(); + community["id"] = json!("com.example.svn"); + let mut broken = svn_provider(); + broken["vcsProviders"][0]["id"] = json!("SVN"); + for plugins in [ + vec![community.clone(), broken.clone(), svn_provider()], + vec![svn_provider(), broken.clone(), community.clone()], + ] { + let outcome = vcs_detect_roots_command(json!({ + "workspaceRoot": sandbox.0.to_string_lossy(), + "plugins": plugins + .into_iter() + .map(|manifest| json!({ "manifest": manifest })) + .collect::>(), + })) + .unwrap(); + let detection = &outcome["detection"]; + let providers = detection["providers"].as_array().unwrap(); + assert_eq!(providers[0], model["providers"][0], "{detection}"); + assert_eq!(providers.len(), 2, "{detection}"); + assert_eq!(providers[1], { + let mut descriptor = model["providers"][1].clone(); + descriptor.as_object_mut().unwrap().remove("capabilities"); + descriptor["metadataPaths"] = json!([".svn/wc.db"]); + descriptor["tool"] = + json!({ "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }); + descriptor["mayUseNetwork"] = json!(true); + descriptor + }); + assert_eq!( + detection["conflicts"], + json!([{ + "pluginID": "com.example.svn", + "vcs": "svn", + "by": "dev.lithe.fixture.svn-provider" + }]) + ); + let rejected = detection["rejected"].as_array().unwrap(); + assert_eq!(rejected.len(), 1, "{detection}"); + assert_eq!(rejected[0]["index"], 1); + } + + let error = vcs_detect_roots_command(json!({ "workspaceRoot": "relative/path" })) + .expect_err("a relative workspace is a malformed call"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); +} + +#[test] +fn ownership_queries_reject_malformed_ids_and_paths() { + for payload in [ + json!({ "roots": ["../a"], "paths": ["x"] }), + json!({ "roots": [""], "ambiguous": ["/abs"], "paths": ["x"] }), + json!({ "roots": [""], "paths": ["../x"] }), + json!({ "roots": [""], "paths": ["a\\b"] }), + ] { + let error = vcs_owner_of_command(payload.clone()).expect_err("malformed"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{payload}"); + } +} + +fn decode(operation: &str, capabilities: &Value, request: &Value, reply: &Value) -> Value { + vcs_decode_reply_command(json!({ + "operation": operation, + "capabilities": capabilities, + "request": request, + "reply": reply, + })) + .expect("well-formed payload") +} + +/// A normalized snapshot is a fixed point: decoding it again changes +/// nothing, so hosts can rely on the fixture as the decoded shape. +#[test] +fn status_snapshots_are_the_decoded_shape() { + let model = model(); + for case in model["statusSnapshots"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let capabilities = &case["capabilities"]; + assert!( + negotiated_capabilities(capabilities).is_some(), + "{name}: the capabilities must be a valid negotiation" + ); + let outcome = decode("status", capabilities, &json!({}), &case["snapshot"]); + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"], case["snapshot"], "{name}"); + } + // The Subversion provider's capabilities survive negotiation unchanged. + let svn = &model["providers"][1]["capabilities"]; + assert_eq!(json!(negotiated_capabilities(svn).expect("complete")), *svn); +} + +#[test] +fn plugin_replies_match_the_validation_cases() { + let model = model(); + let capabilities = &model["providers"][1]["capabilities"]; + for case in model["pluginReplyValidationCases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let operation = case["operation"].as_str().unwrap_or("status"); + let request = case.get("request").cloned().unwrap_or(json!({})); + let outcome = decode(operation, capabilities, &request, &case["reply"]); + if let Some(code) = case.get("expectedError") { + assert_eq!(outcome["status"], "rejected", "{name}: {outcome}"); + assert_eq!(outcome["error"]["code"], *code, "{name}"); + } else if let Some(changes) = case.get("expectedChanges") { + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"]["changes"], *changes, "{name}"); + } else { + assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); + assert_eq!(outcome["result"], case["expectedResult"], "{name}"); + } + } + let error = vcs_decode_reply_command(json!({ + "operation": "push", + "capabilities": capabilities, + "reply": {}, + })) + .expect_err("push has no reply shape in version 1"); + assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); +} diff --git a/rust/lithe-core/src/vcs/commands.rs b/rust/lithe-core/src/vcs/commands.rs new file mode 100644 index 0000000000..9f989092dd --- /dev/null +++ b/rust/lithe-core/src/vcs/commands.rs @@ -0,0 +1,263 @@ +//! The `vcs.*` commands: JSON in, outcome envelope out. +//! +//! Like `build.detect`, `vcs.detectRoots` takes the manifests of the enabled +//! plugins and validates each again, so a host never trusts its own cache of +//! a manifest. Malformed payloads fail the call; rejected manifests and +//! replies come back as `{status: "rejected", error}`. + +use super::manifest::{built_in_git_markers, VcsProviderDeclaration, BUILT_IN_GIT}; +use super::model::{is_root_relative, Capabilities}; +use super::requests::{self, Operation}; +use super::roots::{self, Mapping, MarkerSet}; +use crate::plugins::declarative::{ + decode_payload, is_official_namespace, outcome, validate_manifest_value, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DetectRootsRequest { + /// Absolute workspace root. + workspace_root: String, + /// Manifests of the enabled plugins, in any order. + #[serde(default)] + plugins: Vec, + /// The user's choices for ambiguous directories. + #[serde(default)] + mappings: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DetectPlugin { + manifest: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MappingInput { + id: String, + vcs: String, +} + +/// The declaration that answers for one VCS ID. +struct Owner<'a> { + plugin_id: &'a str, + declaration: &'a VcsProviderDeclaration, +} + +/// Official plugins win a VCS ID, then the lower plugin ID, so the result +/// does not depend on the order a host lists plugins in. +fn precedes(candidate: &Owner<'_>, current: &Owner<'_>) -> bool { + ( + !is_official_namespace(candidate.plugin_id), + candidate.plugin_id, + ) < (!is_official_namespace(current.plugin_id), current.plugin_id) +} + +/// `vcs.detectRoots`: registered providers, the roots of every VCS with a +/// provider (and of the built-in Git), ambiguous directories, and which +/// mappings applied. +pub(crate) fn vcs_detect_roots_command(payload: Value) -> Result { + let request: DetectRootsRequest = decode_payload(payload, "vcs.detectRoots")?; + let workspace = PathBuf::from(&request.workspace_root); + if !workspace.is_absolute() || request.workspace_root.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The workspace root must be an absolute path.", + )); + } + // Ancestors are read from the resolved location: a workspace opened + // through a link sits in the link target's working copy. + let workspace = workspace.canonicalize().unwrap_or(workspace); + + let mut rejected = Vec::new(); + let mut manifests = Vec::new(); + for (index, plugin) in request.plugins.into_iter().enumerate() { + match validate_manifest_value(plugin.manifest) { + Ok(manifest) => manifests.push(manifest), + Err(error) => rejected.push(json!({ "index": index, "error": error })), + } + } + let mut owners: BTreeMap<&str, Owner<'_>> = BTreeMap::new(); + let mut conflicts = Vec::new(); + for manifest in &manifests { + for declaration in manifest.vcs_providers() { + let candidate = Owner { + plugin_id: manifest.id(), + declaration, + }; + let (winner, loser) = match owners.remove(declaration.id.as_str()) { + None => { + owners.insert(&declaration.id, candidate); + continue; + } + Some(current) if precedes(&candidate, ¤t) => (candidate, current), + Some(current) => (current, candidate), + }; + conflicts.push(json!({ + "pluginID": loser.plugin_id, + "vcs": declaration.id, + "by": winner.plugin_id, + })); + owners.insert(&declaration.id, winner); + } + } + conflicts.sort_by_key(Value::to_string); + + let git_markers = built_in_git_markers(); + let mut providers = vec![json!({ + "vcs": BUILT_IN_GIT, + "displayName": "Git", + "origin": { "kind": "builtIn" }, + "markers": git_markers, + })]; + let mut sets = vec![MarkerSet { + vcs: BUILT_IN_GIT, + markers: &git_markers, + }]; + for owner in owners.values() { + providers.push(owner.declaration.descriptor(owner.plugin_id)); + sets.push(MarkerSet { + vcs: &owner.declaration.id, + markers: &owner.declaration.detection.markers, + }); + } + let mappings: Vec = request + .mappings + .into_iter() + .map(|mapping| Mapping { + id: mapping.id, + vcs: mapping.vcs, + }) + .collect(); + let detection = roots::detect(&workspace, &sets, &mappings); + let roots: Vec = detection + .roots + .iter() + .map(|root| { + json!({ + "id": root.id, + "vcs": root.vcs, + "path": root.path.to_string_lossy(), + "mapped": root.mapped, + }) + }) + .collect(); + let ambiguous: Vec = detection + .ambiguous + .iter() + .map(|directory| json!({ "id": directory.id, "vcs": directory.vcs })) + .collect(); + let unused: Vec = detection + .unused_mappings + .iter() + .map(|mapping| json!({ "id": mapping.id, "vcs": mapping.vcs })) + .collect(); + Ok(outcome(Ok(( + "detection", + json!({ + "providers": providers, + "roots": roots, + "ambiguous": ambiguous, + "unusedMappings": unused, + "truncated": detection.truncated, + "rejected": rejected, + "conflicts": conflicts, + }), + )))) +} + +/// Most paths one `vcs.ownerOf` call may ask about. +const MAX_OWNER_PATHS: usize = 100_000; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct OwnerOfRequest { + /// Root IDs of one `vcs.detectRoots` result. + roots: Vec, + /// Its unmapped ambiguous directories. + #[serde(default)] + ambiguous: Vec, + /// Workspace-relative paths with `/` separators; `""` is the workspace. + paths: Vec, +} + +/// `vcs.ownerOf`: the root that owns each path under the rule of +/// `vcs.detectRoots`, so every host assigns files the same way. +pub(crate) fn vcs_owner_of_command(payload: Value) -> Result { + let request: OwnerOfRequest = decode_payload(payload, "vcs.ownerOf")?; + if request.paths.len() > MAX_OWNER_PATHS { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("One call asks about at most {MAX_OWNER_PATHS} paths."), + )); + } + if let Some(id) = request + .roots + .iter() + .chain(&request.ambiguous) + .find(|id| !roots::is_directory_id(id)) + { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Malformed root ID").with_details(id.clone()), + ); + } + if let Some(path) = request + .paths + .iter() + .find(|path| !path.is_empty() && !is_root_relative(path)) + { + return Err( + CoreError::new(ErrorCode::InvalidRequest, "Malformed workspace path") + .with_details(path.clone()), + ); + } + let owners: Vec = request + .paths + .iter() + .map(|path| { + json!({ + "path": path, + "root": roots::owner(&request.roots, &request.ambiguous, path), + }) + }) + .collect(); + Ok(outcome(Ok(("owners", json!(owners))))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DecodeReplyRequest { + operation: String, + /// Capabilities the session negotiated, as Core reported them. + capabilities: Capabilities, + /// The arguments the host sent with the request. + #[serde(default)] + request: Value, + /// The provider's result, unwrapped from the session's `value`. + reply: Value, +} + +/// `vcs.decodeReply`: a provider's reply as the normalized result, or +/// `plugin.vcs.invalidOutput`. +pub(crate) fn vcs_decode_reply_command(payload: Value) -> Result { + let request: DecodeReplyRequest = decode_payload(payload, "vcs.decodeReply")?; + let operation = Operation::parse(&request.operation).ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Unknown VCS operation") + .with_details(request.operation.clone()) + })?; + Ok(outcome( + requests::decode( + operation, + &request.request, + &request.reply, + &request.capabilities, + ) + .map(|result| ("result", result)), + )) +} diff --git a/rust/lithe-core/src/vcs/manifest.rs b/rust/lithe-core/src/vcs/manifest.rs new file mode 100644 index 0000000000..9b0d024254 --- /dev/null +++ b/rust/lithe-core/src/vcs/manifest.rs @@ -0,0 +1,286 @@ +//! `vcsProviders[]` of an API level 3 plugin manifest: what a host must know +//! about a VCS before starting any process for it. +//! +//! A declaration names the VCS, the static markers that identify its roots, +//! the metadata the host watches to refresh status, the client tool the +//! provider drives, and the provider process with the launch fields every +//! plugin server process shares. Capabilities are not declared here: they +//! belong to the running provider and are negotiated in `initialize`. + +use super::model::is_vcs_id; +use crate::plugins::declarative::{ + invalid, is_bare_command, is_safe_relative_path, sorted_unique, PluginRejection, ServerProcess, + ServerRole, Validation, +}; +use crate::plugins::settings::DeclaredSettings; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +/// The built-in VCS ID. Git is not part of the unified layer yet, but Core +/// recognizes its roots so a nested working copy is assigned correctly, and +/// no plugin may claim the ID. +pub(crate) const BUILT_IN_GIT: &str = "git"; + +/// Most providers one plugin may declare. +const MAX_PROVIDERS: usize = 8; +/// Most markers one provider may declare. +const MAX_MARKERS: usize = 8; +/// Most metadata paths one provider may ask the host to watch. +const MAX_METADATA_PATHS: usize = 16; +/// Most tool command names one provider may list. +const MAX_TOOL_COMMANDS: usize = 8; +/// Longest display name, in characters. +const MAX_DISPLAY_NAME: usize = 64; + +/// One VCS a plugin implements. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsProviderDeclaration { + /// Stable lowercase VCS ID (`svn`, `hg`); the provider must answer the + /// same ID in `initialize`. + pub(crate) id: String, + pub(crate) display_name: String, + pub(crate) detection: VcsDetection, + /// Root-relative files or directories whose change means the status may + /// be stale, such as `.svn/wc.db`. Sorted and unique once validated. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) metadata_paths: Vec, + /// The VCS client the provider runs, which the host resolves and passes + /// with every request. Absent for a provider that needs no client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) tool: Option, + #[serde(flatten)] + pub(crate) process: ServerProcess, + /// Whether operations may contact a server; shown in the install review. + pub(crate) may_use_network: bool, +} + +/// Static markers of a root. Detection never starts a process. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsDetection { + pub(crate) markers: Vec, +} + +/// An entry directly inside a root that marks it, such as the `.svn` +/// directory. The kind matters: a Git worktree has a `.git` file. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsMarker { + pub(crate) name: String, + #[serde(rename = "type")] + pub(crate) kind: MarkerKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum MarkerKind { + Directory, + File, +} + +/// A client executable the host discovers like a `systemCommand` launch. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VcsTool { + /// Bare command names, tried in order. + pub(crate) commands: Vec, + /// Arguments of a probe that must exit successfully before the host + /// trusts a candidate; empty means no probe. + #[serde(default)] + pub(crate) validation_arguments: Vec, +} + +/// The markers Core uses for the built-in Git support: a `.git` directory, +/// or the `.git` file of a linked worktree or submodule. +pub(crate) fn built_in_git_markers() -> Vec { + vec![ + VcsMarker { + name: ".git".to_string(), + kind: MarkerKind::Directory, + }, + VcsMarker { + name: ".git".to_string(), + kind: MarkerKind::File, + }, + ] +} + +fn is_marker_name(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && value.len() <= 255 + && !value.contains(['/', '\\', '\0']) +} + +/// What the declaration context needs from the manifest. +pub(crate) struct DeclarationContext<'a> { + pub(crate) plugin_api_version: u32, + pub(crate) declared_settings: &'a DeclaredSettings, +} + +/// Validates every declaration and sorts them by ID. +pub(crate) fn validate_declarations( + providers: &mut [VcsProviderDeclaration], + context: &DeclarationContext<'_>, +) -> Validation<()> { + if providers.is_empty() { + return Ok(()); + } + if context.plugin_api_version < 3 { + return Err(invalid( + "vcsProviders", + "VCS providers require plugin API level 3.", + )); + } + if providers.len() > MAX_PROVIDERS { + return Err(invalid( + "vcsProviders", + format!("A plugin declares at most {MAX_PROVIDERS} VCS providers."), + )); + } + let mut ids = BTreeSet::new(); + for (index, provider) in providers.iter_mut().enumerate() { + let field = format!("vcsProviders[{index}]"); + provider.validate(&field, context)?; + if !ids.insert(provider.id.clone()) { + return Err(invalid( + format!("{field}.id"), + "A VCS is declared more than once.", + )); + } + } + providers.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(()) +} + +impl VcsProviderDeclaration { + fn validate(&mut self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { + if !is_vcs_id(&self.id) { + return Err(invalid( + format!("{field}.id"), + "A VCS ID is a lowercase letter followed by up to 31 lowercase letters, digits, or hyphens.", + )); + } + if self.id == BUILT_IN_GIT { + return Err(PluginRejection::at( + "plugin.vcs.reserved", + format!("{field}.id"), + "Git is built into Lithe and cannot be provided by a plugin yet.", + )); + } + let display_name = self.display_name.trim(); + if display_name.is_empty() + || display_name.chars().count() > MAX_DISPLAY_NAME + || display_name.chars().any(char::is_control) + { + return Err(invalid( + format!("{field}.displayName"), + "The display name is one line of 1-64 characters.", + )); + } + + let markers = &mut self.detection.markers; + if markers.is_empty() || markers.len() > MAX_MARKERS { + return Err(invalid( + format!("{field}.detection.markers"), + format!("Detection needs 1-{MAX_MARKERS} markers."), + )); + } + if let Some(index) = markers + .iter() + .position(|marker| !is_marker_name(&marker.name)) + { + return Err(invalid( + format!("{field}.detection.markers[{index}].name"), + "A marker is a name directly inside the root.", + )); + } + markers.sort(); + markers.dedup(); + + if self.metadata_paths.len() > MAX_METADATA_PATHS { + return Err(invalid( + format!("{field}.metadataPaths"), + format!("At most {MAX_METADATA_PATHS} metadata paths are watched."), + )); + } + if let Some(index) = self.metadata_paths.iter().position(|path| { + !is_safe_relative_path(path) || path.split('/').any(|s| s.is_empty() || s == ".") + }) { + return Err(invalid( + format!("{field}.metadataPaths[{index}]"), + "A metadata path must stay inside the root.", + )); + } + self.metadata_paths = sorted_unique(std::mem::take(&mut self.metadata_paths)); + + if let Some(tool) = &self.tool { + if tool.commands.is_empty() + || tool.commands.len() > MAX_TOOL_COMMANDS + || tool + .commands + .iter() + .any(|command| !is_bare_command(command)) + { + return Err(invalid( + format!("{field}.tool.commands"), + format!("A tool names 1-{MAX_TOOL_COMMANDS} bare executable names."), + )); + } + if tool + .validation_arguments + .iter() + .any(|argument| argument.contains('\0')) + { + return Err(invalid( + format!("{field}.tool.validationArguments"), + "Tool arguments must not contain NUL.", + )); + } + } + + self.process.validate( + field, + context.plugin_api_version, + context.declared_settings, + ServerRole::VcsProvider, + ) + } + + /// Display data and requirements a host keeps for a provider; the + /// process launch is planned separately. + pub(crate) fn descriptor(&self, plugin_id: &str) -> Value { + json!({ + "vcs": self.id, + "displayName": self.display_name, + "origin": { "kind": "plugin", "pluginID": plugin_id }, + "markers": self.detection.markers, + "metadataPaths": self.metadata_paths, + "tool": self.tool, + "mayUseNetwork": self.may_use_network, + }) + } +} + +/// Activation event the host raises before starting a VCS provider, once a +/// root of its VCS was detected. +pub(crate) fn activation_event(provider: &VcsProviderDeclaration) -> String { + format!("onVcsRoot:{}", provider.id) +} + +/// Install-review entries: the provider process with the runtimes it needs. +/// The tool is part of the provider entry, so pointing it at another client +/// is a new program the user must approve. +pub(crate) fn programs(provider: &VcsProviderDeclaration) -> Vec { + let mut program = provider.process.program(ServerRole::VcsProvider); + program["vcs"] = json!(provider.id); + // `tool` already names a runtime launch's runtime. + program["vcsTool"] = json!(provider.tool); + program["mayUseNetwork"] = json!(provider.may_use_network); + let mut programs = vec![program]; + programs.extend(provider.process.runtime_programs()); + programs +} diff --git a/rust/lithe-core/src/vcs/mod.rs b/rust/lithe-core/src/vcs/mod.rs new file mode 100644 index 0000000000..18dc26112c --- /dev/null +++ b/rust/lithe-core/src/vcs/mod.rs @@ -0,0 +1,29 @@ +//! The unified VCS layer: one model of roots, file status, history, and +//! check-in that every version control system maps onto, and the +//! deterministic half of talking to a plugin VCS provider. +//! +//! A provider is a plugin process speaking the LSP base protocol, like a +//! build provider. Core validates its `vcsProviders` declaration, detects +//! roots from static markers, plans each request from an operation name and +//! checked arguments, and validates every reply before a host renders it. +//! Hosts resolve the client tool, start and stop processes, watch metadata, +//! and own every piece of UI and state such as changelists. +//! +//! Git is not part of this layer yet: Core recognizes `.git` markers so a +//! nested working copy is assigned to the right root, and the existing +//! `git.*` commands keep serving Git roots unchanged. +//! +//! Note: the design is recorded in .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +mod commands; +mod manifest; +mod model; +mod requests; +mod roots; + +pub(crate) use commands::*; +pub(crate) use manifest::{ + activation_event, programs, validate_declarations, DeclarationContext, VcsProviderDeclaration, +}; +pub(crate) use model::{is_vcs_id, negotiated_capabilities, Capabilities}; +pub(crate) use requests::{is_vcs_method, plan, Operation}; diff --git a/rust/lithe-core/src/vcs/model.rs b/rust/lithe-core/src/vcs/model.rs new file mode 100644 index 0000000000..9fc128da66 --- /dev/null +++ b/rust/lithe-core/src/vcs/model.rs @@ -0,0 +1,726 @@ +//! The VCS-neutral model: capability IDs, file statuses, revisions, and the +//! validation that turns a plugin provider's reply into a result both hosts +//! can render without trusting the provider. +//! +//! Replies are untrusted input. A value outside the contract fails the reply +//! with `plugin.vcs.invalidOutput`; optional data Core does not know (a flag, +//! a field) is dropped, so a newer provider can add it within version 1. +//! Lists the host renders as sets are sorted here, and every list is capped, +//! so both hosts show the same result for the same reply. + +use crate::plugins::declarative::{PluginRejection, Validation}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Error code of every reply that fails validation or exceeds a limit. +pub(crate) const INVALID_OUTPUT: &str = "plugin.vcs.invalidOutput"; + +/// Capability IDs of protocol version 1. `true` marks the capabilities +/// every provider must support; a provider without one of them is not used. +pub(crate) const CAPABILITIES: &[(&str, bool)] = &[ + ("annotate", false), + ("baseContent", true), + ("checkin", true), + ("cleanup", false), + ("conflicts", false), + ("fileHistory", false), + ("fileOperations", false), + ("history", false), + ("push", false), + ("rollback", true), + ("staging", false), + ("status", true), + ("update", false), +]; + +/// Version of each capability this Core build understands. +const CAPABILITY_VERSION: u64 = 1; + +/// A snapshot keeps at most this many changes and is then marked truncated. +pub(crate) const MAX_CHANGES: usize = 100_000; +/// Largest base-revision content, in UTF-8 bytes, a reply may carry. +pub(crate) const MAX_BASE_CONTENT_BYTES: usize = 32 * 1024 * 1024; +/// Most entries one history page may return; the request limit is lower. +pub(crate) const MAX_HISTORY_PAGE: usize = 500; +/// Most changed paths one history entry may list. +const MAX_ENTRY_CHANGES: usize = 10_000; +/// Most lines an annotation may cover. +pub(crate) const MAX_ANNOTATED_LINES: usize = 500_000; +/// Most distinct revisions one annotation may reference. +const MAX_ANNOTATION_REVISIONS: usize = 100_000; +/// Longest revision ID, opaque cursor, branch, or author, in characters. +const MAX_SHORT_TEXT: usize = 256; +/// Longest revision display text, in characters. +const MAX_REVISION_DISPLAY: usize = 64; +/// Longest commit message, in UTF-8 bytes. +pub(crate) const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +/// Longest remote location after sanitizing, in characters. +const MAX_LOCATION: usize = 2048; +/// Longest root-relative path, in UTF-8 bytes. +const MAX_PATH_BYTES: usize = 4096; + +/// File status values of version 1. A reply with any other value fails. +const STATUSES: &[&str] = &[ + "added", + "conflicted", + "copied", + "deleted", + "ignored", + "missing", + "modified", + "renamed", + "replaced", + "switched", + "typeChanged", + "unversioned", +]; + +/// Statuses whose change names the path it came from. +const STATUSES_WITH_ORIGIN: &[&str] = &["copied", "renamed"]; + +/// Refinements of a status. Unknown flags are dropped, not rejected. +const FLAGS: &[&str] = &[ + "external", + "locked", + "lockedByOther", + "propertiesModified", + "submodule", + "treeConflict", +]; + +/// Staged state of a change; only providers with `staging` report it. +const STAGING: &[&str] = &["partial", "staged", "unstaged"]; + +/// How a host presents a revision ID; never used to order revisions. +const REVISION_KINDS: &[&str] = &["hash", "number", "opaque"]; + +/// Capabilities a provider negotiated, by ID, with their versions. +pub(crate) type Capabilities = BTreeMap; + +fn invalid_output(message: impl Into) -> PluginRejection { + PluginRejection::new(INVALID_OUTPUT, message) +} + +/// Whether `id` is a capability of protocol version 1. +pub(crate) fn is_capability(id: &str) -> bool { + CAPABILITIES.iter().any(|(known, _)| *known == id) +} + +/// The capabilities of a provider's `initialize` answer that this build +/// understands, or `None` when a required one is missing or the value is +/// not an object. Unknown IDs and versions are dropped: a newer provider may +/// offer more, and the host then hides only what Core cannot validate. +pub(crate) fn negotiated_capabilities(value: &Value) -> Option { + let offered = value.as_object()?; + let capabilities: Capabilities = offered + .iter() + .filter(|(id, version)| is_capability(id) && version.as_u64() == Some(CAPABILITY_VERSION)) + .map(|(id, _)| (id.clone(), CAPABILITY_VERSION)) + .collect(); + CAPABILITIES + .iter() + .filter(|(_, required)| *required) + .all(|(id, _)| capabilities.contains_key(*id)) + .then_some(capabilities) +} + +/// Whether a stable VCS ID is well formed: a lowercase letter, then up to +/// 31 lowercase letters, digits, or hyphens (`git`, `svn`, `hg`). +pub(crate) fn is_vcs_id(value: &str) -> bool { + let mut characters = value.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value.len() <= 32 + && characters.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) +} + +/// Whether `value` is a path inside a root: relative, `/`-separated, and +/// without empty, `.`, or `..` segments that could leave or alias the root. +pub(crate) fn is_root_relative(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_PATH_BYTES + && !value.starts_with('/') + && !value.contains('\\') + && !value.chars().any(char::is_control) + && value + .split('/') + .all(|segment| !segment.is_empty() && segment != "." && segment != "..") + // A drive prefix would make the path absolute on Windows. + && !value.split('/').next().is_some_and(|first| first.ends_with(':')) +} + +/// Removes user info, query, and fragment from a remote location, so a URL +/// that carries credentials never leaves Core. Locations without a scheme +/// (a local repository path) are kept apart from the query and fragment. +pub(crate) fn sanitize_location(value: &str) -> Option { + let value = value.split(['?', '#']).next().unwrap_or_default(); + let sanitized = match value.split_once("://") { + Some((scheme, rest)) => { + let (authority, path) = rest + .find('/') + .map_or((rest, ""), |index| rest.split_at(index)); + let host = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + format!("{scheme}://{host}{path}") + } + None => value.to_string(), + }; + (!sanitized.is_empty() + && sanitized.chars().count() <= MAX_LOCATION + && !sanitized.chars().any(char::is_control)) + .then_some(sanitized) +} + +fn object<'a>(value: &'a Value, what: &str) -> Validation<&'a Map> { + value + .as_object() + .ok_or_else(|| invalid_output(format!("The {what} must be an object."))) +} + +fn short_text(value: &str, limit: usize) -> bool { + !value.is_empty() && value.chars().count() <= limit && !value.chars().any(char::is_control) +} + +/// An optional string field: absent and `null` are the same, anything but a +/// short single-line string fails. +fn optional_text( + members: &Map, + key: &str, + limit: usize, + what: &str, +) -> Validation> { + match members.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(text)) if short_text(text, limit) => Ok(Some(text.clone())), + Some(_) => Err(invalid_output(format!( + "The {what} {key} must be a single-line string of at most {limit} characters." + ))), + } +} + +fn root_path(value: Option<&Value>, what: &str) -> Validation { + match value { + Some(Value::String(path)) if is_root_relative(path) => Ok(path.clone()), + _ => Err(invalid_output(format!( + "The {what} must be a path inside the root." + ))), + } +} + +/// A revision as `{id, display, kind}`. `display` falls back to the ID. +pub(crate) fn revision(value: &Value) -> Validation { + let members = object(value, "revision")?; + let id = match members.get("id") { + Some(Value::String(id)) if short_text(id, MAX_SHORT_TEXT) => id.clone(), + _ => { + return Err(invalid_output( + "A revision ID must be a single-line string of at most 256 characters.", + )) + } + }; + let display = optional_text(members, "display", MAX_REVISION_DISPLAY, "revision")? + .unwrap_or_else(|| id.chars().take(MAX_REVISION_DISPLAY).collect()); + let kind = match members.get("kind") { + None | Some(Value::Null) => "opaque", + Some(Value::String(kind)) if REVISION_KINDS.contains(&kind.as_str()) => kind.as_str(), + Some(_) => { + return Err(invalid_output( + "A revision kind must be hash, number, or opaque.", + )) + } + }; + Ok(json!({ "id": id, "display": display, "kind": kind })) +} + +fn optional_revision(value: Option<&Value>) -> Validation { + match value { + None | Some(Value::Null) => Ok(Value::Null), + Some(value) => revision(value), + } +} + +/// One change of a status snapshot or a history entry. +fn change(value: &Value, staging: bool) -> Validation { + let members = object(value, "change")?; + let path = root_path(members.get("path"), "change path")?; + let status = match members.get("status") { + Some(Value::String(status)) if STATUSES.contains(&status.as_str()) => status.clone(), + Some(Value::String(status)) => { + return Err(invalid_output(format!( + "The file status {status} is not part of the contract." + ))) + } + _ => return Err(invalid_output("A change needs a status.")), + }; + let old_path = match members.get("oldPath") { + None | Some(Value::Null) => Value::Null, + // Only a copy or rename names where the file came from; anything + // else would make the host show a move that never happened. + Some(value) if STATUSES_WITH_ORIGIN.contains(&status.as_str()) => { + json!(root_path(Some(value), "original path")?) + } + Some(_) => { + return Err(invalid_output(format!( + "A {status} change cannot name an original path." + ))) + } + }; + let flags: BTreeSet<&str> = match members.get("flags") { + None | Some(Value::Null) => BTreeSet::new(), + Some(Value::Array(flags)) => flags + .iter() + .filter_map(Value::as_str) + .filter(|flag| FLAGS.contains(flag)) + .collect(), + Some(_) => return Err(invalid_output("Change flags must be an array.")), + }; + let mut normalized = json!({ + "path": path, + "oldPath": old_path, + "status": status, + "flags": flags, + }); + match (members.get("staging"), staging) { + (None | Some(Value::Null), false) => {} + (Some(Value::String(state)), true) if STAGING.contains(&state.as_str()) => { + normalized["staging"] = json!(state); + } + (_, true) => return Err(invalid_output( + "A provider with staging must report staged, unstaged, or partial for every change.", + )), + (Some(_), false) => { + return Err(invalid_output( + "A change carries staging although the provider did not negotiate staging.", + )) + } + } + Ok(normalized) +} + +/// Validated changes sorted by path; duplicate paths fail the reply. +fn changes(value: Option<&Value>, staging: bool, limit: usize) -> Validation<(Vec, bool)> { + let items = match value { + Some(Value::Array(items)) => items, + None | Some(Value::Null) => return Ok((Vec::new(), false)), + Some(_) => return Err(invalid_output("Changes must be an array.")), + }; + let mut by_path = BTreeMap::new(); + for item in items { + let change = change(item, staging)?; + let path = change["path"].as_str().unwrap_or_default().to_string(); + if by_path.insert(path.clone(), change).is_some() { + return Err(invalid_output(format!( + "The path {path} is reported more than once." + ))); + } + } + let truncated = by_path.len() > limit; + Ok((by_path.into_values().take(limit).collect(), truncated)) +} + +fn boolean(members: &Map, key: &str) -> Validation { + match members.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(_) => Err(invalid_output(format!("{key} must be a boolean."))), + } +} + +/// `lithe/vcs/status`: `{head, changes, truncated}` with changes sorted by +/// path and capped at [`MAX_CHANGES`]. +pub(crate) fn decode_status(reply: &Value, capabilities: &Capabilities) -> Validation { + let members = object(reply, "status reply")?; + let head = match members.get("head") { + None | Some(Value::Null) => json!({ "revision": null, "branch": null, "location": null }), + Some(head) => { + let head = object(head, "head")?; + let location = optional_text(head, "location", MAX_LOCATION, "head")? + .map(|location| { + sanitize_location(&location) + .ok_or_else(|| invalid_output("The head location is not a location.")) + }) + .transpose()?; + json!({ + "revision": optional_revision(head.get("revision"))?, + "branch": optional_text(head, "branch", MAX_SHORT_TEXT, "head")?, + "location": location, + }) + } + }; + let (changes, capped) = changes( + members.get("changes"), + capabilities.contains_key("staging"), + MAX_CHANGES, + )?; + Ok(json!({ + "head": head, + "changes": changes, + // A provider that stopped early says so; Core adds its own cap. + "truncated": capped || boolean(members, "truncated")?, + })) +} + +/// `lithe/vcs/baseContent`: the file's text at its base revision, `null` +/// content when it has none (an added file), and `binary` when the host +/// must not diff it as text. +pub(crate) fn decode_base_content(reply: &Value) -> Validation { + let members = object(reply, "base content reply")?; + let binary = boolean(members, "binary")?; + let content = match members.get("content") { + None | Some(Value::Null) => Value::Null, + Some(Value::String(_)) if binary => { + return Err(invalid_output( + "Binary base content is reported without text.", + )) + } + Some(Value::String(text)) if text.len() <= MAX_BASE_CONTENT_BYTES => json!(text), + Some(Value::String(_)) => return Err(invalid_output("The base content exceeds 32 MiB.")), + Some(_) => return Err(invalid_output("content must be a string or null.")), + }; + Ok(json!({ "content": content, "binary": binary })) +} + +fn message(members: &Map) -> Validation { + match members.get("message") { + None | Some(Value::Null) => Ok(String::new()), + Some(Value::String(text)) if text.len() <= MAX_MESSAGE_BYTES && !text.contains('\0') => { + Ok(text.clone()) + } + Some(_) => Err(invalid_output( + "A message must be a string of at most 1 MiB without NUL.", + )), + } +} + +/// Author, date, and message of one revision. Dates are passed through as +/// the provider wrote them (RFC 3339); hosts format but never compare them. +fn revision_details(members: &Map) -> Validation { + Ok(json!({ + "revision": revision(members.get("revision").unwrap_or(&Value::Null))?, + "author": optional_text(members, "author", MAX_SHORT_TEXT, "revision")?, + "date": optional_text(members, "date", MAX_SHORT_TEXT, "revision")?, + "message": message(members)?, + })) +} + +/// `lithe/vcs/history` and `lithe/vcs/fileHistory`: one page of entries in +/// the provider's order (newest first), and the cursor of the next page. +pub(crate) fn decode_history(reply: &Value, limit: usize) -> Validation { + let members = object(reply, "history reply")?; + let items = match members.get("entries") { + Some(Value::Array(items)) => items, + _ => return Err(invalid_output("A history reply needs an entries array.")), + }; + // The provider received the limit; more entries means it ignored it. + if items.len() > limit.min(MAX_HISTORY_PAGE) { + return Err(invalid_output(format!( + "The history page has more than the {limit} requested entries." + ))); + } + let mut entries = Vec::with_capacity(items.len()); + for item in items { + let entry = object(item, "history entry")?; + let mut normalized = revision_details(entry)?; + let (changes, truncated) = changes(entry.get("changes"), false, MAX_ENTRY_CHANGES)?; + normalized["changes"] = json!(changes); + normalized["changesTruncated"] = json!(truncated || boolean(entry, "changesTruncated")?); + entries.push(normalized); + } + let next_cursor = optional_text(members, "nextCursor", MAX_SHORT_TEXT, "history")?; + Ok(json!({ "entries": entries, "nextCursor": next_cursor })) +} + +/// `lithe/vcs/annotate`: the revisions a file's lines come from, and per +/// line the ID of one of them, or `null` for a line not yet checked in. +pub(crate) fn decode_annotation(reply: &Value) -> Validation { + let members = object(reply, "annotation reply")?; + let (Some(Value::Array(revisions)), Some(Value::Array(lines))) = + (members.get("revisions"), members.get("lines")) + else { + return Err(invalid_output( + "An annotation needs revisions and lines arrays.", + )); + }; + if revisions.len() > MAX_ANNOTATION_REVISIONS || lines.len() > MAX_ANNOTATED_LINES { + return Err(invalid_output("The annotation exceeds its size limit.")); + } + let mut by_id = BTreeMap::new(); + for item in revisions { + let details = revision_details(object(item, "annotation revision")?)?; + let id = details["revision"]["id"] + .as_str() + .unwrap_or_default() + .to_string(); + if by_id.insert(id.clone(), details).is_some() { + return Err(invalid_output(format!( + "The revision {id} is listed more than once." + ))); + } + } + let mut used = BTreeSet::new(); + let mut normalized_lines = Vec::with_capacity(lines.len()); + for line in lines { + match line { + Value::Null => normalized_lines.push(Value::Null), + Value::String(id) if by_id.contains_key(id) => { + used.insert(id.clone()); + normalized_lines.push(json!(id)); + } + _ => { + return Err(invalid_output( + "Every annotated line names a listed revision or null.", + )) + } + } + } + // Revisions no line uses would show up in a legend with nothing to point + // at; sorting by ID keeps the list stable without ordering revisions. + let revisions: Vec = by_id + .into_iter() + .filter(|(id, _)| used.contains(id)) + .map(|(_, details)| details) + .collect(); + Ok(json!({ "revisions": revisions, "lines": normalized_lines })) +} + +/// `lithe/vcs/checkin`: the new revision, or `null` when the provider +/// cannot name it. +pub(crate) fn decode_checkin(reply: &Value) -> Validation { + let members = object(reply, "check-in reply")?; + Ok(json!({ "revision": optional_revision(members.get("revision"))? })) +} + +/// `lithe/vcs/update`: the revision the working copy is at now, and the +/// paths the update left conflicted, sorted. +pub(crate) fn decode_update(reply: &Value) -> Validation { + let members = object(reply, "update reply")?; + let conflicts: BTreeSet = match members.get("conflicts") { + None | Some(Value::Null) => BTreeSet::new(), + Some(Value::Array(paths)) => paths + .iter() + .map(|path| root_path(Some(path), "conflicted path")) + .collect::>()?, + Some(_) => return Err(invalid_output("conflicts must be an array of paths.")), + }; + if conflicts.len() > MAX_CHANGES { + return Err(invalid_output("The update reports too many conflicts.")); + } + Ok(json!({ + "revision": optional_revision(members.get("revision"))?, + "conflicts": conflicts, + })) +} + +/// Replies that carry no data. Anything in them is ignored, so a provider +/// may report extra details without breaking an older host. +pub(crate) fn decode_empty(reply: &Value) -> Validation { + match reply { + Value::Null | Value::Object(_) => Ok(json!({})), + _ => Err(invalid_output("The reply must be an object or null.")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capabilities(staging: bool) -> Capabilities { + let mut capabilities: Capabilities = CAPABILITIES + .iter() + .filter(|(_, required)| *required) + .map(|(id, _)| (id.to_string(), 1)) + .collect(); + if staging { + capabilities.insert("staging".to_string(), 1); + } + capabilities + } + + #[test] + fn negotiation_keeps_known_version_one_capabilities_and_requires_the_core_set() { + let negotiated = negotiated_capabilities(&json!({ + "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, + "annotate": 2, "futureThing": 1, "update": 1 + })) + .expect("required set present"); + assert_eq!( + negotiated.keys().map(String::as_str).collect::>(), + ["baseContent", "checkin", "rollback", "status", "update"] + ); + // A provider without rollback cannot back the generic commit UI. + assert!( + negotiated_capabilities(&json!({ "status": 1, "baseContent": 1, "checkin": 1 })) + .is_none() + ); + assert!(negotiated_capabilities(&json!(["status"])).is_none()); + } + + #[test] + fn root_relative_paths_cannot_leave_or_alias_the_root() { + for valid in ["a.txt", "src/Main.java", ".svnignore", "a b/c"] { + assert!(is_root_relative(valid), "{valid}"); + } + for invalid in [ + "", + "/etc/passwd", + "../x", + "a/../b", + "a//b", + "./a", + "a/", + "a\\b", + "C:/x", + "a\u{0}b", + ] { + assert!(!is_root_relative(invalid), "{invalid:?}"); + } + } + + #[test] + fn locations_lose_credentials_query_and_fragment() { + assert_eq!( + sanitize_location("https://user:secret@svn.example.invalid/repo/trunk?p=1#f") + .as_deref(), + Some("https://svn.example.invalid/repo/trunk") + ); + assert_eq!( + sanitize_location("svn+ssh://me@host.invalid").as_deref(), + Some("svn+ssh://host.invalid") + ); + assert_eq!( + sanitize_location("file:///var/svn/repo").as_deref(), + Some("file:///var/svn/repo") + ); + assert_eq!(sanitize_location("?only=query"), None); + } + + #[test] + fn status_reply_is_normalized_and_capped() { + let reply = json!({ + "head": { + "revision": { "id": "12", "kind": "number" }, + "location": "https://u:p@svn.example.invalid/r", + "branch": null + }, + "changes": [ + { "path": "b", "status": "modified", "flags": ["propertiesModified", "locked", "x"] }, + { "path": "a", "oldPath": "old", "status": "renamed" } + ] + }); + let snapshot = decode_status(&reply, &capabilities(false)).expect("valid"); + assert_eq!( + snapshot, + json!({ + "head": { + "revision": { "id": "12", "display": "12", "kind": "number" }, + "branch": null, + "location": "https://svn.example.invalid/r" + }, + "changes": [ + { "path": "a", "oldPath": "old", "status": "renamed", "flags": [] }, + { "path": "b", "oldPath": null, "status": "modified", "flags": ["locked", "propertiesModified"] } + ], + "truncated": false + }) + ); + } + + #[test] + fn status_reply_rejects_contract_violations() { + for reply in [ + json!({ "changes": [{ "path": "a", "status": "modified" }, { "path": "a", "status": "added" }] }), + json!({ "changes": [{ "path": "a", "oldPath": "b", "status": "modified" }] }), + json!({ "changes": [{ "path": "a", "status": "modified", "flags": "locked" }] }), + json!({ "changes": "a" }), + json!({ "head": { "revision": { "id": "" } } }), + json!({ "head": { "revision": { "id": "1", "kind": "date" } } }), + ] { + let error = decode_status(&reply, &capabilities(false)).expect_err("rejected"); + assert_eq!(error.code, INVALID_OUTPUT, "{reply}"); + } + // With staging negotiated every change must say how it is staged. + let error = decode_status( + &json!({ "changes": [{ "path": "a", "status": "modified" }] }), + &capabilities(true), + ) + .expect_err("missing staging"); + assert_eq!(error.code, INVALID_OUTPUT); + } + + #[test] + fn oversized_snapshots_are_marked_truncated() { + let changes: Vec = (0..=MAX_CHANGES) + .map(|index| json!({ "path": format!("f{index:06}"), "status": "unversioned" })) + .collect(); + let snapshot = + decode_status(&json!({ "changes": changes }), &capabilities(false)).expect("valid"); + assert_eq!( + snapshot["changes"].as_array().map(Vec::len), + Some(MAX_CHANGES) + ); + assert_eq!(snapshot["truncated"], json!(true)); + } + + #[test] + fn base_content_distinguishes_text_binary_and_absent() { + assert_eq!( + decode_base_content(&json!({ "content": "x\n" })).expect("text"), + json!({ "content": "x\n", "binary": false }) + ); + assert_eq!( + decode_base_content(&json!({ "content": null, "binary": true })).expect("binary"), + json!({ "content": null, "binary": true }) + ); + assert!(decode_base_content(&json!({ "content": "x", "binary": true })).is_err()); + } + + #[test] + fn history_page_keeps_provider_order_and_enforces_the_limit() { + let reply = json!({ + "entries": [ + { "revision": { "id": "9", "kind": "number" }, "author": "ann", "date": "2026-10-22T08:00:00Z", "message": "second", + "changes": [{ "path": "b", "status": "modified" }, { "path": "a", "status": "added" }] }, + { "revision": { "id": "3", "kind": "number" }, "message": "first" } + ], + "nextCursor": "3" + }); + let page = decode_history(&reply, 2).expect("valid"); + assert_eq!(page["entries"][0]["revision"]["id"], "9"); + assert_eq!(page["entries"][0]["changes"][0]["path"], "a"); + assert_eq!(page["entries"][1]["author"], Value::Null); + assert_eq!(page["nextCursor"], "3"); + assert!(decode_history(&reply, 1).is_err()); + } + + #[test] + fn annotation_lines_must_reference_listed_revisions() { + let reply = json!({ + "revisions": [ + { "revision": { "id": "2" }, "author": "bo" }, + { "revision": { "id": "1" }, "author": "al" }, + { "revision": { "id": "7" } } + ], + "lines": ["1", "2", null, "1"] + }); + let annotation = decode_annotation(&reply).expect("valid"); + assert_eq!(annotation["lines"], json!(["1", "2", null, "1"])); + // Unused revision 7 is dropped; the rest are ordered by ID. + assert_eq!(annotation["revisions"][0]["revision"]["id"], "1"); + assert_eq!(annotation["revisions"].as_array().map(Vec::len), Some(2)); + assert!(decode_annotation(&json!({ "revisions": [], "lines": ["1"] })).is_err()); + } + + #[test] + fn update_reports_sorted_conflicts_inside_the_root() { + assert_eq!( + decode_update(&json!({ "revision": { "id": "5" }, "conflicts": ["b", "a", "b"] })) + .expect("valid"), + json!({ "revision": { "id": "5", "display": "5", "kind": "opaque" }, "conflicts": ["a", "b"] }) + ); + assert!(decode_update(&json!({ "conflicts": ["/abs"] })).is_err()); + } +} diff --git a/rust/lithe-core/src/vcs/requests.rs b/rust/lithe-core/src/vcs/requests.rs new file mode 100644 index 0000000000..4923fe42c4 --- /dev/null +++ b/rust/lithe-core/src/vcs/requests.rs @@ -0,0 +1,503 @@ +//! Operations of the VCS provider protocol: which method and capability each +//! one uses, how a host's arguments become request params, and how the +//! provider's reply is decoded. +//! +//! The host names an operation, never a method, so a session that negotiated +//! the protocol cannot be used as a generic JSON-RPC tunnel. Arguments come +//! from the host and are checked as strictly as replies: paths stay inside the +//! root, lists are bounded, and unknown fields fail, because a provider runs +//! the VCS client with them. + +use super::model::{self, Capabilities, MAX_HISTORY_PAGE, MAX_MESSAGE_BYTES}; +use crate::plugins::declarative::Validation; +use crate::protocol::{CoreError, ErrorCode}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::path::Path; + +/// Most paths one mutation may name. +const MAX_PATHS: usize = 10_000; +/// History page size when the host does not ask for one. +const DEFAULT_HISTORY_LIMIT: usize = 100; + +/// One operation of protocol version 1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Operation { + Status, + BaseContent, + History, + FileHistory, + Annotate, + Checkin, + Rollback, + Update, + FileOperation, + Resolve, + Cleanup, +} + +/// Every operation, for method checks and contract tests. +pub(crate) const OPERATIONS: [Operation; 11] = [ + Operation::Status, + Operation::BaseContent, + Operation::History, + Operation::FileHistory, + Operation::Annotate, + Operation::Checkin, + Operation::Rollback, + Operation::Update, + Operation::FileOperation, + Operation::Resolve, + Operation::Cleanup, +]; + +impl Operation { + /// Parses the operation name a host sends, which is also the last + /// segment of its method. + pub(crate) fn parse(name: &str) -> Option { + OPERATIONS + .into_iter() + .find(|operation| operation.name() == name) + } + + pub(crate) fn name(self) -> &'static str { + match self { + Self::Status => "status", + Self::BaseContent => "baseContent", + Self::History => "history", + Self::FileHistory => "fileHistory", + Self::Annotate => "annotate", + Self::Checkin => "checkin", + Self::Rollback => "rollback", + Self::Update => "update", + Self::FileOperation => "fileOperation", + Self::Resolve => "resolve", + Self::Cleanup => "cleanup", + } + } + + /// JSON-RPC method the host writes to the provider. + pub(crate) fn method(self) -> &'static str { + match self { + Self::Status => "lithe/vcs/status", + Self::BaseContent => "lithe/vcs/baseContent", + Self::History => "lithe/vcs/history", + Self::FileHistory => "lithe/vcs/fileHistory", + Self::Annotate => "lithe/vcs/annotate", + Self::Checkin => "lithe/vcs/checkin", + Self::Rollback => "lithe/vcs/rollback", + Self::Update => "lithe/vcs/update", + Self::FileOperation => "lithe/vcs/fileOperation", + Self::Resolve => "lithe/vcs/resolve", + Self::Cleanup => "lithe/vcs/cleanup", + } + } + + /// Capability the provider must have negotiated for this operation. + pub(crate) fn capability(self) -> &'static str { + match self { + Self::FileOperation => "fileOperations", + Self::Resolve => "conflicts", + other => other.name(), + } + } +} + +/// Whether `method` belongs to the protocol; its replies are wrapped like +/// command results so no LSP normalizer reinterprets them. +pub(crate) fn is_vcs_method(method: &str) -> bool { + OPERATIONS + .into_iter() + .any(|operation| operation.method() == method) +} + +fn invalid_request(message: impl Into) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn arguments(operation: Operation, value: Value) -> Result { + // An operation without arguments may omit them entirely. + let value = if value.is_null() { json!({}) } else { value }; + serde_json::from_value(value).map_err(|error| { + invalid_request(format!("Invalid {} arguments", operation.name())) + .with_details(error.to_string()) + }) +} + +fn root_path(path: &str) -> Result { + if model::is_root_relative(path) { + Ok(path.to_string()) + } else { + Err(invalid_request("A VCS path must be relative to its root.").with_details(path)) + } +} + +/// Root-relative paths, sorted and unique; at least one, at most +/// [`MAX_PATHS`]. +fn path_list(paths: &[String]) -> Result, CoreError> { + if paths.is_empty() || paths.len() > MAX_PATHS { + return Err(invalid_request(format!( + "An operation names 1-{MAX_PATHS} paths." + ))); + } + let unique: BTreeSet = paths + .iter() + .map(|path| root_path(path)) + .collect::>()?; + Ok(unique.into_iter().collect()) +} + +fn absolute_path(value: &str, what: &str) -> Result { + if Path::new(value).is_absolute() && !value.contains('\0') { + Ok(value.to_string()) + } else { + Err(invalid_request(format!( + "The {what} must be an absolute path." + ))) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StatusArguments { + #[serde(default)] + include_ignored: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PathArguments { + path: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PathsArguments { + paths: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HistoryArguments { + #[serde(default)] + path: Option, + /// Opaque cursor from the previous page; absent for the first page. + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CheckinArguments { + paths: Vec, + message: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UpdateArguments { + /// Revision ID to update to, as the provider reported it; absent for + /// the newest revision. + #[serde(default)] + revision: Option, +} + +/// A change made in the project tree that the VCS must record. `move` +/// carries both ends so the VCS keeps the file's history. +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +enum FileOperationArguments { + Add { paths: Vec }, + Delete { paths: Vec }, + Move { from: String, to: String }, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NoArguments {} + +/// The params of one request, without `root` and `tool`. +fn operation_params(operation: Operation, value: Value) -> Result { + Ok(match operation { + Operation::Status => { + let arguments: StatusArguments = arguments(operation, value)?; + json!({ "includeIgnored": arguments.include_ignored }) + } + Operation::BaseContent | Operation::Annotate => { + let arguments: PathArguments = arguments(operation, value)?; + json!({ "path": root_path(&arguments.path)? }) + } + Operation::History | Operation::FileHistory => { + let arguments: HistoryArguments = arguments(operation, value)?; + let limit = arguments.limit.unwrap_or(DEFAULT_HISTORY_LIMIT); + if limit == 0 || limit > MAX_HISTORY_PAGE { + return Err(invalid_request(format!( + "A history page has 1-{MAX_HISTORY_PAGE} entries." + ))); + } + if let Some(cursor) = &arguments.cursor { + if cursor.is_empty() || cursor.len() > 256 || cursor.chars().any(char::is_control) { + return Err(invalid_request("The history cursor is malformed.")); + } + } + let mut params = json!({ "cursor": arguments.cursor, "limit": limit }); + match (operation, arguments.path) { + (Operation::FileHistory, Some(path)) => params["path"] = json!(root_path(&path)?), + (Operation::History, None) => {} + _ => { + return Err(invalid_request( + "fileHistory needs a path; history of the whole root takes none.", + )) + } + } + params + } + Operation::Checkin => { + let arguments: CheckinArguments = arguments(operation, value)?; + if arguments.message.trim().is_empty() + || arguments.message.len() > MAX_MESSAGE_BYTES + || arguments.message.contains('\0') + { + return Err(invalid_request( + "A check-in message is non-empty text of at most 1 MiB.", + )); + } + json!({ "paths": path_list(&arguments.paths)?, "message": arguments.message }) + } + Operation::Rollback | Operation::Resolve => { + let arguments: PathsArguments = arguments(operation, value)?; + json!({ "paths": path_list(&arguments.paths)? }) + } + Operation::Update => { + let arguments: UpdateArguments = arguments(operation, value)?; + if let Some(revision) = &arguments.revision { + if revision.is_empty() + || revision.len() > 256 + || revision.chars().any(char::is_control) + { + return Err(invalid_request("The revision ID is malformed.")); + } + } + json!({ "revision": arguments.revision }) + } + Operation::FileOperation => match arguments(operation, value)? { + FileOperationArguments::Add { paths } => { + json!({ "kind": "add", "paths": path_list(&paths)? }) + } + FileOperationArguments::Delete { paths } => { + json!({ "kind": "delete", "paths": path_list(&paths)? }) + } + FileOperationArguments::Move { from, to } => { + let (from, to) = (root_path(&from)?, root_path(&to)?); + if from == to { + return Err(invalid_request("A move needs two different paths.")); + } + json!({ "kind": "move", "from": from, "to": to }) + } + }, + Operation::Cleanup => { + let _: NoArguments = arguments(operation, value)?; + json!({}) + } + }) +} + +/// A request ready to be written to a provider session. +#[derive(Debug)] +pub(crate) struct PlannedRequest { + pub(crate) method: &'static str, + pub(crate) params: Value, +} + +/// Plans one request. `root` is the native path of the working-copy root and +/// `tool` the client executable the host resolved for the provider; both are +/// sent with every request so the provider never searches for either. +pub(crate) fn plan( + operation: Operation, + root: &str, + tool: Option<&str>, + arguments: Value, + capabilities: &Capabilities, +) -> Result { + if !capabilities.contains_key(operation.capability()) { + return Err(CoreError::new( + ErrorCode::NotSupported, + format!( + "The VCS provider did not negotiate {}.", + operation.capability() + ), + ) + .with_details("vcs.capability.unsupported")); + } + let mut params = operation_params(operation, arguments)?; + params["root"] = json!({ "path": absolute_path(root, "VCS root")? }); + params["tool"] = match tool { + Some(tool) => json!({ "executable": absolute_path(tool, "VCS tool")? }), + None => Value::Null, + }; + Ok(PlannedRequest { + method: operation.method(), + params, + }) +} + +/// Decodes the reply to `operation`. `request` is the arguments the host +/// sent, which bound what a valid reply may contain. +pub(crate) fn decode( + operation: Operation, + request: &Value, + reply: &Value, + capabilities: &Capabilities, +) -> Validation { + match operation { + Operation::Status => model::decode_status(reply, capabilities), + Operation::BaseContent => model::decode_base_content(reply), + Operation::History | Operation::FileHistory => { + let limit = request + .get("limit") + .and_then(Value::as_u64) + .and_then(|limit| usize::try_from(limit).ok()) + .unwrap_or(DEFAULT_HISTORY_LIMIT) + .min(MAX_HISTORY_PAGE); + model::decode_history(reply, limit) + } + Operation::Annotate => model::decode_annotation(reply), + Operation::Checkin => model::decode_checkin(reply), + Operation::Update => model::decode_update(reply), + Operation::Rollback + | Operation::FileOperation + | Operation::Resolve + | Operation::Cleanup => model::decode_empty(reply), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn all_capabilities() -> Capabilities { + model::CAPABILITIES + .iter() + .map(|(id, _)| (id.to_string(), 1)) + .collect() + } + + fn root() -> String { + std::env::temp_dir() + .join("lithe-vcs-root") + .to_string_lossy() + .into_owned() + } + + #[test] + fn every_operation_round_trips_its_name_and_uses_a_contract_capability() { + for operation in OPERATIONS { + assert_eq!(Operation::parse(operation.name()), Some(operation)); + assert!(is_vcs_method(operation.method())); + assert!( + model::is_capability(operation.capability()), + "{operation:?}" + ); + } + assert!(!is_vcs_method("workspace/executeCommand")); + assert_eq!(Operation::parse("push"), None); + } + + #[test] + fn a_plan_carries_root_tool_and_checked_arguments() { + let tool = std::env::temp_dir() + .join("svn") + .to_string_lossy() + .into_owned(); + let planned = plan( + Operation::Checkin, + &root(), + Some(&tool), + json!({ "paths": ["b", "a", "b"], "message": "Fix" }), + &all_capabilities(), + ) + .expect("planned"); + assert_eq!(planned.method, "lithe/vcs/checkin"); + assert_eq!(planned.params["paths"], json!(["a", "b"])); + assert_eq!(planned.params["root"]["path"], json!(root())); + assert_eq!(planned.params["tool"]["executable"], json!(tool)); + } + + #[test] + fn a_plan_rejects_unsafe_or_unknown_arguments() { + let capabilities = all_capabilities(); + for (operation, arguments) in [ + (Operation::Rollback, json!({ "paths": ["../x"] })), + (Operation::Rollback, json!({ "paths": [] })), + ( + Operation::Checkin, + json!({ "paths": ["a"], "message": " " }), + ), + (Operation::Status, json!({ "recursive": false })), + (Operation::History, json!({ "path": "a" })), + (Operation::FileHistory, json!({})), + (Operation::History, json!({ "limit": 0 })), + ( + Operation::FileOperation, + json!({ "kind": "move", "from": "a", "to": "a" }), + ), + (Operation::Cleanup, json!({ "force": true })), + ] { + let error = plan(operation, &root(), None, arguments.clone(), &capabilities) + .expect_err("rejected"); + assert!( + matches!(error.code, ErrorCode::InvalidRequest), + "{operation:?} {arguments}" + ); + } + assert!(plan( + Operation::Status, + "relative", + None, + Value::Null, + &capabilities + ) + .is_err()); + } + + #[test] + fn an_operation_needs_its_negotiated_capability() { + let mut capabilities = all_capabilities(); + capabilities.remove("conflicts"); + let error = plan( + Operation::Resolve, + &root(), + None, + json!({ "paths": ["a"] }), + &capabilities, + ) + .expect_err("unsupported"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert_eq!(error.details.as_deref(), Some("vcs.capability.unsupported")); + } + + #[test] + fn history_replies_are_bounded_by_the_requested_limit() { + let reply = json!({ "entries": [ + { "revision": { "id": "2" } }, { "revision": { "id": "1" } } + ] }); + assert!(decode( + Operation::History, + &json!({ "limit": 2 }), + &reply, + &all_capabilities() + ) + .is_ok()); + assert!(decode( + Operation::History, + &json!({ "limit": 1 }), + &reply, + &all_capabilities() + ) + .is_err()); + } +} diff --git a/rust/lithe-core/src/vcs/roots.rs b/rust/lithe-core/src/vcs/roots.rs new file mode 100644 index 0000000000..35148a8f23 --- /dev/null +++ b/rust/lithe-core/src/vcs/roots.rs @@ -0,0 +1,357 @@ +//! Which VCS owns which directory of a workspace. +//! +//! Roots are found from static markers only, so opening a workspace never +//! starts a provider. One bounded walk serves every provider: it records the +//! markers directly inside each directory, never descends into a VCS's own +//! metadata directory or through a symbolic link, and stops at a depth and +//! directory budget. When the workspace root itself carries no marker, its +//! ancestors are checked too, because a Subversion working copy has a single +//! `.svn` at its top and a workspace may be opened below it. +//! +//! A file belongs to the deepest root that contains it. A directory with +//! markers of several providers is ambiguous and owns nothing until the +//! user maps it to one of them; Core never guesses. + +use super::manifest::{MarkerKind, VcsMarker}; +use super::model::is_root_relative; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::fs::{self, FileType}; +use std::path::{Path, PathBuf}; + +/// Deepest directory, below the workspace root, whose markers are read. +const MAX_DEPTH: usize = 8; +/// Most directories one detection reads before reporting a partial result. +const MAX_DIRECTORIES: usize = 20_000; + +/// Directories that hold generated or downloaded files and never a working +/// copy worth tracking, and whose size would dominate the walk. Lithe's own +/// worktrees are pruned so they are not listed as nested repositories. +const PRUNED: &[&str] = &[ + ".build", + ".cargo", + ".dart_tool", + ".gradle", + ".idea", + ".lithe", + ".mypy_cache", + ".next", + ".nuxt", + ".pytest_cache", + ".stack-work", + ".svelte-kit", + ".swiftpm", + ".terraform", + ".tox", + ".venv", + ".vscode", + ".worktree", + ".worktrees", + "DerivedData", + "Pods", + "__pycache__", + "bower_components", + "node_modules", + "target", +]; + +/// The markers of one VCS, as detection sees them. +pub(crate) struct MarkerSet<'a> { + pub(crate) vcs: &'a str, + pub(crate) markers: &'a [VcsMarker], +} + +/// A detected root. `id` is workspace-relative with `/` separators: `""` is +/// the workspace root and `..` segments name an enclosing directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Root { + pub(crate) id: String, + pub(crate) vcs: String, + pub(crate) path: PathBuf, + /// Whether the user's mapping, not a single marker, chose the VCS. + pub(crate) mapped: bool, +} + +/// A directory with markers of several VCSs and no mapping. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Ambiguous { + pub(crate) id: String, + pub(crate) vcs: Vec, +} + +/// The user's choice for an ambiguous directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Mapping { + pub(crate) id: String, + pub(crate) vcs: String, +} + +#[derive(Debug, Default)] +pub(crate) struct Detection { + pub(crate) roots: Vec, + pub(crate) ambiguous: Vec, + /// Mappings that name no ambiguous directory, or a VCS whose markers + /// are not there. Hosts may offer to forget them. + pub(crate) unused_mappings: Vec, + /// Whether the directory budget cut the walk short, so a root may be + /// missing. Roots deeper than [`MAX_DEPTH`] are never looked for. + pub(crate) truncated: bool, +} + +/// Sort key that puts enclosing roots first, then inner roots by depth. +pub(crate) fn depth(id: &str) -> i64 { + if id.is_empty() { + return 0; + } + let segments = id.split('/').count() as i64; + if id.starts_with("..") { + -segments + } else { + segments + } +} + +/// Whether `id` has the shape of a root or ambiguous-directory ID: `""`, +/// only `..` segments, or a path inside the workspace. +pub(crate) fn is_directory_id(id: &str) -> bool { + id.is_empty() || id.split('/').all(|segment| segment == "..") || is_root_relative(id) +} + +/// Whether the root or ambiguous directory `id` contains the +/// workspace-relative `path`. +fn contains(id: &str, path: &str) -> bool { + id.is_empty() + || id.starts_with("..") + || path == id + || path + .strip_prefix(id) + .is_some_and(|rest| rest.starts_with('/')) +} + +/// The root ID that owns a workspace-relative path, or `None` when the path +/// is outside every root or inside an unmapped ambiguous directory. +pub(crate) fn owner<'a>(roots: &'a [String], ambiguous: &[String], path: &str) -> Option<&'a str> { + let deepest_root = roots + .iter() + .filter(|root| contains(root, path)) + .max_by_key(|root| depth(root)); + let deepest_ambiguous = ambiguous + .iter() + .filter(|directory| contains(directory, path)) + .map(|directory| depth(directory)) + .max(); + match (deepest_root, deepest_ambiguous) { + (Some(root), Some(ambiguous)) if ambiguous > depth(root) => None, + (root, _) => root.map(String::as_str), + } +} + +/// Resolves a directory entry's kind, following a link only for the +/// marker check itself (a `.git` that links elsewhere still marks a root). +fn marker_kind(path: &Path, file_type: FileType) -> Option { + let file_type = if file_type.is_symlink() { + fs::metadata(path).ok()?.file_type() + } else { + file_type + }; + if file_type.is_dir() { + Some(MarkerKind::Directory) + } else if file_type.is_file() { + Some(MarkerKind::File) + } else { + None + } +} + +fn matches(sets: &[MarkerSet<'_>], name: &str, kind: MarkerKind) -> Vec { + sets.iter() + .filter(|set| { + set.markers + .iter() + .any(|marker| marker.name == name && marker.kind == kind) + }) + .map(|set| set.vcs.to_string()) + .collect() +} + +/// VCSs whose markers sit directly in `directory`, checked by name. +fn markers_at(directory: &Path, sets: &[MarkerSet<'_>]) -> BTreeSet { + let names: BTreeSet<&str> = sets + .iter() + .flat_map(|set| set.markers.iter().map(|marker| marker.name.as_str())) + .collect(); + let mut found = BTreeSet::new(); + for name in names { + let path = directory.join(name); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if let Some(kind) = marker_kind(&path, metadata.file_type()) { + found.extend(matches(sets, name, kind)); + } + } + found +} + +/// Walks the workspace and its ancestors. `workspace` must be absolute. +pub(crate) fn detect(workspace: &Path, sets: &[MarkerSet<'_>], mappings: &[Mapping]) -> Detection { + let metadata_directories: BTreeSet<&str> = sets + .iter() + .flat_map(|set| set.markers.iter()) + .filter(|marker| marker.kind == MarkerKind::Directory) + .map(|marker| marker.name.as_str()) + .collect(); + let mut found: BTreeMap)> = BTreeMap::new(); + let mut truncated = false; + let mut visited = 0usize; + let mut queue = VecDeque::from([(workspace.to_path_buf(), String::new(), 0usize)]); + // Breadth first, children sorted by name: when the budget runs out the + // shallow roots are kept, and two runs over one tree agree. + while let Some((directory, relative, level)) = queue.pop_front() { + if visited == MAX_DIRECTORIES { + truncated = true; + break; + } + visited += 1; + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + let mut entries: Vec<_> = entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_str()?.to_string(); + Some((name, entry.path(), entry.file_type().ok()?)) + }) + .collect(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut here = BTreeSet::new(); + for (name, path, file_type) in entries { + if let Some(kind) = marker_kind(&path, file_type) { + here.extend(matches(sets, &name, kind)); + } + // Source trees are routinely deeper than the limit, so stopping + // there is the rule rather than a partial result. + if !file_type.is_dir() + || level == MAX_DEPTH + || metadata_directories.contains(name.as_str()) + || PRUNED.contains(&name.as_str()) + { + continue; + } + let child = if relative.is_empty() { + name + } else { + format!("{relative}/{name}") + }; + queue.push_back((path, child, level + 1)); + } + if !here.is_empty() { + found.insert(relative, (directory, here)); + } + } + + if !found.contains_key("") { + let mut id = String::from(".."); + let mut ancestor = workspace.parent(); + while let Some(directory) = ancestor { + let here = markers_at(directory, sets); + if !here.is_empty() { + found.insert(id, (directory.to_path_buf(), here)); + break; + } + id.push_str("/.."); + ancestor = directory.parent(); + } + } + + let mut detection = Detection { + truncated, + ..Detection::default() + }; + let mut used = BTreeSet::new(); + for (id, (path, vcs)) in found { + if vcs.len() == 1 { + let vcs = vcs.into_iter().next().unwrap_or_default(); + detection.roots.push(Root { + id, + vcs, + path, + mapped: false, + }); + continue; + } + match mappings + .iter() + .find(|mapping| mapping.id == id && vcs.contains(&mapping.vcs)) + { + Some(mapping) => { + used.insert((mapping.id.clone(), mapping.vcs.clone())); + detection.roots.push(Root { + id, + vcs: mapping.vcs.clone(), + path, + mapped: true, + }); + } + None => detection.ambiguous.push(Ambiguous { + id, + vcs: vcs.into_iter().collect(), + }), + } + } + detection.unused_mappings = mappings + .iter() + .filter(|mapping| !used.contains(&(mapping.id.clone(), mapping.vcs.clone()))) + .cloned() + .collect(); + detection + .unused_mappings + .sort_by(|left, right| (&left.id, &left.vcs).cmp(&(&right.id, &right.vcs))); + detection.unused_mappings.dedup(); + detection + .roots + .sort_by(|left, right| (depth(&left.id), &left.id).cmp(&(depth(&right.id), &right.id))); + detection + .ambiguous + .sort_by(|left, right| (depth(&left.id), &left.id).cmp(&(depth(&right.id), &right.id))); + detection +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(values: &[&str]) -> Vec { + values.iter().map(ToString::to_string).collect() + } + + #[test] + fn the_deepest_root_owns_a_path_and_ambiguity_blocks_fallback() { + let roots = ids(&["..", "", "a/b"]); + let ambiguous = ids(&["m"]); + let owner_of = |path| owner(&roots, &ambiguous, path); + assert_eq!(owner_of("README.md"), Some("")); + assert_eq!(owner_of("a/b/c.txt"), Some("a/b")); + assert_eq!(owner_of("a/bc.txt"), Some("")); + // Files below an unmapped ambiguous directory belong to nobody, not + // to the enclosing Git root. + assert_eq!(owner_of("m/x.txt"), None); + assert_eq!(owner_of("mx.txt"), Some("")); + } + + #[test] + fn enclosing_roots_sort_before_inner_ones() { + let mut ids = vec!["a/b", "", "../..", "a", ".."]; + ids.sort_by_key(|id| (depth(id), *id)); + assert_eq!(ids, ["../..", "..", "", "a", "a/b"]); + } + + #[test] + fn directory_ids_are_enclosing_or_inside_the_workspace() { + for id in ["", "..", "../..", "a", "a/b"] { + assert!(is_directory_id(id), "{id}"); + } + for id in ["/a", "../a", "a/..", "a//b", "./a", "a\\b"] { + assert!(!is_directory_id(id), "{id}"); + } + } +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index ba81f07cdf..20edf77944 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -188,8 +188,8 @@ contains a machine path, and the host decides where state lives. | `${setting:}` | Current value of a declared [plugin setting](#plugin-settings-api-2). Not available in `validationArguments`, and never inside a package path. | `plugins.declarative.languageServerLaunchPlan` expands a server's launch. The -host selects the process with exactly one of `languageID` (a language server) -and `buildSystemID` (a build provider), passes `{pluginDirectory, +host selects the process with exactly one of `languageID` (a language server), +`buildSystemID` (a build provider), and `vcsProviderID` (a VCS provider), passes `{pluginDirectory, stateDirectory, workspaceStateDirectory, architecture, settings?}`, and receives: @@ -204,9 +204,11 @@ receives: | `requiredPackagePaths` | Sorted package-relative paths the launch points at. The host verifies each is inside the package and not a symbolic link before starting the server. | | `settings` | Effective value of every declared setting that produced this plan; absent when the plugin declares none. | | `buildProvider` | Present only for a `buildSystemID` selection: `{buildSystemID, executesProjectCode, mayUseNetwork, languageServer?}`. `languageServer` names the language whose server is the provider; the rest of the plan is then that server's plan, and the host reuses its running session instead of starting a second process. | +| `vcsProvider` | Present only for a `vcsProviderID` selection: the [provider descriptor](../../vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | -A language without a server, an undeclared build system, and a build system -without a provider are `plugin.launch.unavailable`. Core never creates or +A language without a server, an undeclared build system, a build system +without a provider, and an undeclared VCS provider are +`plugin.launch.unavailable`. Core never creates or reads any of these paths. The command also serves API 1 manifests, whose arguments are returned unchanged. @@ -359,7 +361,7 @@ files sorted by path, or an empty list when no project marker is present. | --- | --- | | `1` | Languages, `packaged` and `systemCommand` language servers, `node` and `go` runtime dependencies, run and test providers. | | `2` | `runtime` launches with a packaged or discovered runtime, host integrations, the `java` runtime, launch templates, `initializationOptions`, and plugin settings. | -| `3` | Capability versions (`requires`), and `buildSystems`: declarative build tools with detection, options, a project model, tasks, run entries, tests, diagnostics, dependencies, and sync. | +| `3` | Capability versions (`requires`), `buildSystems`: declarative build tools with detection, options, a project model, tasks, run entries, tests, diagnostics, dependencies, and sync, and [`vcsProviders`](#api-level-3-vcs-providers). | A level 2 feature in a level 1 manifest is refused with `plugin.manifest.invalid` (`plugin.runtime.unsupported` for the `java` tool), @@ -383,12 +385,13 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | --- | --- | --- | | `languages` | `languages[]` | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | -| `serverProcess` | The launch fields of language servers and of build providers with a process of their own: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | +| `serverProcess` | The launch fields of language servers, of build providers with a process of their own, and of VCS providers: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | | `executionProviders` | `executionProviders[]` | `1` | | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | | `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers | `1` | +| `vcsProviders` | `vcsProviders[]` | `1` | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -409,7 +412,8 @@ moving a level 2 manifest to level 3 only adds `requires`. `buildSystems[].provider` requires `buildProviders: 1`, whether it starts its own process or shares a language server. A template-only build plugin must omit `buildProviders`. Launch support alone does not implement Provider - negotiation, requests, and lifecycle. + negotiation, requests, and lifecycle. Every VCS provider starts its own + process, so `vcsProviders` always comes with `serverProcess`. - The base fields (`schemaVersion`, `id`, `displayName`, `version`, `vendor`, `permissions`, `activationEvents`, and `requires` itself) change only with a new `pluginAPIVersion`. @@ -434,8 +438,10 @@ The macOS host runs levels 1 to 3 and sends its capability table, including `buildSystems: [1]`, with all three commands. It does not implement build providers and does not declare `buildProviders`. Provider plugins are therefore filtered from the market and refused by declarative validation and install -review; template-only build plugins remain available. Core supports -`buildProviders: 1` for validation and plans without a host table. Windows has +review; template-only build plugins remain available. It does not declare +`vcsProviders` either, so VCS provider plugins are filtered the same way until +the macOS host starts providers. Core supports `buildProviders: 1` and +`vcsProviders: 1` for validation and plans without a host table. Windows has no declarative plugin host and sends no `hostCapabilities`, so it offers no level 3 version. @@ -464,10 +470,11 @@ Rules for publishing across levels and capability versions: | Permission | Required when | | --- | --- | -| `languageServer.launch` | `languageServers` is not empty | +| `languageServer.launch` | `languageServers` or `vcsProviders` is not empty, or a build provider starts its own process | | `process.run` | any provider declares `run` | | `process.test` | any provider declares `test` | | `process.build` | `buildSystems` is not empty | +| `process.vcs` | `vcsProviders` is not empty | | `ui.contribute` | `ui` is declared | | `ui.web` | a tool window shows [web content](#web-content-ui-2) | @@ -477,6 +484,7 @@ Rules for publishing across levels and capability versions: | `onRun:` | Activate the run provider when the user runs a file of the language. | | `onTest:` | Activate the test provider when the user lists or runs tests. | | `onBuildSystem:` | Activate the build system before its first plan, model, or decode. | +| `onVcsRoot:` | Start the VCS provider once a root of its VCS is detected in the workspace. | Each provider needs its event and each event needs a provider. Enabling a plugin only permits activation; nothing starts until its event occurs. @@ -613,7 +621,8 @@ the plugin, sorted by `kind`, language, and tool: | `runtimeLanguageServer` | `languageID`, `tool`, `packagedRuntime`, `entry`, `runtimeArguments`, `arguments`, `validationArguments` | Package entry started by a runtime; `packagedRuntime` is `null` for a discovered runtime | | `buildSystem` | `buildSystemID`, `invocation`, `options`, `tasks`, `run`, `test`, `model`, `dependencies`, `hostIntegration`?, `provider`? | Every command line the build system can plan; `provider` is the shared form, which sends build requests to a language server | | `packagedBuildProvider`, `systemBuildProvider`, `runtimeBuildProvider` | the matching language-server fields with `buildSystemID`, `executesProjectCode`, `mayUseNetwork` instead of `languageID` | A build provider with a process of its own | -| `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server or build provider | +| `packagedVcsProvider`, `systemVcsProvider`, `runtimeVcsProvider` | the matching language-server fields with `vcs`, `vcsTool`, `mayUseNetwork` instead of `languageID` | A VCS provider process; `vcsTool` is the declared VCS client or `null` | +| `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server, build provider, or VCS provider | | `run` | `languageID`, `executable`, `arguments` | Run command and its argument template | | `test` | `languageID`, `executable`, `workspaceArguments`, `fileArguments`, `testCaseArguments` | Test command and its argument templates | @@ -952,6 +961,25 @@ digests the watched files. A selection the declaration does not allow is `plugin.build.invalidSelection`; an operation it does not declare is `plugin.build.unavailable`. +## API level 3: VCS providers + +`vcsProviders[]` declares version control systems the plugin implements, each +through its own LSP base-protocol process: the VCS ID, the static markers that +identify a root, metadata paths the host watches, the VCS client the provider +drives, and the shared [`serverProcess`](#capability-versions-level-3) launch +fields. The ID `git` is reserved for the built-in support +(`plugin.vcs.reserved`). Capabilities belong to the running process and are +negotiated in `initialize` under `experimental.litheVcs`, never declared in +the manifest. + +Detection never starts a process: `vcs.detectRoots` finds roots from the +markers, and the host starts a provider only after a root of its VCS is +found. The install review lists the provider as a `…VcsProvider` program with +its VCS client (`vcsTool`) and `mayUseNetwork`. Requests go through +`lsp.vcsRequest` by operation name and replies are normalized by +`vcs.decodeReply`. The fields, operations, reply shapes, and error codes are +defined in [the VCS provider contract](../../vcs-provider-v1.md). + ## Plugin functions A plugin backend (a language server or build provider process the plugin @@ -1151,7 +1179,7 @@ valid. ## Outcomes and error codes -`plugins.declarative.*`, `plugins.marketplace.*`, and `build.*` commands return `{"status": "ok", …}` or +`plugins.declarative.*`, `plugins.marketplace.*`, `build.*`, and `vcs.*` commands return `{"status": "ok", …}` or `{"status": "rejected", "error": {"code", "message", "field"?}}`. A malformed command payload is a Core `invalid_request` failure instead. @@ -1170,11 +1198,13 @@ command payload is a Core `invalid_request` failure instead. | `plugin.test.unsupportedProject` | Core | No project marker in the workspace | | `plugin.execution.unavailable` | Core | The plugin has no run/test provider for the language | | `plugin.integration.unsupported` | Core | A host integration the host does not implement, or one requested by a plugin outside the official namespace | -| `plugin.launch.unavailable` | Core | The plugin has no language server for the language, or no provider process for the build system | +| `plugin.launch.unavailable` | Core | The plugin has no language server for the language, no provider process for the build system, or no such VCS provider | | `plugin.build.unavailable` | Core | The plugin declares no such build system, or the build system does not declare the requested operation | | `plugin.build.invalidSelection` | Core | A build request selects a scope, target, filter, or argument the declaration does not allow, or misses a required path | | `plugin.build.invalidOutput` | Core | Build-tool output or a report does not fit its decoder | | `plugin.launch.invalidRequest` | Core | A launch plan request has an unusable directory or an unknown architecture | +| `plugin.vcs.reserved` | Core | A VCS provider claims the ID of the built-in Git support | +| `plugin.vcs.invalidOutput` | Core | A VCS provider's reply does not fit the [VCS provider contract](../../vcs-provider-v1.md) | | `plugin.index.invalid` | Core | Malformed index root or entry, or an invalid URL, checksum, platform, version, or date | | `plugin.index.schemaUnsupported` | Core | The index `schemaVersion` is not `1` | | `plugin.index.officialNamespace` | Core | A third-party source lists a `dev.lithe` ID | diff --git a/shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json new file mode 100644 index 0000000000..690c224122 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.svn-provider", + "displayName": "Subversion Provider Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.vcs" + ], + "activationEvents": [ + "onVcsRoot:svn" + ], + "languages": [], + "vcsProviders": [ + { + "id": "svn", + "displayName": "Subversion", + "detection": { + "markers": [{ "name": ".svn", "type": "directory" }] + }, + "metadataPaths": [".svn/wc.db"], + "tool": { + "commands": ["svn"], + "validationArguments": ["--version", "--quiet"] + }, + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "provider/svn-provider.mjs" + }, + "arguments": ["--stdio"], + "mayUseNetwork": true + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 1b45e5dadd..80fb73f45a 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -26,9 +26,10 @@ "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, - "serverProcess": { "description": "The launch fields of language servers and of build providers that start their own process.", "enum": [1] }, + "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, and of VCS providers.", "enum": [1] }, "settings": { "enum": [1] }, - "ui": { "description": "Version 2 adds web content in tool windows.", "enum": [1, 2] } + "ui": { "description": "Version 2 adds web content in tool windows.", "enum": [1, 2] }, + "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] } } }, "vendor": { @@ -43,11 +44,11 @@ }, "permissions": { "type": "array", - "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "ui.contribute", "ui.web"] } + "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "ui.contribute", "ui.web"] } }, "activationEvents": { "type": "array", - "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem):[a-z0-9.+_-]+$" } + "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot):[a-z0-9.+_-]+$" } }, "languages": { "type": "array", @@ -68,6 +69,12 @@ "maxItems": 16, "items": { "$ref": "#/$defs/buildSystem" } }, + "vcsProviders": { + "description": "pluginAPIVersion 3: version control systems the plugin provides, each through its own LSP base-protocol process. Requires languageServer.launch, process.vcs, and one onVcsRoot: activation event per provider.", + "type": "array", + "maxItems": 8, + "items": { "$ref": "#/$defs/vcsProvider" } + }, "ui": { "type": "object", "additionalProperties": false, @@ -361,7 +368,7 @@ } }, "serverProcess": { - "description": "The launch fields a language server and a pluginAPIVersion 3 build provider share; both are LSP base-protocol processes and pass the same validation.", + "description": "The launch fields a language server, a pluginAPIVersion 3 build provider, and a VCS provider share; all are LSP base-protocol processes and pass the same validation.", "type": "object", "required": ["launch"], "properties": { @@ -451,6 +458,66 @@ } ] }, + "vcsProvider": { + "description": "pluginAPIVersion 3: a VCS provider process (shared/contracts/vcs-provider-v1.md). Detection uses the markers only and never starts the process; capabilities are negotiated, not declared.", + "type": "object", + "$ref": "#/$defs/serverProcess", + "unevaluatedProperties": false, + "required": ["id", "displayName", "detection", "mayUseNetwork"], + "properties": { + "id": { + "description": "Stable VCS ID; git is reserved for the built-in support.", + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "not": { "const": "git" } + }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 64 }, + "detection": { + "type": "object", + "additionalProperties": false, + "required": ["markers"], + "properties": { + "markers": { + "description": "Entries directly inside a root that mark it.", + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^/\\\\]+$", "not": { "enum": [".", ".."] } }, + "type": { "enum": ["directory", "file"] } + } + } + } + } + }, + "metadataPaths": { + "description": "Root-relative paths the host watches; a change means the status may be stale.", + "type": "array", + "maxItems": 16, + "items": { "type": "string", "minLength": 1 } + }, + "tool": { + "description": "The VCS client the provider drives. The host resolves it like a systemCommand launch and passes its absolute path with every request.", + "type": "object", + "additionalProperties": false, + "required": ["commands"], + "properties": { + "commands": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "type": "string", "pattern": "^[^/\\\\\\s]+$" } + }, + "validationArguments": { "type": "array", "items": { "type": "string" } } + } + }, + "mayUseNetwork": { "type": "boolean" } + } + }, "minimumVersion": { "description": "pluginAPIVersion 2: one to three dot-separated numbers, for example 21 or 18.17.", "type": "string", diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index bd19767c07..bab8297a79 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -836,6 +836,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.request` | Submit a typed semantic request and return an opaque operation ID | | `lsp.buildRequest` | Forward one planned `lithe/build/*` request to a session that negotiated the build-provider protocol | | `lsp.functionRequest` | Invoke one registered plugin function on a session its plugin started | +| `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | | `java.navigationMarkers` | Resolve versioned Java gutter markers from bounded JDT LS semantic requests | | `java.resolveNavigation` | Resolve one Java gutter marker to normalized parent or implementation locations | | `lsp.cancelOperation` | Cancel one pending semantic operation | @@ -911,7 +912,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `plugins.declarative.runPlan` | Expand a declarative plugin's run template for one workspace file | | `plugins.declarative.discoverTests` | List a declarative plugin's workspace and file test items | | `plugins.declarative.testPlan` | Expand a declarative plugin's test template for a workspace, file, or test case | -| `plugins.declarative.languageServerLaunchPlan` | Expand a declarative plugin's language-server or build-provider launch with host directories and architecture | +| `plugins.declarative.languageServerLaunchPlan` | Expand a declarative plugin's language-server, build-provider, or VCS-provider launch with host directories and architecture | | `plugins.declarative.resolveSettings` | Merge a plugin's stored global and project setting layers into effective values with their source | | `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | | `plugins.declarative.checkRuntimeVersion` | Decide whether a launch runtime's version output meets the declared minimum version | @@ -929,6 +930,9 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.decodeDependencies` | Decode dependency output or a provider result into one project's bounded tree | | `build.syncFingerprint` | Digest the files a build model depends on, to tell whether it is stale | | `build.languageImport` | Map a build system's options to the Java language server's Maven or Gradle project import | +| `vcs.detectRoots` | Find the VCS roots of a workspace from static markers, with registered providers, ambiguous directories, and applied mappings | +| `vcs.ownerOf` | Assign workspace paths to the detected root that owns each | +| `vcs.decodeReply` | Validate a VCS provider's reply and return the normalized result | | `plugins.marketplace.validateIndex` | Validate a marketplace index and report each plugin's availability for one platform | | `plugins.marketplace.resolveSource` | Resolve a user-entered marketplace source to the index URL to fetch | | `plugins.marketplace.installReview` | List a package's permissions and programs, and those not yet approved | @@ -2350,6 +2354,23 @@ event carries the provider's reply unchanged as `result.value`, which the host passes to `build.decodeModel` or `build.decodeDependencies` as `result`; cancellation and late results follow `lsp.cancelOperation`. +`lsp.vcsRequest` accepts `{ sessionId, operationId?, vcs, operation, root, +tool?, arguments?, timeoutMilliseconds? }` and returns `{ operationId }`. +`operation` is a [VCS provider contract](vcs-provider-v1.md#lspvcsrequest) +operation name, never a method; Core checks `arguments`, adds the absolute +`root` and `tool` paths, and writes the operation's `lithe/vcs/*` request. +Only plugin-owned sessions offer `capabilities.experimental.litheVcs: +{ versions: [1] }`; a session whose process did not answer with a VCS ID and +every required capability, or answered another VCS than `vcs`, refuses the +request with `not_supported` and details `vcs.provider.unavailable`, and an +operation whose capability was not negotiated is `not_supported` with details +`vcs.capability.unsupported`. A successful negotiation emits a `vcsProvider` +runtime event whose `result` is `{ vcs, capabilities }`. The +`requestCompleted` event carries the provider's reply as `result.value`, which +the host passes to `vcs.decodeReply`; a failed request carries a `vcs.*` +`error.code` (the provider's own code only when the contract lists it). +Timeout, cancellation, and late results follow `lsp.buildRequest`. + A plugin-owned session started with `workspaceConfiguration` answers each `workspace/configuration` item from it by `section` (an exact key wins over a dotted path; no section returns the whole object, a missing one `null`) and @@ -3128,6 +3149,26 @@ inside `workspaceRoot` (and the given `outputFile`); none of the commands writes anything or starts a process. `shared/contracts/plugins/v1/fixtures/build-plans.json` holds the golden plans and decoded outputs. +### VCS providers + +The `vcs.*` commands implement API level 3 `vcsProviders` of +[`plugins/v1`](plugins/v1/README.md) and the +[VCS provider contract](vcs-provider-v1.md). Results use the +`plugins.declarative.*` envelope; a malformed payload, a relative +`workspaceRoot`, or an unknown operation is an `invalid_request` failure. + +| Command | Request | Result | +| --- | --- | --- | +| `vcs.detectRoots` | `{ workspaceRoot, plugins: [{ manifest }], mappings?: [{ id, vcs }] }` | `detection`: `{ providers, roots: [{ id, vcs, path, mapped }], ambiguous: [{ id, vcs }], unusedMappings, truncated, rejected: [{ index, error }], conflicts: [{ pluginID, vcs, by }] }` | +| `vcs.ownerOf` | `{ roots: [id], ambiguous?: [id], paths: [path] }` | `owners`: `[{ path, root }]` in request order; `root` is `null` outside every root or inside an unmapped ambiguous directory | +| `vcs.decodeReply` | `{ operation, capabilities, request?, reply }` | `result`: the normalized reply, or `plugin.vcs.invalidOutput` | + +`vcs.detectRoots` reads directory entries below `workspaceRoot` (at most eight +levels and 20,000 directories) and the marker names in its ancestors; it never +reads file contents, writes anything, or starts a process. The other two +commands touch no file. `shared/fixtures/vcs/model-v1.json` holds the +detection cases, decoded snapshots, and reply validation cases. + ### Git patch discovery and rebase amendment preconditions `git.patchExport` accepts optional `metadataOnly` (default false). In metadata diff --git a/shared/contracts/vcs-provider-v1.md b/shared/contracts/vcs-provider-v1.md index 3d80008eb8..cb271575e9 100644 --- a/shared/contracts/vcs-provider-v1.md +++ b/shared/contracts/vcs-provider-v1.md @@ -1,61 +1,251 @@ -# VCS provider contract v1 (draft) +# VCS provider contract v1 -> **Status: draft, not implemented.** No Core command, plugin manifest field, -> host capability, or provider method below is accepted by any Lithe build yet. -> Names and fields may change until phase P1 lands. The decision record is +> **Status: Core implemented (P1); no host uses it yet.** Core accepts the +> `vcsProviders` manifest capability and the commands below. The macOS and +> Windows hosts do not start providers yet, so no build shows a plugin VCS. +> The decision record is > [Unified VCS model and providers](../../.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md); -> example data is in [`shared/fixtures/vcs/model-v1.json`](../fixtures/vcs/model-v1.json). +> example data is in [`shared/fixtures/vcs/model-v1.json`](../fixtures/vcs/model-v1.json) +> and [`plugins/v1/fixtures/svn-provider.plugin.json`](plugins/v1/fixtures/svn-provider.plugin.json). This contract defines one VCS-neutral model for the features every version control system has: roots, file status, base content, history, annotation, -check-in, rollback, and update. A **provider** implements it for one VCS. Git is -a built-in provider inside Core that adapts the existing `git.*` operations; -other systems (Subversion first) are plugin providers that run as LSP -base-protocol processes, the same process kind as -[build providers](plugins/v1/README.md#build-providers). +check-in, rollback, and update. A **provider** implements it for one VCS as a +plugin process speaking the LSP base protocol, the same process kind as +[build providers](plugins/v1/README.md#build-providers). Subversion is the +first provider. -`git.*` commands, fields, and fixtures are unchanged by this contract. Features -that only exist in one VCS (Git log graph, rebase, worktrees, stash, push -dialog; Subversion locks UI, properties editor, relocate) stay in that -provider's own module or plugin and are not modeled here. +Git is not part of this layer yet. Core recognizes `.git` markers so a nested +working copy is assigned to the right root, and the `git.*` commands keep +serving Git roots unchanged. Features that only exist in one VCS (Git log +graph, rebase, worktrees, stash; Subversion locks, properties, relocate) are +not modeled here. ## Shared rules -- UTF-8 JSON; deterministic ordering of every list. -- Paths inside a root are root-relative with `/` separators. A root's own - identity is a workspace-relative `id` (`..` allowed for enclosing roots); its - native `path` is a platform binding, never a portable identity. -- Line numbers are one-based; missing values are `null`. +- UTF-8 JSON; every list has a deterministic order. +- Paths inside a root are root-relative with `/` separators and no empty, + `.`, or `..` segment. A root's identity is a workspace-relative `id` (`""` + for the workspace, `..` segments for an enclosing directory); its native + `path` is a platform binding, never a portable identity. +- Missing values are `null`. - Hosts never parse or order revision IDs; only the provider compares them. -- Remote locations (Subversion URLs, Git remotes) are display data with user - info, query, and fragment removed before they leave Core. +- Remote locations are display data; Core removes user info, query, and + fragment before they leave it. - Secrets never appear in arguments, results, console events, or logs. -## Model +## Manifest (plugin API level 3) -### Provider descriptor +```json +{ + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "permissions": ["languageServer.launch", "process.vcs"], + "activationEvents": ["onVcsRoot:svn"], + "vcsProviders": [ + { + "id": "svn", + "displayName": "Subversion", + "detection": { "markers": [{ "name": ".svn", "type": "directory" }] }, + "metadataPaths": [".svn/wc.db"], + "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, + "launch": { "kind": "runtime", "tool": "node", "minimumVersion": "20.3", "entry": "provider/svn-provider.mjs" }, + "arguments": ["--stdio"], + "mayUseNetwork": true + } + ] +} +``` + +| Field | Rule | +| --- | --- | +| `id` | `[a-z][a-z0-9-]{0,31}`; unique in the plugin. `git` is reserved (`plugin.vcs.reserved`). | +| `displayName` | One line of 1-64 characters. | +| `detection.markers` | 1-8 entries directly inside a root; `type` is `directory` or `file`. | +| `metadataPaths` | Up to 16 root-relative paths the host watches to refresh status. | +| `tool` | Optional. Bare command names the host resolves like a `systemCommand` launch, and an optional probe. | +| `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | The shared server-process fields. | +| `mayUseNetwork` | Shown in the install review. | + +A plugin declares at most 8 providers. A non-empty `vcsProviders` requires the +`languageServer.launch` and `process.vcs` permissions, the activation event +`onVcsRoot:` for every provider, and `requires.vcsProviders` and +`requires.serverProcess`. Capabilities are not declared in the manifest: they +belong to the running process and are negotiated. + +The install review lists one program per provider, of kind +`packagedVcsProvider`, `systemVcsProvider`, or `runtimeVcsProvider`, with +`vcs`, `vcsTool` (the declared `tool`), and `mayUseNetwork`, plus the +runtime it needs. Changing the tool or the launch is a new program the user +approves again. + +`plugins.declarative.languageServerLaunchPlan` with `vcsProviderID` plans +the process and adds `vcsProvider`, the descriptor below. + +## Provider descriptor + +```json +{ + "vcs": "svn", + "displayName": "Subversion", + "origin": { "kind": "plugin", "pluginID": "dev.lithe.plugin.svn-support" }, + "markers": [{ "name": ".svn", "type": "directory" }], + "metadataPaths": [".svn/wc.db"], + "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, + "mayUseNetwork": true +} +``` + +The built-in Git entry is `{ vcs: "git", displayName: "Git", origin: { kind: +"builtIn" }, markers }` with the `.git` directory and file markers. + +## Core commands + +| Command | Purpose | +| --- | --- | +| `vcs.detectRoots` | Providers, roots, ambiguous directories, and applied mappings of a workspace | +| `vcs.ownerOf` | The root that owns each workspace path | +| `lsp.vcsRequest` | Sends one operation to a negotiated provider session | +| `vcs.decodeReply` | Validates a provider's reply and returns the normalized result | + +### `vcs.detectRoots` + +```json +{ + "workspaceRoot": "/abs/workspace", + "plugins": [{ "manifest": { } }], + "mappings": [{ "id": "mixed", "vcs": "svn" }] +} +``` + +Core validates every manifest again; a rejected one is listed as +`rejected: [{ index, error }]` and the rest still count. When two plugins claim +one VCS, an official plugin wins, then the lower plugin ID; the loser is listed +in `conflicts: [{ pluginID, vcs, by }]`. A relative `workspaceRoot` fails the +call. + +```json +{ + "status": "ok", + "detection": { + "providers": [ ], + "roots": [{ "id": "services/legacy", "vcs": "svn", "path": "/abs/workspace/services/legacy", "mapped": false }], + "ambiguous": [{ "id": "mixed", "vcs": ["git", "svn"] }], + "unusedMappings": [], + "truncated": false, + "rejected": [], + "conflicts": [] + } +} +``` + +Roots come from static markers; detection never starts a process. One +breadth-first walk reads the markers directly inside each directory down to +eight levels below the workspace, at most 20,000 directories. It never enters a +marker directory (`.git`, `.svn`), a symbolic link, or a dependency or build +directory such as `node_modules`, `target`, or `.worktrees`. When the workspace +itself has no marker, its ancestors are checked, because a Subversion working +copy has one `.svn` at its top; the enclosing root's `id` is `..`, `../..`, …. +`truncated` is `true` only when the directory budget ran out, so a root may be +missing. + +A directory with markers of several VCSs is ambiguous until a mapping names +one of them; Core never guesses. Mappings that name no ambiguous directory, or +a VCS whose markers are not there, are returned in `unusedMappings`. Roots and +ambiguous directories are ordered by depth (enclosing first), then `id`. + +### `vcs.ownerOf` + +```json +{ "roots": ["", "services/legacy"], "ambiguous": ["mixed"], "paths": ["README.md", "mixed/a.txt"] } +``` + +returns `{ status: "ok", owners: [{ path, root }] }` in request order. A path +belongs to the deepest root that contains it; inside an unmapped ambiguous +directory it belongs to no root (`null`), not to an enclosing one. At most +100,000 paths per call. + +### `lsp.vcsRequest` ```json { - "vcs": "git", - "displayName": "Git", - "origin": { "kind": "builtIn" }, - "capabilities": { "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "push": 1, "staging": 1 } + "sessionID": "…", + "operationID": "…", + "vcs": "svn", + "operation": "checkin", + "root": "/abs/workspace/services/legacy", + "tool": "/opt/homebrew/bin/svn", + "arguments": { "paths": ["src/Main.java"], "message": "Fix" }, + "timeoutMilliseconds": 600000 } ``` -`vcs` is a stable lowercase ID (`git`, `svn`, `hg`, …). Plugin providers report -`origin: { "kind": "plugin", "pluginID": "" }`. Two installed providers may -not claim the same `vcs`; the second is reported as a conflict and not -registered. +The host names an operation, never a method, so a session cannot be used as a +generic JSON-RPC tunnel. Core refuses the request before writing it when the +session did not negotiate a provider for `vcs` +(`notSupported`, details `vcs.provider.unavailable`), when the provider did not +negotiate the operation's capability (`notSupported`, details +`vcs.capability.unsupported`), or when the arguments are malformed +(`invalidRequest`). `timeoutMilliseconds` is capped at one hour; the session's +request timeout applies when it is absent. The result arrives as a +`requestCompleted` event with `result: { value: }`; a failure carries +one of the error codes below. + +| Operation | Method | Capability | Arguments | +| --- | --- | --- | --- | +| `status` | `lithe/vcs/status` | `status` | `{ includeIgnored? }` | +| `baseContent` | `lithe/vcs/baseContent` | `baseContent` | `{ path }` | +| `history` | `lithe/vcs/history` | `history` | `{ cursor?, limit? }` | +| `fileHistory` | `lithe/vcs/fileHistory` | `fileHistory` | `{ path, cursor?, limit? }` | +| `annotate` | `lithe/vcs/annotate` | `annotate` | `{ path }` | +| `checkin` | `lithe/vcs/checkin` | `checkin` | `{ paths, message }` | +| `rollback` | `lithe/vcs/rollback` | `rollback` | `{ paths }` | +| `update` | `lithe/vcs/update` | `update` | `{ revision? }` | +| `fileOperation` | `lithe/vcs/fileOperation` | `fileOperations` | `{ kind: "add" \| "delete", paths }` or `{ kind: "move", from, to }` | +| `resolve` | `lithe/vcs/resolve` | `conflicts` | `{ paths }` | +| `cleanup` | `lithe/vcs/cleanup` | `cleanup` | `{}` | + +`paths` holds 1-10,000 root-relative paths; Core sorts and deduplicates them. +A history page has 1-500 entries (100 by default). A check-in message is +non-empty text of at most 1 MiB. Unknown argument fields fail. + +Core adds `root: { path }` and `tool: { executable } | null` to the params. +The provider receives, for example: + +```json +{ "paths": ["src/Main.java"], "message": "Fix", "root": { "path": "/abs/…/legacy" }, "tool": { "executable": "/opt/homebrew/bin/svn" } } +``` + +### `vcs.decodeReply` + +```json +{ "operation": "status", "capabilities": { "status": 1, "…": 1 }, "request": { }, "reply": { } } +``` + +`capabilities` is what the session reported in its `vcsProvider` event, +`request` the arguments the host sent, and `reply` the `value` of the +completed request. The result is `{ status: "ok", result }` or +`{ status: "rejected", error: { code: "plugin.vcs.invalidOutput", … } }`. -`capabilities` maps capability IDs to the negotiated version. A missing key -means unsupported; the host hides the entry point. UI behavior is chosen from -capabilities only, never from `vcs`. +## Negotiation + +The host offers `capabilities.experimental.litheVcs: { "versions": [1] }` in +`initialize`, to plugin-owned sessions only. The provider answers: + +```json +{ "litheVcs": { "version": 1, "vcs": "svn", "capabilities": { "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "update": 1 } } } +``` + +A session is a provider only when the answer names an offered version, a valid +VCS ID, and every required capability. Unknown capabilities, and known ones at +another version, are ignored. Core then publishes a runtime event of kind +`vcsProvider` with `result: { vcs, capabilities }`; the host compares `vcs` +with the manifest entry it launched and stops a session that answered another +one. A session without a valid answer receives no `lithe/vcs/*` request. | Capability | Required | Meaning | | --- | --- | --- | -| `status` | yes | File status for a root | +| `status` | yes | File status of a root | | `baseContent` | yes | Content of a file at its base revision (Diff, gutter) | | `checkin` | yes | Record selected changes as a new revision | | `rollback` | yes | Discard local changes of selected files | @@ -65,26 +255,17 @@ capabilities only, never from `vcs`. | `update` | no | Bring remote revisions into the working copy | | `push` | no | Check-in is local; publishing is a separate step | | `staging` | no | Changes carry a staged/unstaged dimension | -| `fileOperations` | no | Add/move/delete must go through the VCS to be tracked | +| `fileOperations` | no | Add, move, and delete must go through the VCS to be tracked | | `conflicts` | no | Report and resolve conflicted files | -| `ignore` | no | Add paths to the VCS ignore list | -| `lock` | no | Lock/unlock files and report lock owners | | `cleanup` | no | Recover an interrupted working copy | -A provider without `push` commits directly to the server; the host labels the -action accordingly and does not offer "Commit and Push". +UI behavior is chosen from capabilities only, never from `vcs`. A provider +without `push` commits directly to the server; the host labels the action +accordingly and does not offer "Commit and Push". Version 1 has no operation +for `push`: a provider that negotiates it tells the host the VCS has a +publishing step, which a later version will model. -### Root - -```json -{ "id": "services/legacy", "vcs": "svn", "path": "", "location": "https://svn.example.invalid/repo/trunk" } -``` - -Core detects roots from static markers without starting a provider. A file -belongs to the deepest root that contains it. A directory that matches markers -of two providers is reported as `ambiguous` and stays unassigned until the user -maps it; Core never guesses. Roots are ordered by workspace containment, depth, -then `id`. +## Replies ### Revision @@ -92,17 +273,17 @@ then `id`. { "id": "1234", "display": "r1234", "kind": "number" } ``` -`kind` is `hash`, `number`, or `opaque` and only affects presentation (for -example monospace hashes). `id` is passed back to the provider unchanged. +`kind` is `hash`, `number`, or `opaque` (the default) and only affects +presentation. `display` defaults to `id`. `id` is passed back to the provider +unchanged. -### Status snapshot +### `status` ```json { - "root": "services/legacy", "head": { "revision": { "id": "1234", "display": "r1234", "kind": "number" }, "branch": null, "location": "https://svn.example.invalid/repo/trunk" }, "changes": [ - { "path": "src/Main.java", "oldPath": null, "status": "modified", "flags": ["propertiesModified"] } + { "path": "src/Main.java", "oldPath": null, "status": "modified", "flags": ["locked", "propertiesModified"] } ], "truncated": false } @@ -110,122 +291,71 @@ example monospace hashes). `id` is passed back to the provider unchanged. `status` is one of `modified`, `added`, `deleted`, `missing` (removed from disk without telling the VCS), `renamed`, `copied`, `replaced`, `typeChanged`, -`unversioned`, `ignored`, `conflicted`, `switched`. A plugin reply with any -other value fails with `plugin.vcs.invalidOutput`. +`unversioned`, `ignored`, `conflicted`, `switched`; any other value is invalid. +Only `renamed` and `copied` may name an `oldPath`. -`flags` refine a status: `propertiesModified`, `locked`, `lockedByOther`, -`treeConflict`, `external`, `submodule`. Core drops unknown flags so a newer -provider can add optional data within version 1. +`flags` refine a status: `external`, `locked`, `lockedByOther`, +`propertiesModified`, `submodule`, `treeConflict`. Core drops unknown flags so +a newer provider can add optional data within version 1, and sorts the rest. -Providers with `staging` add `staging: "staged" | "unstaged" | "partial"` to each -change. Changelist membership is host state and never part of a provider -reply. +A provider that negotiated `staging` gives every change +`staging: "staged" | "unstaged" | "partial"`; any other provider gives none. +Changelist membership is host state and never part of a reply. -`changes` is ordered by `path`; Core sorts plugin replies, so a provider need -not. Core caps a snapshot at 100,000 changes and sets `truncated: true`; a -truncated snapshot must not be presented as complete. +Core sorts `changes` by `path` and rejects duplicates. It caps a snapshot at +100,000 changes and sets `truncated: true`, as does a provider that stopped +early; a truncated snapshot must not be presented as complete. -Core rejects a plugin reply with `plugin.vcs.invalidOutput` when a path is -absolute, contains a `..` segment, or otherwise resolves outside the root, or -when a change carries `staging` although the provider did not negotiate the -`staging` capability. +### Other operations -## Core commands (planned) - -| Command | Purpose | -| --- | --- | -| `vcs.providers` | Registered providers, their origin and negotiated capabilities | -| `vcs.roots` | Detected roots, ambiguous directories, and applied user mappings | -| `vcs.status` | Status snapshot of one root | -| `vcs.baseContent` | Base-revision content of one file | -| `vcs.history` / `vcs.fileHistory` | Paged history with an opaque cursor | -| `vcs.annotate` | Per-line revision of one file | -| `vcs.checkin` / `vcs.rollback` / `vcs.update` | Mutations, with the existing execution-event lifecycle | -| `vcs.fileOperation` | Track an add, move, or delete made in the project tree | - -For the built-in Git provider Core executes the request itself. For a plugin -provider, Core follows the build-provider pattern: it returns -`provider: { method, params }`, the host sends it through the negotiated LSP -session, and Core decodes and validates the reply into the same result shape. -Exact plan/decode command names are fixed in P1. +| Operation | Reply | Rules | +| --- | --- | --- | +| `baseContent` | `{ content: string \| null, binary: bool }` | `null` content: the file has no base (added or unversioned); binary content is not sent. At most 32 MiB. | +| `history`, `fileHistory` | `{ entries: [{ revision, author, date, message, changes, changesTruncated }], nextCursor }` | Provider order (newest first) is kept; more entries than requested is invalid. Each entry lists at most 10,000 changes. | +| `annotate` | `{ revisions: [{ revision, author, date, message }], lines: [id \| null] }` | Each line names a listed revision ID or `null`; only used revisions are returned, sorted by ID. At most 500,000 lines. | +| `checkin` | `{ revision }` | The new revision, or `null` when the VCS does not report one. | +| `update` | `{ revision, conflicts: [path] }` | Conflicted paths, sorted and inside the root. | +| `rollback`, `fileOperation`, `resolve`, `cleanup` | `{}` | Unknown fields are ignored. | -## Plugin providers (planned, plugin API level 3) +A path that is absolute, has a `..` segment, or otherwise leaves the root, a +missing required field, or a size limit exceeded makes the reply invalid. -### Manifest +## Provider messages -```json -{ - "pluginAPIVersion": 3, - "requires": { "vcsProviders": 1, "serverProcess": 1 }, - "vcsProviders": [ - { - "id": "svn", - "displayName": "Subversion", - "detection": { "markers": [{ "name": ".svn", "type": "directory" }] }, - "metadataPaths": [".svn/wc.db"], - "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, - "launch": { "kind": "packaged", "path": "bin/lithe-svn-provider" } - } - ] -} -``` - -The manifest only states what the host must know before starting a process: -the VCS ID, detection markers, metadata paths to watch, the client tool the -provider drives, and the shared `serverProcess` launch fields. The host -resolves `tool.commands` with the same discovery as `systemCommand` launches -(PATH, user override) and sends the absolute path as `tool: { executable }` in -every request, as it does for build providers, so the provider does not repeat -tool lookup. A missing tool is reported once and the root stays visible as -unavailable rather than clean. - -A non-empty `vcsProviders` requires `languageServer.launch` (the plugin starts -its own process, like an own-process build provider), a new `process.vcs` -permission (the provider changes the working copy and contacts remote -servers), and the activation event `onVcsRoot:`. Detection never starts a -process; the host starts the provider after a root of its VCS is found, which -the install review must state. - -### Negotiation - -The host sends `capabilities.experimental.litheVcs: { "versions": [1] }` in -`initialize`. The provider answers -`{ "version": 1, "vcs": "", "capabilities": { … } }`. A session without a -valid answer, or with a `vcs` different from the manifest entry, is not used as -a provider and receives no `lithe/vcs/*` request. Capabilities belong to the -running process and are not repeated in the manifest. - -### Methods - -| Direction | Method | Kind | +| Direction | Method | Version 1 behavior | | --- | --- | --- | -| host → provider | `lithe/vcs/status`, `lithe/vcs/baseContent`, `lithe/vcs/history`, `lithe/vcs/fileHistory`, `lithe/vcs/annotate`, `lithe/vcs/checkin`, `lithe/vcs/rollback`, `lithe/vcs/update`, `lithe/vcs/fileOperation` | request | -| provider → host | `lithe/vcs/execution` | notification: an actual command started, produced output, or finished (project console) | -| provider → host | `lithe/vcs/authenticate` | request: credential challenge answered through the host's existing authentication UI; answers are never persisted by Lithe | +| provider → host | `lithe/vcs/execution` | Notification of a command the provider ran and its output. Core passes it on as a session `log` event (`message` is the method, `detail` the params); routing it to the project console is host work (P3). | +| provider → host | `lithe/vcs/authenticate` | Not accepted yet: Core answers `-32601`. A provider runs its client non-interactively and fails with `vcs.authentication.failed`. | -Both sides ignore unknown fields in params and results. Requests carry -`operationID`; `$/cancelRequest` and the host deadline end them with -`requestCancelled` or `requestTimeout`. +Both sides ignore unknown fields in params and results. `$/cancelRequest` and +the request deadline end a request with `requestCancelled` or +`requestTimeout`. -## Error codes (draft) +## Error codes -| Code | Meaning | -| --- | --- | -| `vcs.root.notFound` | The path is not inside a detected or mapped root | -| `vcs.root.ambiguous` | The directory matches several providers and is unmapped | -| `vcs.provider.unavailable` | The provider is not registered, not negotiated, or has exited | -| `vcs.capability.unsupported` | The request needs a capability the provider did not negotiate | -| `vcs.authentication.failed` | Credentials were rejected after the allowed retries | -| `vcs.outOfDate` | The server rejected a check-in because the working copy is out of date | -| `vcs.conflict` | The operation stopped on conflicted files | -| `plugin.vcs.invalidOutput` | A plugin reply failed validation or exceeded a limit | - -Platform details go in `details`; messages must not contain credentials. +| Code | Raised by | Meaning | +| --- | --- | --- | +| `vcs.provider.unavailable` | Core (`details` of `notSupported`) | The session negotiated no provider, or one for another VCS | +| `vcs.capability.unsupported` | Core (`details` of `notSupported`) | The operation needs a capability the provider did not negotiate | +| `plugin.vcs.reserved` | Core (manifest) | A plugin declared the reserved `git` ID | +| `plugin.vcs.invalidOutput` | Core | A reply failed validation, exceeded a limit, or had no result | +| `vcs.provider.failed` | Core | The provider failed without one of the codes below | +| `vcs.authentication.failed` | provider | Credentials were missing or rejected | +| `vcs.conflict` | provider | The operation stopped on conflicted files | +| `vcs.outOfDate` | provider | The server rejected a check-in because the working copy is out of date | +| `vcs.tool.unavailable` | provider | The VCS client could not be run | +| `vcs.workingCopy.locked` | provider | The working copy is locked by an interrupted operation; `cleanup` may help | +| `vcs.workingCopy.unsupported` | provider | The working copy format is not supported | + +A provider reports its code in the JSON-RPC `error.data.code`. Core keeps only +the provider codes listed here; any other code, including Core's own, becomes +`vcs.provider.failed`. The provider's message (at most 4 KiB) is passed on and +must not contain credentials. ## Subversion provider notes The official Subversion plugin calls the user's `svn` client and parses its `--xml` output (`status`, `info`, `log`, `blame`). Every invocation uses -`--non-interactive`; passwords use `--password-from-stdin`. Subversion's own -credential cache answers first. Working copies older than format 1.7 (one -`.svn` per directory) are not supported. +`--non-interactive`, so only Subversion's own credential cache answers. +Working copies older than format 1.7 (one `.svn` per directory) are reported +as `vcs.workingCopy.unsupported`. diff --git a/shared/fixtures/vcs/model-v1.json b/shared/fixtures/vcs/model-v1.json index d62109adac..a488bbbe55 100644 --- a/shared/fixtures/vcs/model-v1.json +++ b/shared/fixtures/vcs/model-v1.json @@ -6,26 +6,33 @@ { "vcs": "git", "displayName": "Git", - "origin": { "kind": "builtIn" }, - "capabilities": { - "annotate": 1, - "baseContent": 1, - "checkin": 1, - "conflicts": 1, - "fileHistory": 1, - "history": 1, - "ignore": 1, - "push": 1, - "rollback": 1, - "staging": 1, - "status": 1, - "update": 1 - } + "origin": { + "kind": "builtIn" + }, + "markers": [ + { + "name": ".git", + "type": "directory" + }, + { + "name": ".git", + "type": "file" + } + ] }, { "vcs": "svn", "displayName": "Subversion", - "origin": { "kind": "plugin", "pluginID": "dev.lithe.plugin.svn-support" }, + "origin": { + "kind": "plugin", + "pluginID": "dev.lithe.fixture.svn-provider" + }, + "markers": [ + { + "name": ".svn", + "type": "directory" + } + ], "capabilities": { "annotate": 1, "baseContent": 1, @@ -35,8 +42,6 @@ "fileHistory": 1, "fileOperations": 1, "history": 1, - "ignore": 1, - "lock": 1, "rollback": 1, "status": 1, "update": 1 @@ -47,73 +52,386 @@ { "name": "nested Subversion working copy inside a Git repository", "markers": [ - { "directory": "", "name": ".git", "type": "directory" }, - { "directory": "services/legacy", "name": ".svn", "type": "directory" } + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "services/legacy", + "name": ".svn", + "type": "directory" + } ], "expectedRoots": [ - { "id": "", "vcs": "git" }, - { "id": "services/legacy", "vcs": "svn" } + { + "id": "", + "vcs": "git" + }, + { + "id": "services/legacy", + "vcs": "svn" + } ], "expectedOwnership": [ - { "path": "README.md", "root": "" }, - { "path": "services/legacy/src/Main.java", "root": "services/legacy" } + { + "path": "README.md", + "root": "" + }, + { + "path": "services/legacy/src/Main.java", + "root": "services/legacy" + }, + { + "path": "services/legacy-tools/a.sh", + "root": "" + } ], "expectedAmbiguous": [] }, { - "name": "one directory with markers of two providers stays unmapped", + "name": "an unmapped directory with markers of two providers owns nothing and blocks the outer root", "markers": [ - { "directory": "mixed", "name": ".git", "type": "directory" }, - { "directory": "mixed", "name": ".svn", "type": "directory" } + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "git" + } + ], + "expectedOwnership": [ + { + "path": "mixed/a.txt", + "root": null + }, + { + "path": "mixed.txt", + "root": "" + } + ], + "expectedAmbiguous": [ + { + "id": "mixed", + "vcs": [ + "git", + "svn" + ] + } + ] + }, + { + "name": "a mapping chooses the provider of an ambiguous directory", + "markers": [ + { + "directory": "", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".git", + "type": "directory" + }, + { + "directory": "mixed", + "name": ".svn", + "type": "directory" + } + ], + "mappings": [ + { + "id": "gone", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "hg" + }, + { + "id": "mixed", + "vcs": "svn" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "svn", + "mapped": true + } ], - "expectedRoots": [], - "expectedOwnership": [{ "path": "mixed/a.txt", "root": null }], - "expectedAmbiguous": [{ "id": "mixed", "vcs": ["git", "svn"] }] + "expectedOwnership": [ + { + "path": "mixed/a.txt", + "root": "mixed" + } + ], + "expectedAmbiguous": [], + "expectedUnusedMappings": [ + { + "id": "gone", + "vcs": "git" + }, + { + "id": "mixed", + "vcs": "hg" + } + ] }, { "name": "Git worktree file marker", - "markers": [{ "directory": "linked", "name": ".git", "type": "file" }], - "expectedRoots": [{ "id": "linked", "vcs": "git" }], - "expectedOwnership": [{ "path": "linked/src/lib.rs", "root": "linked" }], + "markers": [ + { + "directory": "linked", + "name": ".git", + "type": "file" + } + ], + "expectedRoots": [ + { + "id": "linked", + "vcs": "git" + } + ], + "expectedOwnership": [ + { + "path": "linked/src/lib.rs", + "root": "linked" + }, + { + "path": "other/a.txt", + "root": null + } + ], + "expectedAmbiguous": [] + }, + { + "name": "a workspace opened inside a Subversion working copy", + "workspace": "trunk/module", + "markers": [ + { + "directory": "", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "../..", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "src/Main.java", + "root": "../.." + } + ], + "expectedAmbiguous": [] + }, + { + "name": "metadata and dependency directories are not searched", + "markers": [ + { + "directory": "", + "name": ".svn", + "type": "directory" + }, + { + "directory": ".svn/pristine", + "name": ".git", + "type": "directory" + }, + { + "directory": "node_modules/left-pad", + "name": ".git", + "type": "directory" + }, + { + "directory": "target/checkout", + "name": ".svn", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "node_modules/left-pad/index.js", + "root": "" + } + ], + "expectedAmbiguous": [] + }, + { + "name": "roots deeper than eight directories are not looked for", + "markers": [ + { + "directory": "a/b/c/d/e/f/g/h", + "name": ".svn", + "type": "directory" + }, + { + "directory": "z/b/c/d/e/f/g/h/i", + "name": ".git", + "type": "directory" + } + ], + "expectedRoots": [ + { + "id": "a/b/c/d/e/f/g/h", + "vcs": "svn" + } + ], + "expectedOwnership": [ + { + "path": "z/b/c/d/e/f/g/h/i/a.txt", + "root": null + } + ], "expectedAmbiguous": [] } ], "statusSnapshots": [ { - "name": "git root with staging dimension", - "vcs": "git", + "name": "provider with a staging dimension", + "capabilities": { + "baseContent": 1, + "checkin": 1, + "rollback": 1, + "status": 1, + "staging": 1 + }, "snapshot": { - "root": "", "head": { - "revision": { "id": "4b825dc642cb6eb9a060e54bf8d69288fbee4904", "display": "4b825dc6", "kind": "hash" }, + "revision": { + "id": "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + "display": "4b825dc6", + "kind": "hash" + }, "branch": "preview", - "location": "https://github.example.invalid/owner/repo.git" + "location": "https://vcs.example.invalid/owner/repo.git" }, "changes": [ - { "path": "docs/new.md", "oldPath": null, "status": "added", "flags": [], "staging": "staged" }, - { "path": "src/lib.rs", "oldPath": null, "status": "modified", "flags": [], "staging": "partial" }, - { "path": "src/renamed.rs", "oldPath": "src/old.rs", "status": "renamed", "flags": [], "staging": "staged" }, - { "path": "vendor/module", "oldPath": null, "status": "modified", "flags": ["submodule"], "staging": "unstaged" } + { + "path": "docs/new.md", + "oldPath": null, + "status": "added", + "flags": [], + "staging": "staged" + }, + { + "path": "src/lib.rs", + "oldPath": null, + "status": "modified", + "flags": [], + "staging": "partial" + }, + { + "path": "src/renamed.rs", + "oldPath": "src/old.rs", + "status": "renamed", + "flags": [], + "staging": "staged" + }, + { + "path": "vendor/module", + "oldPath": null, + "status": "modified", + "flags": [ + "submodule" + ], + "staging": "unstaged" + } ], "truncated": false } }, { "name": "svn root without staging, with Subversion-only flags", - "vcs": "svn", + "capabilities": { + "annotate": 1, + "baseContent": 1, + "checkin": 1, + "cleanup": 1, + "conflicts": 1, + "fileHistory": 1, + "fileOperations": 1, + "history": 1, + "rollback": 1, + "status": 1, + "update": 1 + }, "snapshot": { - "root": "services/legacy", "head": { - "revision": { "id": "1234", "display": "r1234", "kind": "number" }, + "revision": { + "id": "1234", + "display": "r1234", + "kind": "number" + }, "branch": null, "location": "https://svn.example.invalid/repo/trunk" }, "changes": [ - { "path": "build.xml", "oldPath": null, "status": "missing", "flags": [] }, - { "path": "lib/vendor", "oldPath": null, "status": "unversioned", "flags": ["external"] }, - { "path": "src/Main.java", "oldPath": null, "status": "modified", "flags": ["propertiesModified", "locked"] }, - { "path": "src/Moved.java", "oldPath": "src/Old.java", "status": "renamed", "flags": [] }, - { "path": "src/Shared.java", "oldPath": null, "status": "conflicted", "flags": ["treeConflict"] } + { + "path": "build.xml", + "oldPath": null, + "status": "missing", + "flags": [] + }, + { + "path": "lib/vendor", + "oldPath": null, + "status": "unversioned", + "flags": [ + "external" + ] + }, + { + "path": "src/Main.java", + "oldPath": null, + "status": "modified", + "flags": [ + "locked", + "propertiesModified" + ] + }, + { + "path": "src/Moved.java", + "oldPath": "src/Old.java", + "status": "renamed", + "flags": [] + }, + { + "path": "src/Shared.java", + "oldPath": null, + "status": "conflicted", + "flags": [ + "treeConflict" + ] + } ], "truncated": false } @@ -122,36 +440,224 @@ "pluginReplyValidationCases": [ { "name": "unknown status value is rejected", - "reply": { "changes": [{ "path": "a.txt", "oldPath": null, "status": "obstructed", "flags": [] }] }, + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "obstructed", + "flags": [] + } + ] + }, "expectedError": "plugin.vcs.invalidOutput" }, { "name": "path escaping the root is rejected", - "reply": { "changes": [{ "path": "../outside.txt", "oldPath": null, "status": "modified", "flags": [] }] }, + "reply": { + "changes": [ + { + "path": "../outside.txt", + "oldPath": null, + "status": "modified", + "flags": [] + } + ] + }, "expectedError": "plugin.vcs.invalidOutput" }, { "name": "unknown flag is dropped, change is kept", - "reply": { "changes": [{ "path": "a.txt", "oldPath": null, "status": "modified", "flags": ["futureFlag", "locked"] }] }, - "expectedChanges": [{ "path": "a.txt", "oldPath": null, "status": "modified", "flags": ["locked"] }] + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [ + "futureFlag", + "locked" + ] + } + ] + }, + "expectedChanges": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [ + "locked" + ] + } + ] }, { "name": "staging field from a provider without staging is rejected", - "reply": { "changes": [{ "path": "a.txt", "oldPath": null, "status": "modified", "flags": [], "staging": "staged" }] }, + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": null, + "status": "modified", + "flags": [], + "staging": "staged" + } + ] + }, "expectedError": "plugin.vcs.invalidOutput" }, { "name": "unordered changes are sorted by path", "reply": { "changes": [ - { "path": "b.txt", "oldPath": null, "status": "added", "flags": [] }, - { "path": "a.txt", "oldPath": null, "status": "deleted", "flags": [] } + { + "path": "b.txt", + "oldPath": null, + "status": "added", + "flags": [] + }, + { + "path": "a.txt", + "oldPath": null, + "status": "deleted", + "flags": [] + } ] }, "expectedChanges": [ - { "path": "a.txt", "oldPath": null, "status": "deleted", "flags": [] }, - { "path": "b.txt", "oldPath": null, "status": "added", "flags": [] } + { + "path": "a.txt", + "oldPath": null, + "status": "deleted", + "flags": [] + }, + { + "path": "b.txt", + "oldPath": null, + "status": "added", + "flags": [] + } ] + }, + { + "name": "credentials, query, and fragment leave the head location", + "reply": { + "head": { + "revision": { + "id": "7" + }, + "location": "https://alice:secret@svn.example.invalid/repo/trunk?p=1#top" + }, + "changes": [] + }, + "expectedResult": { + "head": { + "revision": { + "id": "7", + "display": "7", + "kind": "opaque" + }, + "branch": null, + "location": "https://svn.example.invalid/repo/trunk" + }, + "changes": [], + "truncated": false + } + }, + { + "name": "an original path on a modification is rejected", + "reply": { + "changes": [ + { + "path": "a.txt", + "oldPath": "b.txt", + "status": "modified", + "flags": [] + } + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "a history page longer than the request is rejected", + "operation": "history", + "request": { + "limit": 1 + }, + "reply": { + "entries": [ + { + "revision": { + "id": "2" + }, + "author": "a", + "date": "2026-10-22T00:00:00Z", + "message": "m" + }, + { + "revision": { + "id": "1" + }, + "author": "a", + "date": "2026-10-21T00:00:00Z", + "message": "m" + } + ], + "nextCursor": null + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "an annotated line must name a listed revision", + "operation": "annotate", + "request": { + "path": "a.txt" + }, + "reply": { + "revisions": [ + { + "revision": { + "id": "1" + }, + "author": "a", + "date": "2026-10-21T00:00:00Z", + "message": "m" + } + ], + "lines": [ + "1", + "2" + ] + }, + "expectedError": "plugin.vcs.invalidOutput" + }, + { + "name": "update conflicts are sorted and stay inside the root", + "operation": "update", + "request": {}, + "reply": { + "revision": { + "id": "9", + "display": "r9", + "kind": "number" + }, + "conflicts": [ + "b.txt", + "a.txt" + ] + }, + "expectedResult": { + "revision": { + "id": "9", + "display": "r9", + "kind": "number" + }, + "conflicts": [ + "a.txt", + "b.txt" + ] + } } ] } diff --git a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json index c54f270a5c..3f25f78e23 100644 --- a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json +++ b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json @@ -7,6 +7,10 @@ "evidence": [ "shared/contracts/vcs-provider-v1.md", "shared/fixtures/vcs/model-v1.json", + "rust/lithe-core/src/vcs", + "rust/lithe-core/src/lsp/interface/vcs_provider.rs", + "rust/lithe-core/src/tests/vcs.rs", + "shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json", ".agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md" ], "implementationStatus": "missing", @@ -15,12 +19,16 @@ "windows": { "evidence": [ "shared/contracts/vcs-provider-v1.md", - "shared/fixtures/vcs/model-v1.json" + "shared/fixtures/vcs/model-v1.json", + "rust/lithe-core/src/vcs", + "rust/lithe-core/src/lsp/interface/vcs_provider.rs", + "rust/lithe-core/src/tests/vcs.rs", + "shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Git", - "verification": "安装 Subversion 官方插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变。", - "notes": "Issue #827。当前只有提议 Note、契约草案和 fixtures,两端都没有实现入口;分阶段计划见提议 Note。" + "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单以及 vcs.decodeReply 的回复校验用例。宿主验收:安装 Subversion 官方插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", + "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商(P1);两端宿主尚未声明 vcsProviders,也没有调用这些命令,所以仍记为未实现。分阶段计划见提议 Note。" } From 0e8392c3e1800ccaa5ef94741b9ef1bae72431b8 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 22 Oct 2026 21:53:28 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(sdk):=20Node=20SDK=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20litheVcs=20=E5=B9=B6=E7=94=A8=E7=A4=BA=E4=BE=8B=20P?= =?UTF-8?q?rovider=20=E7=AB=AF=E5=88=B0=E7=AB=AF=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=EF=BC=88#827=20P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend.vcs(id, handlers, traits) 按声明的处理函数协商 VCS 能力,必需 status/baseContent/checkin/rollback,git 保留,litheVcs 不能手写进 capabilities - 只在宿主提供 litheVcs 1 时分发 lithe/vcs/* 请求;回复必须是对象,无返回值视为 {} - VCS 处理函数的 context.run 自动发出 lithe/vcs/execution(命令行、退出码、耗时、截断到 16 KiB 的 stderr),可用 report: false 关闭 - 导出 VCS_ERRORS 与 isVcsId,补充类型声明、README 和单元测试 - 新增 sdk/node/examples/sample-vcs 玩具 VCS,Rust 端到端测试经真实 Core 会话跑通协商、全部 11 种操作、错误码和执行报告 - 契约写明 lithe/vcs/execution 的参数;Note 与功能矩阵更新 P1 完成状态 --- .../2026-10-22-unified-vcs-provider-model.md | 3 +- rust/lithe-core/tests/plugin_sdk/mod.rs | 21 +- rust/lithe-core/tests/plugin_sdk/vcs.rs | 299 ++++++++++++++++++ rust/lithe-core/tests/plugin_sdk_node.rs | 14 +- sdk/node/README.md | 68 +++- sdk/node/examples/sample-vcs/plugin.json | 35 ++ .../examples/sample-vcs/provider/main.mjs | 241 ++++++++++++++ sdk/node/src/backend.mjs | 139 +++++++- sdk/node/src/index.d.ts | 60 +++- sdk/node/src/index.mjs | 2 +- sdk/node/src/protocol.mjs | 49 ++- sdk/node/test/backend.test.mjs | 2 +- sdk/node/test/vcs.test.mjs | 162 ++++++++++ shared/contracts/vcs-provider-v1.md | 2 +- .../vcs-subversion-and-plugin-providers.json | 14 +- 15 files changed, 1082 insertions(+), 29 deletions(-) create mode 100644 rust/lithe-core/tests/plugin_sdk/vcs.rs create mode 100644 sdk/node/examples/sample-vcs/plugin.json create mode 100644 sdk/node/examples/sample-vcs/provider/main.mjs create mode 100644 sdk/node/test/vcs.test.mjs diff --git a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md index 18cff763b0..77d95d3541 100644 --- a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md +++ b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md @@ -122,11 +122,12 @@ SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` | P4 | Windows 按同一契约和 fixtures 接入 | Windows 通过同一组 fixtures | | 之后 | Git 精简并接入统一层,见第 10 节 | 另起提案 | -P1 的 Core 部分已完成(2026-10-22),Node SDK 和假 Provider 端到端测试还没做: +P1 已完成(2026-10-22): - 插件清单:API 级别 3 的 `vcsProviders`、`process.vcs` 权限和 `onVcsRoot:` 激活事件;`git` 是保留 ID。安装审查列出 Provider 进程,SVN 这类工具路径另放在 `vcsTool` 字段,不覆盖运行时的 `tool`。 - 根识别 `vcs.detectRoots`:只读目录项,不读文件、不启动进程;工作区自己没有标记时向上查祖先目录,所以“工作区开在 SVN 工作副本的子目录里”也能识别。新增 `vcs.ownerOf`,让各宿主用同一条“最深根”规则给文件找归属,不必各写一份。 - 会话:`litheVcs` 只提供给插件启动的会话;`lsp.vcsRequest` 按操作名生成 `lithe/vcs/*` 请求,未协商的能力在写出前就拒绝;Provider 只能使用契约列出的错误码,其余一律变成 `vcs.provider.failed`。`lithe/vcs/authenticate` 在宿主实现交互式认证前按未知请求回绝。 +- Node SDK:`backend.vcs(id, handlers, traits)` 按声明的处理函数协商能力,VCS 处理函数里的 `context.run` 会自动发出 `lithe/vcs/execution` 执行报告(只带命令行、退出码、耗时和截断后的 stderr,不带 stdout)。`sdk/node/examples/sample-vcs` 是一个把修订存在 JSON 文件里的玩具 VCS,Rust 端到端测试经真实 Core 会话跑通它的协商和全部 11 种操作。 ### 10. Git 什么时候、怎样接入 diff --git a/rust/lithe-core/tests/plugin_sdk/mod.rs b/rust/lithe-core/tests/plugin_sdk/mod.rs index cad0fd45c3..f363143f43 100644 --- a/rust/lithe-core/tests/plugin_sdk/mod.rs +++ b/rust/lithe-core/tests/plugin_sdk/mod.rs @@ -13,6 +13,8 @@ // Each test binary includes this module and uses only the helpers its SDK needs. #![allow(dead_code)] +pub mod vcs; + use lithe_core::execute_json; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -97,10 +99,27 @@ struct Session { impl Session { fn start(plugin_id: &str, executable: &str, arguments: &[Value], workspace: &Path) -> Self { + Self::start_provider( + &format!("buildProvider:{plugin_id}:{BUILD_SYSTEM}"), + plugin_id, + executable, + arguments, + workspace, + ) + } + + /// Starts a plugin-owned session under the host's provider ID. + fn start_provider( + provider_id: &str, + plugin_id: &str, + executable: &str, + arguments: &[Value], + workspace: &Path, + ) -> Self { let started = core( "lsp.startServer", json!({ - "providerId": format!("buildProvider:{plugin_id}:{BUILD_SYSTEM}"), + "providerId": provider_id, "pluginId": plugin_id, "executablePath": executable, "arguments": arguments, diff --git a/rust/lithe-core/tests/plugin_sdk/vcs.rs b/rust/lithe-core/tests/plugin_sdk/vcs.rs new file mode 100644 index 0000000000..1fb682755d --- /dev/null +++ b/rust/lithe-core/tests/plugin_sdk/vcs.rs @@ -0,0 +1,299 @@ +//! End-to-end scenario of a VCS provider written with the Node.js SDK +//! (`sdk/node/examples/sample-vcs`), driven through the JSON commands a host +//! sends: root detection from the manifest, the launch plan, the `litheVcs` +//! negotiation, every operation through `lsp.vcsRequest` with its reply +//! decoded by `vcs.decodeReply`, a provider error code, and the execution +//! report a provider's tool run produces. + +use super::{core, listing, repository_path, Fixture, Session}; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +const VCS: &str = "sample"; + +/// Revision 1 of the working copy, as the sample stores it. +fn initial_revision() -> Value { + json!([{ + "id": "1", + "author": "sample", + "date": "2026-01-01T00:00:00.000Z", + "message": "Initial", + "files": { "a.txt": "one\ntwo\n", "docs/b.txt": "bee\n" }, + "changes": [ + { "path": "a.txt", "status": "added" }, + { "path": "docs/b.txt", "status": "added" } + ] + }]) +} + +/// One provider session with what it negotiated and the host's resolved tool. +struct Provider<'a> { + session: Session, + capabilities: Value, + root: &'a Path, + tool: &'a str, + sequence: usize, +} + +impl Provider<'_> { + /// Sends one operation and returns its `requestCompleted` event. + fn send(&mut self, operation: &str, arguments: &Value) -> Value { + self.sequence += 1; + let operation_id = format!("{operation}-{}", self.sequence); + core( + "lsp.vcsRequest", + json!({ + "sessionId": self.session.id, + "operationId": operation_id, + "vcs": VCS, + "operation": operation, + "root": self.root, + "tool": self.tool, + "arguments": arguments, + }), + ); + self.session.completion(&operation_id) + } + + /// Sends one operation and returns the reply as `vcs.decodeReply` normalizes it. + fn request(&mut self, operation: &str, arguments: Value) -> Value { + let completed = self.send(operation, &arguments); + assert!( + completed.get("error").is_none(), + "{operation} failed: {completed}" + ); + let decoded = core( + "vcs.decodeReply", + json!({ + "operation": operation, + "capabilities": self.capabilities, + "request": arguments, + "reply": completed["result"]["value"], + }), + ); + assert_eq!(decoded["status"], "ok", "{operation}: {decoded}"); + decoded["result"].clone() + } + + /// `(path, status, oldPath)` of each change, in Core's order. + fn changes(&mut self) -> Vec<(String, String, Value)> { + self.request("status", json!({}))["changes"] + .as_array() + .expect("decoded changes") + .iter() + .map(|change| { + ( + change["path"].as_str().unwrap().to_string(), + change["status"].as_str().unwrap().to_string(), + change["oldPath"].clone(), + ) + }) + .collect() + } +} + +fn change(path: &str, status: &str) -> (String, String, Value) { + (path.to_string(), status.to_string(), Value::Null) +} + +fn revision_ids(entries: &Value) -> Vec { + entries + .as_array() + .expect("entries") + .iter() + .map(|entry| entry["revision"]["id"].as_str().unwrap().to_string()) + .collect() +} + +/// Runs the scenario with `node` as both the provider's runtime and the +/// sample VCS's "client". +pub fn run_vcs_scenario(node: &str) { + let package = repository_path("sdk/node/examples/sample-vcs"); + let manifest: Value = + serde_json::from_slice(&fs::read(package.join("plugin.json")).expect("sample manifest")) + .expect("sample manifest JSON"); + let plugin_id = manifest["id"].as_str().expect("plugin ID").to_string(); + let package_before = listing(&package); + let validated = core( + "plugins.declarative.validate", + json!({ "manifest": manifest }), + ); + assert_eq!(validated["status"], "ok", "{validated}"); + + let fixture = Fixture::new("vcs"); + let workspace = fixture.workspace(); + fs::create_dir_all(workspace.join(".samplevcs")).expect("metadata directory"); + fs::create_dir_all(workspace.join("docs")).expect("docs directory"); + fs::write( + workspace.join(".samplevcs/revisions.json"), + initial_revision().to_string(), + ) + .expect("revisions"); + fs::write(workspace.join("a.txt"), "one\ntwo\n").expect("a.txt"); + fs::write(workspace.join("docs/b.txt"), "bee\n").expect("b.txt"); + + // Core finds the working copy from the manifest's marker alone. + let detection = core( + "vcs.detectRoots", + json!({ "workspaceRoot": workspace, "plugins": [{ "manifest": manifest }] }), + ); + assert_eq!(detection["status"], "ok", "{detection}"); + let root = detection["detection"]["roots"] + .as_array() + .expect("roots") + .iter() + .find(|root| root["vcs"] == VCS) + .unwrap_or_else(|| panic!("no sample root: {detection}")) + .clone(); + assert_eq!(root["id"], ""); + assert_eq!(root["path"], json!(workspace)); + + // The host starts the provider from the plan Core expands. + let plan = core( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest": manifest, + "vcsProviderID": VCS, + "request": { + "pluginDirectory": package, + "stateDirectory": fixture.root.join("state"), + "workspaceStateDirectory": fixture.root.join("workspace-state"), + "architecture": if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" }, + } + }), + ); + assert_eq!(plan["status"], "ok", "{plan}"); + let plan = &plan["plan"]; + assert_eq!(plan["vcsProvider"]["vcs"], VCS, "{plan}"); + assert_eq!(plan["launch"]["tool"], "node"); + let arguments = plan["arguments"].as_array().expect("arguments").clone(); + let mut session = Session::start_provider( + &format!("vcsProvider:{plugin_id}:{VCS}"), + &plugin_id, + node, + &arguments, + &workspace, + ); + + // The provider negotiated one capability per handler it declared. + let negotiated = session.await_event("the VCS negotiation", |event| { + event["type"] == "vcsProvider" + }); + assert_eq!(negotiated["result"]["vcs"], VCS, "{negotiated}"); + let capabilities = negotiated["result"]["capabilities"].clone(); + assert_eq!( + capabilities, + json!({ + "annotate": 1, "baseContent": 1, "checkin": 1, "cleanup": 1, "conflicts": 1, + "fileHistory": 1, "fileOperations": 1, "history": 1, "rollback": 1, "status": 1, + "update": 1 + }) + ); + let mut provider = Provider { + session, + capabilities, + root: &workspace, + tool: node, + sequence: 0, + }; + + // Local edits come back as one sorted status snapshot. + fs::write(workspace.join("a.txt"), "one\nTWO\nthree\n").expect("edit a.txt"); + fs::remove_file(workspace.join("docs/b.txt")).expect("remove b.txt"); + fs::write(workspace.join("c.txt"), "sea\n").expect("c.txt"); + assert_eq!( + provider.changes(), + [ + change("a.txt", "modified"), + change("c.txt", "unversioned"), + change("docs/b.txt", "missing"), + ] + ); + provider.request( + "fileOperation", + json!({ "kind": "add", "paths": ["c.txt"] }), + ); + assert_eq!(provider.changes()[1], change("c.txt", "added")); + assert_eq!( + provider.request("baseContent", json!({ "path": "a.txt" })), + json!({ "content": "one\ntwo\n", "binary": false }) + ); + + // A provider's contract code reaches the host; anything else would not. + fs::write(workspace.join(".samplevcs/lock"), "").expect("lock"); + let locked = provider.send( + "checkin", + &json!({ "paths": ["a.txt", "c.txt"], "message": "Second" }), + ); + assert_eq!( + locked["error"]["code"], "vcs.workingCopy.locked", + "{locked}" + ); + // Cleanup runs the tool, which the session reports for the console. + provider.request("cleanup", json!({})); + let execution = provider + .session + .await_event("the execution report", |event| { + event["type"] == "log" && event["message"] == "lithe/vcs/execution" + }); + let report: Value = serde_json::from_str(execution["detail"].as_str().expect("report detail")) + .expect("report JSON"); + assert_eq!(report["command"], json!([node, "--version"]), "{report}"); + assert_eq!(report["exitCode"], 0, "{report}"); + + let checked_in = provider.request( + "checkin", + json!({ "paths": ["c.txt", "a.txt"], "message": "Second" }), + ); + assert_eq!( + checked_in["revision"], + json!({ "id": "2", "display": "r2", "kind": "number" }) + ); + assert_eq!(provider.changes(), [change("docs/b.txt", "missing")]); + provider.request("rollback", json!({ "paths": ["docs/b.txt"] })); + assert_eq!( + fs::read_to_string(workspace.join("docs/b.txt")).expect("restored b.txt"), + "bee\n" + ); + assert!(provider.changes().is_empty()); + + // History pages keep the provider's order and resume from its cursor. + let first = provider.request("history", json!({ "limit": 1 })); + assert_eq!(revision_ids(&first["entries"]), ["2"]); + assert_eq!(first["nextCursor"], "1"); + let second = provider.request("history", json!({ "limit": 1, "cursor": "1" })); + assert_eq!(revision_ids(&second["entries"]), ["1"]); + assert_eq!(second["nextCursor"], Value::Null); + let file_history = provider.request("fileHistory", json!({ "path": "c.txt" })); + assert_eq!(revision_ids(&file_history["entries"]), ["2"]); + let annotation = provider.request("annotate", json!({ "path": "a.txt" })); + assert_eq!(annotation["lines"], json!(["1", "2", "2"])); + assert_eq!(revision_ids(&annotation["revisions"]), ["1", "2"]); + assert_eq!( + provider.request("update", json!({})), + json!({ "revision": { "id": "2", "display": "r2", "kind": "number" }, "conflicts": [] }) + ); + + // A tracked move is reported as a rename from its source. + provider.request( + "fileOperation", + json!({ "kind": "move", "from": "c.txt", "to": "docs/c.txt" }), + ); + assert_eq!( + provider.changes(), + [( + "docs/c.txt".to_string(), + "renamed".to_string(), + json!("c.txt") + )] + ); + provider.request("resolve", json!({ "paths": ["docs/c.txt"] })); + + provider.session.stop(); + drop(provider); + assert!( + listing(&package) == package_before, + "the plugin package changed at runtime" + ); +} diff --git a/rust/lithe-core/tests/plugin_sdk_node.rs b/rust/lithe-core/tests/plugin_sdk_node.rs index 15322fd13a..8cd689b422 100644 --- a/rust/lithe-core/tests/plugin_sdk_node.rs +++ b/rust/lithe-core/tests/plugin_sdk_node.rs @@ -1,7 +1,8 @@ //! End-to-end check of the official Node.js plugin SDK (`sdk/node`) against //! Rust Core; the scenario is shared with the Java SDK in `plugin_sdk/mod.rs`. -//! It runs only when `LITHE_PLUGIN_SDK_NODE` names a Node.js 20.3 or newer -//! executable; `scripts/verify-rust-core-crates.sh` sets it. +//! The VCS scenario is in `plugin_sdk/vcs.rs`. Both run only when +//! `LITHE_PLUGIN_SDK_NODE` names a Node.js 20.3 or newer executable; +//! `scripts/verify-rust-core-crates.sh` sets it. mod plugin_sdk; @@ -17,3 +18,12 @@ fn the_node_sdk_sample_backend_serves_build_requests_and_plugin_functions() { node, }); } + +#[test] +fn the_node_sdk_sample_vcs_provider_answers_every_operation() { + let Some(node) = plugin_sdk::node_from_environment("the Node.js SDK VCS end-to-end check") + else { + return; + }; + plugin_sdk::vcs::run_vcs_scenario(&node); +} diff --git a/sdk/node/README.md b/sdk/node/README.md index f11e666fcb..c4f8a68a36 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -3,7 +3,7 @@ `@lithe/plugin-sdk` is the official way to write a plugin backend in JavaScript: the process a plugin starts through a `runtime` launch (`{ "kind": "runtime", "tool": "node", … }`). It speaks the LSP base protocol -on stdio and implements Lithe's two backend protocols from +on stdio and implements Lithe's backend protocols from [plugin contract v1](../../shared/contracts/plugins/v1/README.md): - **`litheBuild` 1**: typed `lithe/build/model` and `lithe/build/dependencies` @@ -12,6 +12,9 @@ on stdio and implements Lithe's two backend protocols from - **`litheFunctions` 1**: plugin functions, which are plugin-private actions that Lithe invokes for the same plugin, with progress, cancellation, and registration at runtime. +- **`litheVcs` 1**: typed `lithe/vcs/*` requests, for a plugin's + `vcsProviders` entry, from the + [VCS provider contract](../../shared/contracts/vcs-provider-v1.md). The SDK owns the wire format, negotiation, cancellation, process cleanup, and error mapping. Your code supplies only handlers. Node 20.3 or newer is @@ -46,6 +49,53 @@ and cancellation. Rust Core's `rust/lithe-core/tests/plugin_sdk_node.rs` drives it through a real Core session. +## A VCS provider + +```js +import { createBackend, PluginError, VCS_ERRORS } from './vendor/lithe-plugin-sdk/src/index.mjs'; + +createBackend({ name: 'example-vcs', version: '1.0.0' }) + .vcs('example', { + status: async ({ root, tool }, context) => { + if (!tool) throw new PluginError(VCS_ERRORS.toolUnavailable, 'The example client was not found.'); + const result = await context.run(tool.executable, ['status', '--json'], { cwd: root.path }); + return { changes: JSON.parse(result.stdout).changes }; + }, + baseContent: ({ path }) => ({ content: readBase(path), binary: false }), + checkin: ({ paths, message }) => ({ revision: commit(paths, message) }), + rollback: ({ paths }) => revert(paths), + }) + .listen(); +``` + +`backend.vcs(id, handlers, traits?)` names the VCS of the manifest's +`vcsProviders` entry and its handlers, keyed by operation: `status`, +`baseContent`, `checkin`, and `rollback` are required; `history`, +`fileHistory`, `annotate`, `update`, `fileOperation`, `resolve`, and +`cleanup` are optional. Each handler you declare negotiates its capability, +so the host offers only what you implement. `traits` describes the VCS +itself: `{ push: true }` when a check-in is local and publishing is a +separate step, `{ staging: true }` when changes have a staged dimension. +`git` is reserved for Lithe. + +A handler receives the operation's arguments plus `root: { path }` and +`tool: { executable } | null` (the client the host resolved), and returns the +reply of the contract; returning nothing answers `{}`. Core validates every +reply, sorts paths, and refuses one that leaves the root. Throw +`PluginError` with a `VCS_ERRORS` code (`conflict`, `outOfDate`, +`authenticationFailed`, `workingCopyLocked`, `workingCopyUnsupported`, +`toolUnavailable`) so the host can explain the failure; other codes reach it +as `vcs.provider.failed`. A VCS handler's `context.run` also reports each +finished command (command line, exit code, duration, and at most 16 KiB of +stderr) as `lithe/vcs/execution` for the project console; pass +`{ report: false }` to skip one. Arguments are reported as given, so never +put a credential in them. + +[`examples/sample-vcs`](examples/sample-vcs) is a complete provider for a +toy VCS that keeps revisions in a JSON file. Rust Core's +`rust/lithe-core/tests/plugin_sdk_node.rs` drives every operation through a +real Core session. + ## Packaging The SDK is not published to a registry yet. Copy `src/` into your plugin @@ -58,8 +108,9 @@ imports has to be inside the package. The SDK has no dependencies. | API | Purpose | | --- | --- | -| `createBackend({ name?, version?, capabilities? })` | Creates the backend. `capabilities` are standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved for the SDK. | +| `createBackend({ name?, version?, capabilities? })` | Creates the backend. `capabilities` are standard LSP server capabilities; `experimental.litheBuild`, `experimental.litheFunctions`, and `experimental.litheVcs` are reserved for the SDK. | | `backend.build.model(handler)`, `backend.build.dependencies(handler)` | Implements the typed build requests. The SDK stamps `format` and `version`; a handler returning another format or a version other than 1 fails. Core validates the document. `litheBuild` is answered only when a handler exists. | +| `backend.vcs(id, handlers, traits?)` | Implements the typed VCS requests; see [A VCS provider](#a-vcs-provider). `litheVcs` is answered only when the host offers it. | | `backend.function(id, handler)` | Declares a function offered in `initialize`. | | `backend.registerFunction(id, handler)`, `backend.unregisterFunction(id)` | Adds or withdraws a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list; later they send `lithe/function/register` / `unregister`. Withdrawing revokes running calls. | | `backend.onInitialize(hook)`, `backend.onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | @@ -67,7 +118,7 @@ imports has to be inside the package. The SDK has no dependencies. | `backend.sendRequest`, `backend.sendNotification` | Ordinary LSP messages to the host; `lithe/*` methods are reserved. | | `backend.log(...)` | Writes a line to stderr, which the host keeps in its log. | | `backend.listen()` | Serves stdio until `exit` or the end of input. | -| `PluginError(code, message)` | A failure meant for your own UI. | +| `PluginError(code, message)` | A failure meant for your own UI, or with a `VCS_ERRORS` code, for the host's. | | `runProcess(command, args, options)` | Runs a process without a shell, as one cancellable process tree. | | `openStateStore(directory)` | A small atomic JSON store in a host-provided directory. | @@ -77,7 +128,7 @@ Every handler receives a context: | --- | --- | | `signal` | Aborted when Lithe cancels the call, its deadline passes, the function is withdrawn, or the session ends; also once the handler has settled, so work it left running stops. | | `throwIfCancelled()` | Throws the cancellation if `signal` is aborted. | -| `run(command, args, options)` | `runProcess` bound to the call: cancelling or ending the call ends the whole process tree. | +| `run(command, args, options)` | `runProcess` bound to the call: cancelling or ending the call ends the whole process tree. In VCS handlers it also reports the finished command unless `options.report` is `false`. | | `log(...)` | Same as `backend.log`. | | `functionID`, `operationID` | Functions only. `operationID` is opaque; never derive anything from it. | | `progress({ message?, percentage? })` | Functions only. The message is cut to 1024 bytes and the percentage clamped to 0–100; reports after the call ends are dropped. | @@ -105,14 +156,15 @@ UI, and state, but these rules keep Lithe's workflows, permissions, and lifecycle unified. The SDK enforces the ones it can. 1. **Domain capabilities stay typed.** Build models and dependency trees go - through `backend.build.*`, never through a function, so every host and IDE - workflow reads one contract. Functions are for actions only your plugin - understands. + through `backend.build.*`, and version control through `backend.vcs`, + never through a function, so every host and IDE workflow reads one + contract. Functions are for actions only your plugin understands. 2. **No calls back into Lithe's dispatch.** A backend never sends `lithe/function/invoke` or `lithe/build/*`; Lithe would route it back to the same process, which would then wait for itself. Lithe refuses these with `plugin.function.recursiveCall`, and the SDK refuses to send any `lithe/*` - method. + method. The only `lithe/vcs/*` message a provider sends is the execution + report, which `context.run` writes for you. 3. **stdout belongs to the protocol.** When serving stdio, the SDK moves `console.log`, `console.info`, and `console.debug` to stderr. Never write to `process.stdout` yourself. diff --git a/sdk/node/examples/sample-vcs/plugin.json b/sdk/node/examples/sample-vcs/plugin.json new file mode 100644 index 0000000000..f21a591660 --- /dev/null +++ b/sdk/node/examples/sample-vcs/plugin.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 2, + "id": "org.example.sample-vcs", + "displayName": "Sample VCS", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": ["languageServer.launch", "process.vcs"], + "activationEvents": ["onVcsRoot:sample"], + "languages": [], + "vcsProviders": [ + { + "id": "sample", + "displayName": "Sample VCS", + "detection": { + "markers": [{ "name": ".samplevcs", "type": "directory" }] + }, + "metadataPaths": [".samplevcs/revisions.json"], + "tool": { "commands": ["node"], "validationArguments": ["--version"] }, + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "provider/main.mjs" + }, + "arguments": ["--stdio"], + "mayUseNetwork": false + } + ] +} diff --git a/sdk/node/examples/sample-vcs/provider/main.mjs b/sdk/node/examples/sample-vcs/provider/main.mjs new file mode 100644 index 0000000000..56d590a53d --- /dev/null +++ b/sdk/node/examples/sample-vcs/provider/main.mjs @@ -0,0 +1,241 @@ +// A sample VCS provider written with the Lithe plugin SDK. It is not a real +// version control system: a working copy is a directory with a +// `.samplevcs/revisions.json` file that stores every revision as a full +// snapshot of the files' text, so the sample shows the protocol without a +// VCS client. A real provider, such as the official Subversion plugin, runs +// its client through `context.run` and parses the output instead. +// +// Rust Core's end-to-end test (rust/lithe-core/tests/plugin_sdk/vcs.rs) +// drives every operation below through a real session. A packaged plugin +// vendors the SDK inside its package, for example as +// `provider/vendor/lithe-plugin-sdk/`, and imports it from there. + +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { createBackend, PluginError, VCS_ERRORS } from '../../../src/index.mjs'; + +const METADATA = '.samplevcs'; + +/** The working copy's metadata: its revisions, oldest first, and pending operations. */ +function load(root) { + const read = (name, fallback) => { + const path = join(root, METADATA, name); + return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : fallback; + }; + return { + revisions: read('revisions.json', []), + pending: read('pending.json', { added: [], deleted: [], moved: {} }), + }; +} + +function save(root, { revisions, pending }) { + writeFileSync(join(root, METADATA, 'revisions.json'), `${JSON.stringify(revisions, null, 2)}\n`); + writeFileSync(join(root, METADATA, 'pending.json'), `${JSON.stringify(pending, null, 2)}\n`); +} + +/** Text of every file below `root`, by `/`-separated relative path. */ +function workingFiles(root, directory = '', files = {}) { + for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) { + if (entry.name === METADATA) continue; + const path = directory ? `${directory}/${entry.name}` : entry.name; + if (entry.isDirectory()) workingFiles(root, path, files); + else if (entry.isFile()) files[path] = readFileSync(join(root, path), 'utf8'); + } + return files; +} + +const head = (revisions) => revisions.at(-1) ?? null; +const base = (revisions) => head(revisions)?.files ?? {}; +const revisionOf = (entry) => ({ id: entry.id, display: `r${entry.id}`, kind: 'number' }); + +function rootOf(params) { + const root = params.root.path; + if (existsSync(join(root, METADATA, 'lock'))) { + throw new PluginError(VCS_ERRORS.workingCopyLocked, 'The working copy is locked; run Cleanup.'); + } + return root; +} + +function status(params) { + const root = rootOf(params); + const { revisions, pending } = load(root); + const committed = base(revisions); + const disk = workingFiles(root); + const moveSources = new Set(Object.values(pending.moved)); + const changes = []; + for (const [path, text] of Object.entries(committed)) { + if (moveSources.has(path)) continue; + if (!(path in disk)) { + changes.push({ path, status: pending.deleted.includes(path) ? 'deleted' : 'missing' }); + } else if (disk[path] !== text) { + changes.push({ path, status: 'modified' }); + } + } + for (const path of Object.keys(disk)) { + if (path in committed) continue; + if (pending.moved[path]) changes.push({ path, oldPath: pending.moved[path], status: 'renamed' }); + else changes.push({ path, status: pending.added.includes(path) ? 'added' : 'unversioned' }); + } + const latest = head(revisions); + // Core sorts the changes and checks every path; the provider only reports. + return { head: { revision: latest && revisionOf(latest), branch: null, location: null }, changes }; +} + +function baseContent(params) { + const content = base(load(rootOf(params)).revisions)[params.path]; + return { content: content ?? null, binary: false }; +} + +function checkin(params) { + const root = rootOf(params); + const state = load(root); + const files = { ...base(state.revisions) }; + const disk = workingFiles(root); + const changes = []; + for (const path of params.paths) { + const source = state.pending.moved[path]; + if (source) { + delete files[source]; + delete state.pending.moved[path]; + changes.push({ path, oldPath: source, status: 'renamed' }); + } else if (!(path in disk)) { + changes.push({ path, status: 'deleted' }); + } else { + changes.push({ path, status: path in files ? 'modified' : 'added' }); + } + if (path in disk) files[path] = disk[path]; + else delete files[path]; + state.pending.added = state.pending.added.filter((added) => added !== path); + state.pending.deleted = state.pending.deleted.filter((deleted) => deleted !== path); + } + const entry = { + id: String(state.revisions.length + 1), + author: 'sample', + date: new Date(Date.UTC(2026, 0, 1, 0, 0, state.revisions.length)).toISOString(), + message: params.message, + files, + changes, + }; + state.revisions.push(entry); + save(root, state); + return { revision: revisionOf(entry) }; +} + +function rollback(params) { + const root = rootOf(params); + const state = load(root); + const committed = base(state.revisions); + for (const path of params.paths) { + if (path in committed) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), committed[path]); + } + // Like `svn revert`, an added file stays on disk, unversioned again. + state.pending.added = state.pending.added.filter((added) => added !== path); + state.pending.deleted = state.pending.deleted.filter((deleted) => deleted !== path); + } + save(root, state); +} + +/** One page of `entries`, newest first, resumed from an opaque cursor. */ +function page(entries, { cursor, limit = 100 }) { + const start = cursor ? Number(cursor) : 0; + const slice = entries.slice(start, start + limit); + return { + entries: slice.map((entry) => ({ + revision: revisionOf(entry), + author: entry.author, + date: entry.date, + message: entry.message, + changes: entry.changes, + changesTruncated: false, + })), + nextCursor: start + limit < entries.length ? String(start + limit) : null, + }; +} + +function history(params) { + return page(load(rootOf(params)).revisions.toReversed(), params); +} + +function fileHistory(params) { + const touched = (entry) => entry.changes.some((change) => change.path === params.path || change.oldPath === params.path); + return page(load(rootOf(params)).revisions.filter(touched).toReversed(), params); +} + +/** Each line comes from the oldest revision since which the file kept it. */ +function annotate(params) { + const root = rootOf(params); + const { revisions } = load(root); + const lines = readFileSync(join(root, params.path), 'utf8').split('\n'); + if (lines.at(-1) === '') lines.pop(); + const origins = lines.map((line) => { + let origin = null; + for (const entry of revisions.toReversed()) { + const text = entry.files[params.path]; + if (text === undefined || !text.split('\n').includes(line)) break; + origin = entry; + } + return origin; + }); + const used = new Map(origins.filter(Boolean).map((entry) => [entry.id, entry])); + return { + revisions: [...used.values()].map((entry) => ({ + revision: revisionOf(entry), + author: entry.author, + date: entry.date, + message: entry.message, + })), + lines: origins.map((entry) => entry?.id ?? null), + }; +} + +/** The sample has no server, so an update only reports where the copy is. */ +function update(params) { + const latest = head(load(rootOf(params)).revisions); + return { revision: latest && revisionOf(latest), conflicts: [] }; +} + +function fileOperation(params) { + const root = rootOf(params); + const state = load(root); + if (params.kind === 'add') { + state.pending.added = [...new Set([...state.pending.added, ...params.paths])]; + } else if (params.kind === 'delete') { + for (const path of params.paths) rmSync(join(root, path), { force: true }); + state.pending.deleted = [...new Set([...state.pending.deleted, ...params.paths])]; + } else { + mkdirSync(dirname(join(root, params.to)), { recursive: true }); + renameSync(join(root, params.from), join(root, params.to)); + state.pending.moved[params.to] = params.from; + } + save(root, state); +} + +/** + * Clears an interrupted operation's lock. A real provider runs its client + * here (`svn cleanup`); the sample runs the tool's version check so the host + * sees an execution report in its console. + */ +async function cleanup(params, context) { + if (!params.tool) throw new PluginError(VCS_ERRORS.toolUnavailable, 'The sample tool was not found.'); + const result = await context.run(params.tool.executable, ['--version']); + if (result.exitCode !== 0) throw new PluginError(VCS_ERRORS.toolUnavailable, 'The sample tool did not run.'); + rmSync(join(params.root.path, METADATA, 'lock'), { force: true }); +} + +createBackend({ name: 'sample-vcs', version: '0.1.0' }) + .vcs('sample', { + status, + baseContent, + checkin, + rollback, + history, + fileHistory, + annotate, + update, + fileOperation, + resolve: () => {}, + cleanup, + }) + .listen(); diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index e9eb1ebc37..9960081d2b 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -4,8 +4,8 @@ // cancellation, error codes, and the `lithe/*` namespace -- and leaves the // plugin free to decide what its handlers do. // -// Contract: shared/contracts/plugins/v1/README.md ("Build providers" and -// "Plugin functions"). +// Contracts: shared/contracts/plugins/v1/README.md ("Build providers" and +// "Plugin functions") and shared/contracts/vcs-provider-v1.md. import { format } from 'node:util'; import { encodeFrame, FrameDecoder, FramingError, MALFORMED } from './framing.mjs'; @@ -18,8 +18,12 @@ import { FUNCTIONS_VERSION, isFunctionId, isReservedMethod, + isVcsId, LIMITS, METHODS, + VCS_OPERATIONS, + VCS_REQUIRED_OPERATIONS, + VCS_VERSION, } from './protocol.mjs'; /** Canonical result format of each typed build request. */ @@ -28,6 +32,9 @@ const BUILD_FORMATS = Object.freeze({ [METHODS.buildDependencies]: 'litheDependencies', }); +/** VCS capabilities that describe the VCS rather than a request it answers. */ +const VCS_TRAITS = Object.freeze(['push', 'staging']); + /** * Creates a backend. Declare handlers, then call `listen()`. * @@ -35,8 +42,9 @@ const BUILD_FORMATS = Object.freeze({ * @param {string} [options.name] Reported as `serverInfo.name` and used as the log prefix. * @param {string} [options.version] Reported as `serverInfo.version`. * @param {object} [options.capabilities] Standard LSP server capabilities, for a - * backend that is also a language server. `experimental.litheBuild` and - * `experimental.litheFunctions` are negotiated by the SDK and cannot be set here. + * backend that is also a language server. `experimental.litheBuild`, + * `experimental.litheFunctions`, and `experimental.litheVcs` are negotiated by + * the SDK and cannot be set here. */ export function createBackend(options = {}) { return new Backend(options); @@ -48,13 +56,15 @@ class Backend { #capabilities; #functions = new Map(); #buildHandlers = new Map(); + /** The declared VCS provider: `{ id, handlers: Map, capabilities }`. */ + #vcs = null; #requestHandlers = new Map(); #notificationHandlers = new Map(); #initializeHooks = []; #shutdownHooks = []; /** `idle` → `listening` → `initializing` → `ready` → `shuttingDown` → `exited`. */ #phase = 'idle'; - #negotiated = Object.freeze({ functions: false, build: false }); + #negotiated = Object.freeze({ functions: false, build: false, vcs: false }); #initializeParams = null; /** Host requests being handled, by JSON-RPC ID. */ #incoming = new Map(); @@ -72,10 +82,10 @@ class Backend { constructor({ name = 'lithe-plugin-backend', version, capabilities = {} } = {}) { const experimental = capabilities.experimental ?? {}; - if ('litheBuild' in experimental || 'litheFunctions' in experimental) { + if ('litheBuild' in experimental || 'litheFunctions' in experimental || 'litheVcs' in experimental) { throw new PluginSdkError( 'sdk.capabilities.reserved', - 'litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead.', + 'litheBuild, litheFunctions, and litheVcs are negotiated by the SDK; declare handlers instead.', ); } this.#name = String(name); @@ -89,7 +99,7 @@ class Backend { }); } - /** Protocols the host agreed to in `initialize`; both false before it. */ + /** Protocols the host agreed to in `initialize`; all false before it. */ get negotiated() { return this.#negotiated; } @@ -120,6 +130,53 @@ class Backend { return this; } + /** + * Declares the VCS this backend provides and the operations it answers, + * before `listen()`. Each handler gets the operation's arguments plus + * `root: { path }` and `tool: { executable } | null`, and returns the reply + * object of shared/contracts/vcs-provider-v1.md. Declaring a handler + * negotiates its capability; `status`, `baseContent`, `checkin`, and + * `rollback` are required. + * + * @param {string} id The VCS ID of the manifest's `vcsProviders` entry, such as `svn`. + * @param {Record} handlers Keyed by operation name. + * @param {object} [traits] + * @param {boolean} [traits.push] Check-in is local and publishing is a separate step. + * @param {boolean} [traits.staging] Changes carry a staged/unstaged dimension. + */ + vcs(id, handlers, traits = {}) { + if (this.#phase !== 'idle') { + throw new PluginSdkError('sdk.vcs.lateDeclaration', 'Declare the VCS provider before listen().'); + } + if (this.#vcs) throw new PluginSdkError('sdk.vcs.duplicate', 'A backend provides one VCS.'); + if (!isVcsId(id)) { + throw new PluginSdkError( + 'sdk.vcs.invalidID', + `VCS ID ${JSON.stringify(id)} must be lowercase letters, digits, and "-", start with a letter, and be at most ${LIMITS.maxVcsIdBytes} bytes.`, + ); + } + if (id === 'git') throw new PluginSdkError('sdk.vcs.reserved', 'git is provided by Lithe.'); + const methods = new Map(); + const capabilities = {}; + for (const [operation, handler] of Object.entries(handlers ?? {})) { + const declared = VCS_OPERATIONS[operation]; + if (!declared) throw new PluginSdkError('sdk.vcs.unknownOperation', `${operation} is not a VCS operation.`); + if (typeof handler !== 'function') throw new TypeError(`The ${operation} handler must be a function.`); + methods.set(declared.method, handler); + capabilities[declared.capability] = VCS_VERSION; + } + const missing = VCS_REQUIRED_OPERATIONS.filter((operation) => !methods.has(VCS_OPERATIONS[operation].method)); + if (missing.length > 0) { + throw new PluginSdkError('sdk.vcs.missingOperation', `A VCS provider must answer ${missing.join(', ')}.`); + } + for (const [trait, enabled] of Object.entries(traits)) { + if (!VCS_TRAITS.includes(trait)) throw new PluginSdkError('sdk.vcs.unknownTrait', `${trait} is not a VCS trait.`); + if (enabled) capabilities[trait] = VCS_VERSION; + } + this.#vcs = { id, methods, capabilities: sortedKeys(capabilities) }; + return this; + } + /** Handles a standard LSP request, for a backend that is also a language server. */ onRequest(method, handler) { this.#declareHandler(this.#requestHandlers, method, handler); @@ -311,6 +368,11 @@ class Backend { this.#run(id, null, (context) => build(params ?? {}, context), (result) => buildResult(method, result)); return; } + const vcs = this.#negotiated.vcs ? this.#vcs.methods.get(method) : undefined; + if (vcs) { + this.#run(id, null, (context) => vcs(params ?? {}, this.#vcsContext(context)), (result) => vcsResult(method, result)); + return; + } const handler = this.#requestHandlers.get(method); if (handler) { this.#run(id, null, (context) => handler(params, context), (result) => result ?? null); @@ -348,6 +410,7 @@ class Backend { this.#negotiated = Object.freeze({ functions: offers(offered.litheFunctions, FUNCTIONS_VERSION), build: this.#buildHandlers.size > 0 && offers(offered.litheBuild, BUILD_VERSION), + vcs: this.#vcs !== null && offers(offered.litheVcs, VCS_VERSION), }); try { for (const hook of this.#initializeHooks) { @@ -370,6 +433,11 @@ class Backend { } const experimental = { ...(this.#capabilities.experimental ?? {}) }; if (this.#negotiated.build) experimental.litheBuild = { version: BUILD_VERSION }; + if (this.#negotiated.vcs) { + experimental.litheVcs = { version: VCS_VERSION, vcs: this.#vcs.id, capabilities: this.#vcs.capabilities }; + } else if (this.#vcs) { + this.log('The host did not offer litheVcs version 1; the VCS provider stays unavailable.'); + } if (this.#negotiated.functions) { experimental.litheFunctions = { version: FUNCTIONS_VERSION, @@ -475,6 +543,44 @@ class Backend { }); } + /** + * The request context of a VCS handler. Its `run` reports every finished + * command to the host as `lithe/vcs/execution`, for the project console, + * unless the call passes `report: false`. Arguments are reported as given, + * so they must never carry a credential. + */ + #vcsContext(context) { + const report = (command, args, started, outcome) => { + const params = { + command: [String(command), ...args.map(String)], + exitCode: outcome.exitCode ?? null, + durationMilliseconds: Math.max(0, Math.round(performance.now() - started)), + stderr: boundedText(outcome.stderr ?? '', LIMITS.maxExecutionErrorOutputBytes), + }; + this.#write({ jsonrpc: '2.0', method: METHODS.vcsExecution, params }); + }; + return { + ...context, + run: (command, args = [], { report: reporting = true, ...options } = {}) => { + const started = performance.now(); + const running = context.run(command, args, options); + if (!reporting) return running; + const reported = running.then( + (result) => { + report(command, args, started, result); + return result; + }, + (error) => { + if (!context.signal.aborted) report(command, args, started, { exitCode: null, stderr: error?.message }); + throw error; + }, + ); + reported.catch(() => {}); + return reported; + }, + }; + } + #declareBuild(method, handler) { if (this.#phase !== 'idle') { throw new PluginSdkError('sdk.build.lateDeclaration', 'Declare build handlers before listen().'); @@ -614,6 +720,23 @@ function buildResult(method, result) { return { ...result, format: expected, version: 1 }; } +/** + * A VCS reply must be a JSON object; Core validates its fields and refuses + * anything else as `plugin.vcs.invalidOutput`, so it fails here with a log. + */ +function vcsResult(method, result) { + if (result === undefined) return {}; + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + throw new PluginSdkError('sdk.vcs.invalidResult', `${method} must return a reply object.`); + } + return result; +} + +/** A copy of `object` with its keys sorted, for a stable `initialize` answer. */ +function sortedKeys(object) { + return Object.fromEntries(Object.entries(object).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))); +} + /** * A function result as the JSON value Core accepts. A value JSON cannot * encode, or one over Core's 1 MiB limit, fails here as a backend bug with a diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index 8841d2461a..dd7b030b7b 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -72,15 +72,58 @@ export interface BuildRequestParams { [key: string]: unknown; } +/** Params every VCS handler receives, on top of its operation's arguments. */ +export interface VcsRequestParams { + root: { path: string }; + /** The VCS client the host resolved; null when it could not, so report `vcs.tool.unavailable`. */ + tool: { executable: string } | null; + [key: string]: unknown; +} + +export interface VcsRunOptions extends RunProcessOptions { + /** Report the finished command as `lithe/vcs/execution`; default true. */ + report?: boolean; +} + +export interface VcsContext extends RequestContext { + /** `RequestContext.run` that also reports each finished command to the host's console. */ + run(command: string, args?: string[], options?: VcsRunOptions): Promise; +} + +export type VcsHandler = (params: VcsRequestParams, context: VcsContext) => JsonObject | void | Promise; + +/** Handlers keyed by operation; see shared/contracts/vcs-provider-v1.md for each reply. */ +export interface VcsHandlers { + status: VcsHandler; + baseContent: VcsHandler; + checkin: VcsHandler; + rollback: VcsHandler; + history?: VcsHandler; + fileHistory?: VcsHandler; + annotate?: VcsHandler; + update?: VcsHandler; + fileOperation?: VcsHandler; + resolve?: VcsHandler; + cleanup?: VcsHandler; +} + +export interface VcsTraits { + /** Check-in is local and publishing is a separate step. */ + push?: boolean; + /** Changes carry a staged/unstaged dimension. */ + staging?: boolean; +} + export interface Negotiated { functions: boolean; build: boolean; + vcs: boolean; } export interface BackendOptions { name?: string; version?: string; - /** Standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved. */ + /** Standard LSP server capabilities; `experimental.litheBuild`, `litheFunctions`, and `litheVcs` are reserved. */ capabilities?: JsonObject; } @@ -100,6 +143,7 @@ export interface Backend { model(handler: BuildHandler): Backend; dependencies(handler: BuildHandler): Backend; }; + vcs(id: string, handlers: VcsHandlers, traits?: VcsTraits): Backend; function(id: string, handler: FunctionHandler): Backend; registerFunction(id: string, handler: FunctionHandler): Promise; unregisterFunction(id: string): Promise; @@ -137,10 +181,24 @@ export function openStateStore(directory: string, options?: { packageDirectory?: export function isFunctionId(value: unknown): boolean; +export function isVcsId(value: unknown): boolean; + +/** Provider error codes Core passes on; use them as `PluginError` codes. */ +export const VCS_ERRORS: Readonly<{ + authenticationFailed: 'vcs.authentication.failed'; + conflict: 'vcs.conflict'; + outOfDate: 'vcs.outOfDate'; + toolUnavailable: 'vcs.tool.unavailable'; + workingCopyLocked: 'vcs.workingCopy.locked'; + workingCopyUnsupported: 'vcs.workingCopy.unsupported'; +}>; + export const LIMITS: Readonly<{ maxFunctions: number; maxFunctionIdBytes: number; maxPluginErrorCodeBytes: number; maxMessageBytes: number; maxResultBytes: number; + maxVcsIdBytes: number; + maxExecutionErrorOutputBytes: number; }>; diff --git a/sdk/node/src/index.mjs b/sdk/node/src/index.mjs index 6263ac8288..f8f6b5aef1 100644 --- a/sdk/node/src/index.mjs +++ b/sdk/node/src/index.mjs @@ -5,4 +5,4 @@ export { createBackend } from './backend.mjs'; export { PluginError, PluginSdkError } from './errors.mjs'; export { runProcess } from './process.mjs'; export { openStateStore } from './state.mjs'; -export { isFunctionId, LIMITS } from './protocol.mjs'; +export { isFunctionId, isVcsId, LIMITS, VCS_ERRORS } from './protocol.mjs'; diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index cb13ce88b9..17797e06f8 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -1,11 +1,12 @@ // Wire constants and validation shared with Rust Core -// (rust/lithe-core/src/lsp/interface/plugin_function.rs). The SDK checks the -// same rules locally so a plugin author sees a mistake when registering a +// (rust/lithe-core/src/lsp/interface/plugin_function.rs and +// rust/lithe-core/src/vcs/). The SDK checks the same rules locally so a plugin author sees a mistake when registering a // handler instead of as a refused session at runtime. /** Protocol versions this SDK can answer. */ export const FUNCTIONS_VERSION = 1; export const BUILD_VERSION = 1; +export const VCS_VERSION = 1; export const METHODS = Object.freeze({ initialize: 'initialize', @@ -19,6 +20,43 @@ export const METHODS = Object.freeze({ functionProgress: 'lithe/function/progress', buildModel: 'lithe/build/model', buildDependencies: 'lithe/build/dependencies', + vcsExecution: 'lithe/vcs/execution', +}); + +/** + * VCS-provider operations: the handler name a plugin declares, the request + * Core sends for it, and the capability declaring the handler negotiates. + * Contract: shared/contracts/vcs-provider-v1.md. + */ +export const VCS_OPERATIONS = Object.freeze({ + status: Object.freeze({ method: 'lithe/vcs/status', capability: 'status' }), + baseContent: Object.freeze({ method: 'lithe/vcs/baseContent', capability: 'baseContent' }), + history: Object.freeze({ method: 'lithe/vcs/history', capability: 'history' }), + fileHistory: Object.freeze({ method: 'lithe/vcs/fileHistory', capability: 'fileHistory' }), + annotate: Object.freeze({ method: 'lithe/vcs/annotate', capability: 'annotate' }), + checkin: Object.freeze({ method: 'lithe/vcs/checkin', capability: 'checkin' }), + rollback: Object.freeze({ method: 'lithe/vcs/rollback', capability: 'rollback' }), + update: Object.freeze({ method: 'lithe/vcs/update', capability: 'update' }), + fileOperation: Object.freeze({ method: 'lithe/vcs/fileOperation', capability: 'fileOperations' }), + resolve: Object.freeze({ method: 'lithe/vcs/resolve', capability: 'conflicts' }), + cleanup: Object.freeze({ method: 'lithe/vcs/cleanup', capability: 'cleanup' }), +}); + +/** Handlers every VCS provider must declare; Core refuses a provider without them. */ +export const VCS_REQUIRED_OPERATIONS = Object.freeze(['status', 'baseContent', 'checkin', 'rollback']); + +/** + * The provider error codes Core passes on (`error.data.code`). Throw a + * `PluginError` with one of them; any other code reaches the host as + * `vcs.provider.failed`. + */ +export const VCS_ERRORS = Object.freeze({ + authenticationFailed: 'vcs.authentication.failed', + conflict: 'vcs.conflict', + outOfDate: 'vcs.outOfDate', + toolUnavailable: 'vcs.tool.unavailable', + workingCopyLocked: 'vcs.workingCopy.locked', + workingCopyUnsupported: 'vcs.workingCopy.unsupported', }); /** JSON-RPC and LSP error codes the SDK answers with. */ @@ -39,6 +77,8 @@ export const LIMITS = Object.freeze({ maxPluginErrorCodeBytes: 64, maxMessageBytes: 1024, maxResultBytes: 1 << 20, + maxVcsIdBytes: 32, + maxExecutionErrorOutputBytes: 16 * 1024, }); /** Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. */ @@ -51,6 +91,11 @@ export function isFunctionId(value) { ); } +/** A VCS ID: lowercase ASCII letters, digits, and `-`, starting with a letter, such as `svn`. */ +export function isVcsId(value) { + return typeof value === 'string' && value.length <= LIMITS.maxVcsIdBytes && /^[a-z][a-z0-9-]*$/.test(value); +} + /** The plugin error code alphabet Core forwards as `pluginErrorCode`. */ export function isPluginErrorCode(value) { return ( diff --git a/sdk/node/test/backend.test.mjs b/sdk/node/test/backend.test.mjs index 937a680c33..8f569c2ded 100644 --- a/sdk/node/test/backend.test.mjs +++ b/sdk/node/test/backend.test.mjs @@ -24,7 +24,7 @@ test('initialize answers the protocols the host offered, with sorted functions', litheFunctions: { version: 1, functions: [{ id: 'install.prepare' }, { id: 'snapshot.refresh' }] }, }); assert.deepEqual(result.serverInfo, { name: 'test-backend', version: '1.0.0' }); - assert.deepEqual(backend.negotiated, { functions: true, build: true }); + assert.deepEqual(backend.negotiated, { functions: true, build: true, vcs: false }); assert.equal(await host.close(), 1); }); diff --git a/sdk/node/test/vcs.test.mjs b/sdk/node/test/vcs.test.mjs new file mode 100644 index 0000000000..9b49ed922c --- /dev/null +++ b/sdk/node/test/vcs.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { createBackend, isVcsId, PluginError, PluginSdkError, VCS_ERRORS } from '../src/index.mjs'; +import { ERROR_CODES, VCS_OPERATIONS } from '../src/protocol.mjs'; +import { TestHost } from './harness.mjs'; + +const model = JSON.parse(readFileSync(new URL('../../../shared/fixtures/vcs/model-v1.json', import.meta.url), 'utf8')); + +const OFFER = { litheVcs: { versions: [1] } }; +const ROOT = { path: '/workspace/legacy' }; + +/** The four operations every provider answers, echoing what they received. */ +function required(overrides = {}) { + return { + status: () => ({ changes: [] }), + baseContent: (params) => ({ content: `base of ${params.path}`, binary: false }), + checkin: () => ({ revision: { id: '2' } }), + rollback: () => {}, + ...overrides, + }; +} + +test('every VCS operation negotiates a capability of the contract, and the IDs follow Core', () => { + const svn = model.providers.find((provider) => provider.vcs === 'svn'); + // The fixture provider declares every operation-backed capability. + assert.deepEqual( + Object.values(VCS_OPERATIONS).map(({ capability }) => capability).sort(), + Object.keys(svn.capabilities).sort(), + ); + for (const id of ['svn', 'hg', 'p4-helix', 'a1']) assert.ok(isVcsId(id), id); + for (const id of ['', 'SVN', '1svn', 'svn_x', 'é', 'a'.repeat(33)]) assert.ok(!isVcsId(id), id); +}); + +test('a provider declaration is checked when it is made', () => { + const declare = (...args) => () => createBackend().vcs(...args); + const code = (expected) => (error) => error instanceof PluginSdkError && error.code === expected; + assert.throws(declare('SVN', required()), code('sdk.vcs.invalidID')); + assert.throws(declare('git', required()), code('sdk.vcs.reserved')); + assert.throws(declare('svn', required({ push: () => ({}) })), code('sdk.vcs.unknownOperation')); + assert.throws(declare('svn', { status: () => ({}) }), code('sdk.vcs.missingOperation')); + assert.throws(declare('svn', required(), { locks: true }), code('sdk.vcs.unknownTrait')); + assert.throws(declare('svn', required({ history: 'not a function' })), TypeError); + assert.throws(() => createBackend().vcs('svn', required()).vcs('hg', required()), code('sdk.vcs.duplicate')); + assert.throws( + () => createBackend({ capabilities: { experimental: { litheVcs: { version: 1 } } } }), + code('sdk.capabilities.reserved'), + ); + // lithe/vcs/* belongs to the typed handlers, never to onRequest. + assert.throws(() => createBackend().onRequest('lithe/vcs/status', () => {}), code('sdk.method.reserved')); +}); + +test('initialize answers the VCS and the capabilities its handlers and traits imply', async () => { + const backend = createBackend().vcs( + 'svn', + required({ fileOperation: () => {}, resolve: () => {}, history: () => ({ entries: [], nextCursor: null }) }), + { push: false, staging: false }, + ); + const host = new TestHost(backend); + const { result } = await host.initialize(OFFER); + assert.deepEqual(result.capabilities.experimental.litheVcs, { + version: 1, + vcs: 'svn', + capabilities: { baseContent: 1, checkin: 1, conflicts: 1, fileOperations: 1, history: 1, rollback: 1, status: 1 }, + }); + assert.deepEqual(backend.negotiated, { functions: false, build: false, vcs: true }); + await host.close(); + + const distributed = new TestHost(createBackend().vcs('hg', required(), { push: true })); + // Traits are capabilities without an operation; a false trait is not claimed. + const answer = (await distributed.initialize(OFFER)).result.capabilities.experimental.litheVcs; + assert.deepEqual(answer.capabilities, { baseContent: 1, checkin: 1, push: 1, rollback: 1, status: 1 }); + await distributed.close(); +}); + +test('a host that does not offer litheVcs gets no provider and no VCS answers', async () => { + const backend = createBackend().vcs('svn', required()); + const host = new TestHost(backend); + const { result } = await host.initialize({ litheVcs: { versions: [2] } }); + assert.deepEqual(result.capabilities.experimental, {}); + assert.equal(backend.negotiated.vcs, false); + const refused = await host.request('lithe/vcs/status', { root: ROOT, tool: null }); + assert.equal(refused.error.code, ERROR_CODES.methodNotFound); + await host.close(); +}); + +test('handlers receive the planned params and answer with the reply object', async () => { + const received = []; + const backend = createBackend().vcs( + 'svn', + required({ + status: (params) => { + received.push(params); + return { changes: [{ path: 'a.txt', status: 'modified' }] }; + }, + cleanup: () => undefined, + update: () => 'not an object', + }), + ); + const host = new TestHost(backend); + await host.initialize(OFFER); + const params = { includeIgnored: false, root: ROOT, tool: { executable: '/usr/bin/svn' } }; + const status = await host.request('lithe/vcs/status', params); + assert.deepEqual(status.result, { changes: [{ path: 'a.txt', status: 'modified' }] }); + assert.deepEqual(received, [params]); + // A handler that returns nothing answers the empty reply of rollback-like operations. + assert.deepEqual((await host.request('lithe/vcs/cleanup', { root: ROOT, tool: null })).result, {}); + // A reply that is not an object is a backend bug, reported without internals. + const invalid = await host.request('lithe/vcs/update', { root: ROOT, tool: null }); + assert.equal(invalid.error.code, ERROR_CODES.internalError); + // An operation the backend did not declare is not one of its capabilities. + assert.equal((await host.request('lithe/vcs/annotate', { root: ROOT })).error.code, ERROR_CODES.methodNotFound); + await host.close(); +}); + +test('provider failures carry the contract code Core passes on', async () => { + const backend = createBackend().vcs( + 'svn', + required({ + checkin: () => { + throw new PluginError(VCS_ERRORS.outOfDate, 'Update the working copy before committing.'); + }, + }), + ); + const host = new TestHost(backend); + await host.initialize(OFFER); + const failed = await host.request('lithe/vcs/checkin', { paths: ['a.txt'], message: 'Fix', root: ROOT, tool: null }); + assert.deepEqual(failed.error, { + code: ERROR_CODES.requestFailed, + message: 'Update the working copy before committing.', + data: { code: 'vcs.outOfDate' }, + }); + await host.close(); +}); + +test('commands a handler runs are reported to the host unless it opts out', async () => { + const script = "process.stderr.write('warning: x'); process.exit(3)"; + const backend = createBackend().vcs( + 'svn', + required({ + status: async (params, context) => { + const reported = await context.run(process.execPath, ['-e', script]); + await context.run(process.execPath, ['-e', 'process.exit(0)'], { report: false }); + return { changes: [], exitCode: reported.exitCode }; + }, + }), + ); + const host = new TestHost(backend); + await host.initialize(OFFER); + const status = await host.request('lithe/vcs/status', { root: ROOT, tool: null }); + assert.equal(status.result.exitCode, 3); + const executions = host.messages.filter((message) => message.method === 'lithe/vcs/execution'); + assert.equal(executions.length, 1); + const [{ params }] = executions; + assert.deepEqual(params.command, [process.execPath, '-e', script]); + assert.equal(params.exitCode, 3); + assert.equal(params.stderr, 'warning: x'); + assert.ok(Number.isInteger(params.durationMilliseconds) && params.durationMilliseconds >= 0); + // The report precedes the response that used its result. + assert.ok(host.messages.indexOf(executions[0]) < host.messages.indexOf(status)); + await host.close(); +}); diff --git a/shared/contracts/vcs-provider-v1.md b/shared/contracts/vcs-provider-v1.md index cb271575e9..999c334b56 100644 --- a/shared/contracts/vcs-provider-v1.md +++ b/shared/contracts/vcs-provider-v1.md @@ -324,7 +324,7 @@ missing required field, or a size limit exceeded makes the reply invalid. | Direction | Method | Version 1 behavior | | --- | --- | --- | -| provider → host | `lithe/vcs/execution` | Notification of a command the provider ran and its output. Core passes it on as a session `log` event (`message` is the method, `detail` the params); routing it to the project console is host work (P3). | +| provider → host | `lithe/vcs/execution` | Notification of a command the provider ran: `{ command: [executable, ...arguments], exitCode: number \| null, durationMilliseconds, stderr }`, with `stderr` cut to 16 KiB and no stdout. Core passes it on as a session `log` event (`message` is the method, `detail` the params as JSON); routing it to the project console is host work (P3). Arguments are shown as sent, so a provider never puts a credential in them. | | provider → host | `lithe/vcs/authenticate` | Not accepted yet: Core answers `-32601`. A provider runs its client non-interactively and fails with `vcs.authentication.failed`. | Both sides ignore unknown fields in params and results. `$/cancelRequest` and diff --git a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json index 3f25f78e23..03858b1e9b 100644 --- a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json +++ b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json @@ -11,6 +11,10 @@ "rust/lithe-core/src/lsp/interface/vcs_provider.rs", "rust/lithe-core/src/tests/vcs.rs", "shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json", + "sdk/node/src/backend.mjs", + "sdk/node/test/vcs.test.mjs", + "sdk/node/examples/sample-vcs", + "rust/lithe-core/tests/plugin_sdk/vcs.rs", ".agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md" ], "implementationStatus": "missing", @@ -23,12 +27,16 @@ "rust/lithe-core/src/vcs", "rust/lithe-core/src/lsp/interface/vcs_provider.rs", "rust/lithe-core/src/tests/vcs.rs", - "shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json" + "shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json", + "sdk/node/src/backend.mjs", + "sdk/node/test/vcs.test.mjs", + "sdk/node/examples/sample-vcs", + "rust/lithe-core/tests/plugin_sdk/vcs.rs" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Git", - "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单以及 vcs.decodeReply 的回复校验用例。宿主验收:安装 Subversion 官方插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", - "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商(P1);两端宿主尚未声明 vcsProviders,也没有调用这些命令,所以仍记为未实现。分阶段计划见提议 Note。" + "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp、npm test --prefix sdk/node、LITHE_PLUGIN_SDK_NODE=$(command -v node) cargo test -p lithe-core --test plugin_sdk_node 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例,以及用 Node SDK 写的示例 Provider 经真实 Core 会话完成协商、全部 11 种操作和执行报告。宿主验收:安装 Subversion 官方插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", + "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 已支持 litheVcs(P1);两端宿主尚未声明 vcsProviders,也没有调用这些命令,所以仍记为未实现。分阶段计划见提议 Note。" } From 54598ae6e0b9fd71a07b77e1fea6917ba8897a6f Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 23 Oct 2026 01:13:07 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(vcs):=20=E6=8C=89=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=A5=96=E5=85=88=E6=A0=B9=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E4=B8=8E=E7=A4=BA=E4=BE=8B=E7=A7=BB=E5=8A=A8=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=EF=BC=88#827=20P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 只有全部由 .. 段组成的根 ID 才是工作区外的祖先根;工作区内名为 ..repo 的目录按普通路径计算深度和包含关系,补充单元测试 - 示例 Provider 回滚移动目标时把文件移回原路径、恢复基线内容并清除移动记录;端到端测试覆盖移动后回滚 - 插件能力版本笔记补充 vcsProviders、其对 serverProcess 的依赖、两端宿主尚未声明的状态,以及 litheVcs 协商 --- .../2026-10-17-plugin-capability-versions.md | 7 ++++-- rust/lithe-core/src/vcs/roots.rs | 25 ++++++++++++++++--- rust/lithe-core/tests/plugin_sdk/vcs.rs | 8 ++++++ .../examples/sample-vcs/provider/main.mjs | 9 +++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md index f86ba51a7e..2572ea2c9b 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md +++ b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md @@ -32,6 +32,7 @@ | `ui` | `ui`;版本 `2` 增加工具窗口里的网页内容 | | `buildSystems` | 声明式构建识别、模型和命令计划 | | `buildProviders` | 任一 `buildSystems[].provider`,包括自有进程和共享语言服务器两种写法 | +| `vcsProviders` | `vcsProviders[]`:插件进程实现的版本控制系统(工作副本标记、元数据路径、命令行工具) | 规则: @@ -46,6 +47,8 @@ 为什么 `serverProcess` 单独作为一个能力:启动字段由语言服务器和构建系统的自有 Provider 共用。以后新增一种启动方式时,只升级 `serverProcess`,`languageServers` 和 `buildSystems` 的其它字段不受牵连。共享写法的 Provider 复用插件里的语言服务器,所以不单独计入 `serverProcess`。两种 Provider 都必须声明 `buildProviders: 1`:能启动语言服务器或执行构建命令,并不代表能完成构建协议协商、请求和生命周期管理。纯模板构建插件不声明 `buildProviders`,否则会因多列能力被拒绝。 +`vcsProviders` 同样依赖 `serverProcess`:每个 VCS Provider 都是插件自己的进程,启动字段按 `serverProcess` 校验,所以清单要同时写 `vcsProviders: 1` 和 `serverProcess: 1`。只写前者,Core 会报少列了 `serverProcess`。工作副本标记和元数据路径留在 `vcsProviders` 里,因为宿主在进程启动前就要靠它们找到工作副本根,见[统一 VCS 模型提案](../../proposed/architecture/2026-10-22-unified-vcs-provider-model.md)。 + ### 2. 宿主自己声明能运行什么 Core 能校验一个能力,不代表宿主能运行它。宿主在 `plugins.declarative.validate`、`plugins.marketplace.validateIndex` 和 `plugins.marketplace.installReview` 里发送 `hostCapabilities: {"<能力>": [版本…]}`,由 Core 判断: @@ -56,7 +59,7 @@ Core 能校验一个能力,不代表宿主能运行它。宿主在 `plugins.de 能力表放在宿主代码里,写在能力实现的旁边,Core 不再按平台去猜。原来按 `darwin-` 前缀和级别写死的判断,就是 Core 替宿主猜、又容易和宿主实现漂移的例子。 -macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,但 macOS 尚未实现 Provider,所以不声明 `buildProviders`;市场过滤、清单校验和安装审查都会拒绝需要它的插件,纯模板构建插件仍可用。Core 自己支持 `buildProviders: 1`,不传宿主表时仍可校验并生成 Provider 计划。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 +macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,但 macOS 尚未实现 Provider,所以不声明 `buildProviders`;市场过滤、清单校验和安装审查都会拒绝需要它的插件,纯模板构建插件仍可用。Core 自己支持 `buildProviders: 1`,不传宿主表时仍可校验并生成 Provider 计划。`vcsProviders: 1` 也是这样:Core 已经能校验清单、识别工作副本根并转发请求,但 macOS 和 Windows 都还没有接入 VCS Provider 会话,所以两端的能力表都不包含它,需要它的插件在两端都不可安装。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 ### 3. 市场索引带上能力版本 @@ -69,7 +72,7 @@ macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]` 清单回答的问题只有三个:启动什么进程、需要哪些权限、对哪些文件和工程生效。进程启动以后能回答哪些请求、支持哪些可选数据,在 LSP `initialize` 里协商: -- 宿主发送 `capabilities.experimental.litheBuild: {"versions": [1]}`。 +- 宿主发送 `capabilities.experimental.litheBuild: {"versions": [1]}`。VCS Provider 用同样的方式协商 `litheVcs`,回复里还带上它实现了哪些 VCS 能力(例如 `history`、`annotate`),宿主据此决定显示哪些操作,见 `shared/contracts/vcs-provider-v1.md`。 - Provider 从中选一个版本,回复 `{"version": 1}`。没有回复版本、或回复了宿主没提供的版本时,宿主不把它当作 Provider 使用。 - 双方都忽略协商对象、请求参数和结果里自己不认识的字段,所以同一个版本内可以继续增加可选数据。另一方不能忽略的改动,才需要新的协议版本。 diff --git a/rust/lithe-core/src/vcs/roots.rs b/rust/lithe-core/src/vcs/roots.rs index 35148a8f23..51a7e64d02 100644 --- a/rust/lithe-core/src/vcs/roots.rs +++ b/rust/lithe-core/src/vcs/roots.rs @@ -97,13 +97,19 @@ pub(crate) struct Detection { pub(crate) truncated: bool, } +/// Whether `id` names a directory enclosing the workspace: only `..` +/// segments. A directory inside the workspace may still be named `..repo`. +fn is_enclosing(id: &str) -> bool { + !id.is_empty() && id.split('/').all(|segment| segment == "..") +} + /// Sort key that puts enclosing roots first, then inner roots by depth. pub(crate) fn depth(id: &str) -> i64 { if id.is_empty() { return 0; } let segments = id.split('/').count() as i64; - if id.starts_with("..") { + if is_enclosing(id) { -segments } else { segments @@ -113,14 +119,14 @@ pub(crate) fn depth(id: &str) -> i64 { /// Whether `id` has the shape of a root or ambiguous-directory ID: `""`, /// only `..` segments, or a path inside the workspace. pub(crate) fn is_directory_id(id: &str) -> bool { - id.is_empty() || id.split('/').all(|segment| segment == "..") || is_root_relative(id) + id.is_empty() || is_enclosing(id) || is_root_relative(id) } /// Whether the root or ambiguous directory `id` contains the /// workspace-relative `path`. fn contains(id: &str, path: &str) -> bool { id.is_empty() - || id.starts_with("..") + || is_enclosing(id) || path == id || path .strip_prefix(id) @@ -338,6 +344,19 @@ mod tests { assert_eq!(owner_of("mx.txt"), Some("")); } + #[test] + fn a_directory_named_like_dot_dot_is_inside_the_workspace() { + let roots = ids(&["", "..repo"]); + let owner_of = |path| owner(&roots, &[], path); + assert_eq!(owner_of("..repo/a.txt"), Some("..repo")); + assert_eq!(owner_of("README.md"), Some("")); + // Without a workspace root, `..repo` owns only its own files. + let inner = ids(&["..repo"]); + assert_eq!(owner(&inner, &[], "src/main.rs"), None); + assert_eq!(depth("..repo"), 1); + assert!(is_directory_id("..repo/x")); + } + #[test] fn enclosing_roots_sort_before_inner_ones() { let mut ids = vec!["a/b", "", "../..", "a", ".."]; diff --git a/rust/lithe-core/tests/plugin_sdk/vcs.rs b/rust/lithe-core/tests/plugin_sdk/vcs.rs index 1fb682755d..6f5cd74de4 100644 --- a/rust/lithe-core/tests/plugin_sdk/vcs.rs +++ b/rust/lithe-core/tests/plugin_sdk/vcs.rs @@ -289,6 +289,14 @@ pub fn run_vcs_scenario(node: &str) { )] ); provider.request("resolve", json!({ "paths": ["docs/c.txt"] })); + // Rolling the move back restores the source and clears the rename. + provider.request("rollback", json!({ "paths": ["docs/c.txt"] })); + assert!(!workspace.join("docs/c.txt").exists()); + assert_eq!( + fs::read_to_string(workspace.join("c.txt")).expect("restored c.txt"), + "sea\n" + ); + assert!(provider.changes().is_empty()); provider.session.stop(); drop(provider); diff --git a/sdk/node/examples/sample-vcs/provider/main.mjs b/sdk/node/examples/sample-vcs/provider/main.mjs index 56d590a53d..aba848e933 100644 --- a/sdk/node/examples/sample-vcs/provider/main.mjs +++ b/sdk/node/examples/sample-vcs/provider/main.mjs @@ -126,6 +126,15 @@ function rollback(params) { const state = load(root); const committed = base(state.revisions); for (const path of params.paths) { + // Rolling back a move target puts the file back where it came from. + const source = state.pending.moved[path]; + if (source) { + mkdirSync(dirname(join(root, source)), { recursive: true }); + if (existsSync(join(root, path))) renameSync(join(root, path), join(root, source)); + if (source in committed) writeFileSync(join(root, source), committed[source]); + delete state.pending.moved[path]; + continue; + } if (path in committed) { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), committed[path]); From 79e1c2286af4eda86e5f88cab8ee9844201f5745 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 23 Oct 2026 17:00:09 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(vcs):=20macOS=20=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=8F=92=E4=BB=B6=20VCS=20Provider=EF=BC=88#?= =?UTF-8?q?827=20P3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SVN 插件改为独立仓库 Lithe-IDEA/lithe-plugin-svn 的社区插件 (io.github.lithe-idea.svn-support),主仓库只保留与具体 VCS 无关的部分; 原 #1432 里宿主识别 vcsProviders 的通用改动并入本提交。 - 宿主识别:声明式清单解码 vcsProviders,声明了 VCS Provider 的插件生成一个 .vcs-providers 扩展模块,只承载启用状态,默认关闭; PluginManifest.hostCapabilities 声明 vcsProviders: [1]。 - 安装审核:显示 Provider 的启动命令、客户端命令和是否连接仓库服务器。 - Provider 会话、Core 调用(vcs.detectRoots / ownerOf / decodeReply、 lsp.vcsRequest)、VcsProvidersFeatureModel、左侧 VCS 工具窗口、历史与差异、 项目树状态着色和移动跟踪。 - 测试改用契约里的 svn-provider 清单 fixture 和 SDK 的 sample-vcs 样例, 不再依赖 SVN 插件源码。 - 契约的 Subversion provider notes 改为指向插件仓库;Note、功能矩阵和 Plugins/mac/README.md 同步说明社区插件。 --- .../2026-10-22-unified-vcs-provider-model.md | 50 +- Plugins/mac/README.md | 10 + .../zh-Hans.lproj/Localizable.strings | 69 ++ .../Application/Composition/AppServices.swift | 2 + .../WorkspaceProjectionComposition.swift | 3 + .../Features/VcsProvidersFeatureModel.swift | 717 ++++++++++++++++++ .../Features/WorkspaceFeatureModel.swift | 2 + .../Lithe/Core/Rust/RustCoreBridge.swift | 36 + .../RustDeclarativePluginOperations.swift | 35 + .../RustLanguageServerRuntimeAdapter.swift | 17 + .../Lithe/Core/Rust/RustVcsOperations.swift | 151 ++++ .../AppModel+LanguageServerRuntime.swift | 1 + .../AppModel/AppModel+PluginManagement.swift | 2 + .../AppModel/AppModel+RunConfiguration.swift | 1 + .../AppModel/AppModel+VcsProviders.swift | 22 + .../Lithe/Models/AppModel/AppModel.swift | 2 + .../Platform/MacOS/MacServiceContainer.swift | 46 +- ...acPluginLanguageServerLaunchResolver.swift | 91 ++- .../MacOS/Vcs/MacVcsProviderSessions.swift | 340 +++++++++ .../MacOS/Vcs/MacVcsRootMappingStore.swift | 33 + .../Views/App/PluginMarketplaceView.swift | 22 + macos/Sources/Lithe/Views/App/RootView.swift | 4 + .../Vcs/VcsChangeStatusPresentation.swift | 67 ++ .../Vcs/VcsProviderHistoryAndDiffViews.swift | 266 +++++++ .../Views/Vcs/VcsProviderToolWindowView.swift | 708 +++++++++++++++++ .../VcsProviderToolWindowRegistration.swift | 95 +++ .../WorkbenchModuleUIComposition.swift | 15 +- .../Views/Workspace/ProjectSidebarView.swift | 74 +- .../LanguageServerRuntimeContracts.swift | 27 + .../Plugins/DeclarativePluginContracts.swift | 62 +- .../Plugins/PluginMarketplaceContracts.swift | 24 + .../Vcs/VcsProviderContracts.swift | 436 +++++++++++ .../Runtime/LanguageServerSession.swift | 55 ++ .../Catalog/BuiltInModuleCatalog.swift | 18 + .../Lifecycle/ModuleTypes.swift | 5 + .../LitheModuleAPI/Plugins/PluginTypes.swift | 3 +- .../Application/WorkspaceFeatureModel.swift | 21 +- .../LitheTests/AppLocalizationTests.swift | 42 + .../Tests/LitheTests/PluginManagerTests.swift | 20 + .../LitheTests/VcsProviderHostTests.swift | 608 +++++++++++++++ .../examples/sample-vcs/provider/main.mjs | 2 +- shared/contracts/vcs-provider-v1.md | 17 +- .../vcs-subversion-and-plugin-providers.json | 22 +- 43 files changed, 4192 insertions(+), 51 deletions(-) create mode 100644 macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustVcsOperations.swift create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsRootMappingStore.swift create mode 100644 macos/Sources/Lithe/Views/Vcs/VcsChangeStatusPresentation.swift create mode 100644 macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift create mode 100644 macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift create mode 100644 macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift create mode 100644 macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift create mode 100644 macos/Tests/LitheTests/VcsProviderHostTests.swift diff --git a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md index 77d95d3541..e018434fee 100644 --- a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md +++ b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md @@ -6,7 +6,7 @@ Lithe 现在只认识 Git:Rust Core、macOS 和 Windows 的变更列表、提交面板、项目树文件颜色、编辑器行标记(gutter)都直接使用 Git 类型。[Issue #827](https://github.com/1lck/Lithe-IDEA/issues/827) 要求支持 SVN,以后还可能有 Mercurial、Perforce 等其他 VCS(Version Control System,版本控制系统)。 -提案照搬[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)已经验证过的做法:Rust Core 定义一份与具体 VCS 无关的统一模型和 `vcs.*` 命令,每种 VCS 是一个 Provider(回答“这个目录现在是什么状态、怎样提交、怎样回滚”的实现)。SVN 作为第一个官方插件 Provider,后端用官方 Node SDK 编写,以插件进程的形式接入,后续其他 VCS 走同一条路。 +提案照搬[构建系统插件契约](../../implemented/architecture/2026-10-17-build-system-plugin-contract.md)已经验证过的做法:Rust Core 定义一份与具体 VCS 无关的统一模型和 `vcs.*` 命令,每种 VCS 是一个 Provider(回答“这个目录现在是什么状态、怎样提交、怎样回滚”的实现)。SVN 作为第一个插件 Provider,后端用官方 Node SDK 编写,以插件进程的形式接入,后续其他 VCS 走同一条路。2026-10-23 决定 SVN 插件不放进主仓库,而是像 Cargo 插件一样做成独立仓库的社区插件([lithe-plugin-svn](https://github.com/Lithe-IDEA/lithe-plugin-svn));主仓库只保留与具体 VCS 无关的 Core、契约、SDK 和宿主。 2026-10-22 评审决定:**Git 暂不接入统一层**。现有 Git 代码约 8 万行(Rust、Swift、TypeScript 合计,不含测试),两端还有重复的确定性逻辑,直接“包一层”会把这些债一起带进统一层。所以第一期统一层只服务插件 Provider,Git 保持现有 `git.*` 和 Git 模块不变;Git 的精简、迁移和改成内置官方插件另起提案(第 10 节)。 @@ -94,9 +94,9 @@ Git Log 图、Rebase、Worktree、Stash、Push 和 Fetch 对话框、历史改 字段和方法见 [VCS Provider 契约](../../../../shared/contracts/vcs-provider-v1.md),示例数据见 [`shared/fixtures/vcs/model-v1.json`](../../../../shared/fixtures/vcs/model-v1.json)。Core 已按契约实现(见第 9 节的进度),但还没有宿主声明 `vcsProviders`,所以插件市场不会列出这类插件;宿主接入前契约仍可以修改,改动要同时更新 fixtures 和 Core 测试。 -### 7. SVN 作为官方插件 +### 7. SVN 作为社区插件 -SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` 输出。IDEA 的 svn4idea 也是这样做的(`plugins/svn4idea/src/org/jetbrains/idea/svn/status/CmdStatusClient.kt` 使用 `svn status --xml`)。这和 Lithe 使用系统 Git 的方式一致,不需要打包 JVM 或跨平台编译 libsvn。 +SVN 插件在独立仓库维护和发布(见第 9 节 P2),调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` 输出。IDEA 的 svn4idea 也是这样做的(`plugins/svn4idea/src/org/jetbrains/idea/svn/status/CmdStatusClient.kt` 使用 `svn status --xml`)。这和 Lithe 使用系统 Git 的方式一致,不需要打包 JVM 或跨平台编译 libsvn。 后端进程用官方 Node SDK(`sdk/node`)编写,以 `runtime` 方式启动(`tool: node`)。SDK 已经负责线协议、协商、取消、子进程清理和错误映射,Cargo 社区插件也走这条路;同一份后端在 macOS 和 Windows 上都能运行,插件包里没有原生二进制,不涉及按架构编译和 Gatekeeper。代价是用户除了 `svn` 还需要 Node 20.3 以上,安装审查和缺少运行时的提示要说清楚。SDK 新增 `litheVcs` 支持,和 `litheBuild` 并列。 @@ -117,7 +117,7 @@ SVN 插件调用用户系统里的 `svn` 命令行,解析它稳定的 `--xml` | --- | --- | --- | | P0 | 本提案、契约草案和 fixtures | 评审通过 | | P1 | Core:插件清单 `vcsProviders` 能力、根识别、请求计划与插件回复解码、`litheVcs` 协商和 `lsp.vcsRequest`;Node SDK 支持 `litheVcs` | fixtures 和用 SDK 写的假 Provider 经真实 Core 会话跑通 | -| P2 | SVN 官方插件(Node SDK 后端) | 录制的 `svn --xml` 样例解析测试;本地 `file://` 仓库上的端到端测试 | +| P2 | SVN 社区插件(独立仓库,Node SDK 后端) | 插件仓库里录制的 `svn --xml` 样例解析测试,以及用固定版本 Core 在本地 `file://` 仓库上跑的端到端测试 | | P3 | macOS 宿主:Provider 会话(复用构建 Provider 的会话代码)、项目树颜色和 gutter 合并展示插件根、通用“版本控制”工具窗口(变更、提交、回滚、更新)、文件操作跟踪、交互式认证 | 见验收标准 | | P4 | Windows 按同一契约和 fixtures 接入 | Windows 通过同一组 fixtures | | 之后 | Git 精简并接入统一层,见第 10 节 | 另起提案 | @@ -129,6 +129,34 @@ P1 已完成(2026-10-22): - 会话:`litheVcs` 只提供给插件启动的会话;`lsp.vcsRequest` 按操作名生成 `lithe/vcs/*` 请求,未协商的能力在写出前就拒绝;Provider 只能使用契约列出的错误码,其余一律变成 `vcs.provider.failed`。`lithe/vcs/authenticate` 在宿主实现交互式认证前按未知请求回绝。 - Node SDK:`backend.vcs(id, handlers, traits)` 按声明的处理函数协商能力,VCS 处理函数里的 `context.run` 会自动发出 `lithe/vcs/execution` 执行报告(只带命令行、退出码、耗时和截断后的 stderr,不带 stdout)。`sdk/node/examples/sample-vcs` 是一个把修订存在 JSON 文件里的玩具 VCS,Rust 端到端测试经真实 Core 会话跑通它的协商和全部 11 种操作。 +P2 已完成(2026-10-23),同日改为社区插件: + +- 插件在独立仓库 [lithe-plugin-svn](https://github.com/Lithe-IDEA/lithe-plugin-svn),ID 是 `io.github.lithe-idea.svn-support`,`vendor.signatureRequirement` 为 `none`。仓库结构照搬 [Cargo 插件](../../implemented/feature/2026-10-22-cargo-build-plugin.md):`sdk.lock.json` 按上游提交和逐文件 SHA-256 固定 Node SDK,打包前校验;安装包发在插件仓库的 GitHub Releases,Lithe Store 索引记录下载地址和 SHA-256。 +- 插件仓库自己的 CI 按 `sdk.lock.json` 的同一个宿主提交检出 Core,经公开 JSON 接口在 `svnadmin create` 建立的 `file://` 仓库上跑状态、提交、更新、冲突、回滚、移动、历史和 Annotate。svn 怎样映射到每个操作、svn 错误码怎样映射到契约错误码,都写在插件仓库的 README 里,主仓库的契约只留一个指向。 +- 主仓库不再有 SVN 专属的代码和测试,CI 也不用装 svn。它保留通用的部分:契约和 fixtures、`sdk/node/examples/sample-vcs` 样例 Provider 及其 Core 端到端测试,以及宿主测试用的 `shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json`(只有清单,没有实现)。 + +正确做法:主仓库改 VCS 契约或 Core 时,用样例 Provider 和 fixtures 验证;SVN 插件升级 `sdk.lock.json` 后在自己的 CI 里验证。 +不要这样做:在主仓库测试里引用隔壁的 SVN 插件 checkout,或者为 SVN 在宿主代码里加按名字判断的分支。 + +宿主需要知道的 SVN 行为(插件 README 有完整说明): + +- `head` 是根目录自己的工作修订(`svn info` 的结果)。提交后根目录停在旧修订,要 `update` 才前进,这是 SVN 的混合修订语义,宿主不要拿它判断“提交没成功”。 +- svn 每个写命令结束前会等约 1 秒,保证之后的修改时间戳不同,宿主不要指望写操作更快完成。 +- 第一版 `status` 每次查整个根,契约 v1 也没有按路径查询的参数。增量查询要等量出大工作副本的实际耗时后再加,要加就按能力版本加参数。 + +P3 第一部分已完成(2026-10-23),在 macOS 上: + +- 宿主在 `PluginManifest.hostCapabilities` 声明 `vcsProviders: [1]`,声明了 VCS Provider 的插件从这时起能安装。宿主读清单时给这类插件生成一个 `<插件 ID>.vcs-providers` 模块,只承载插件的启用状态,默认关闭,做法和构建系统插件的 `.build-systems` 模块一样。安装审查给三种 Provider 程序(插件内、系统已装、运行时启动)显示启动命令、`vcsTool` 里的客户端命令,以及 `mayUseNetwork` 声明的“可能连接仓库服务器”,让用户批准前知道插件会运行哪个 `svn`。 +- Provider 会话按窗口创建:启动时已安装的插件声明了 `vcsProviders`,`MacServiceContainer` 才建 `VcsProvidersFeatureModel`。启动后再安装的插件要重启才生效,工具窗口这时只显示“重启 Lithe 后使用”。这样做是因为服务在窗口组合时一次建好,运行中插桩会话要改动整个组合根,收益只是省一次重启。 +- 每个 Provider 贡献一个左侧工具窗口,宿主只认贡献上的 `vcsProviderPluginID`、`vcsProviderID` 两个可见性键,代码里没有任何 VCS 或插件的名字(与构建系统窗口同一套注册方式)。窗口按冲突、变更、未版本化文件分组:默认只勾选“变更”组,未版本化文件要用户勾选才提交,冲突文件不能勾选,要先“标记为已解决”;移动的文件提交时连同原路径一起提交,否则 SVN 会只提交一半。按钮是否出现看 Provider 协商的能力,不看 VCS 名字。 +- 与基线比较做成窗口级的弹出面板(`VcsProvidersFeatureModel.presentedDiff`,由 `RootView` 显示),不挂在工具窗口里,因为项目树右键的“显示差异”在工具窗口隐藏时也要能用。比较复用本地历史的 `LocalHistoryDiffBuilder` 和 Git 的 `DiffPaneView`;超过 2 万行时退成左右两栏纯文本,避免逐行比较卡住界面。 +- 项目树颜色:插件根的状态通过一个只观察 `VcsProvidersFeatureModel` 的包装视图传给项目树,项目树本身仍是 `.equatable()`,刷新状态不重绘整棵树。同一文件 Git 和插件都有状态时用 Git 的,因为第一期 Git 不在统一层,嵌套时以 Git 的显示为准,不让两套颜色互相覆盖。 +- 文件操作跟踪:工作区模块只多一个注入的 `moveTrackedItem` 回调,不认识任何 VCS。项目树里的重命名和移动先交给拥有该文件的 Provider(`svn move`);Provider 不跟踪这个文件或拒绝时,退回原来的磁盘移动。 +- 刷新:沿用工作区的文件变化事件,合并后再刷新受影响的根;元数据目录里只有 Provider 声明的 `metadataPaths`(SVN 是 `.svn/wc.db`)会触发刷新。宿主自己的请求执行期间和结束后的短暂时间内忽略元数据变化,否则每次提交都会再刷新一遍。出现或消失标记目录时重新识别根。 +- 歧义目录的选择按工作区存在应用偏好里(`Vcs.RootMappings.<工作区摘要>`),不写工作区,也不写 app bundle。识别时目录不再歧义,就丢掉旧选择。 + +P3 剩下的部分:编辑器 gutter 与 Annotate、交互式认证(`lithe/vcs/authenticate` 仍按未知请求回绝)、把 `lithe/vcs/execution` 执行报告接到项目控制台,以及在 Lithe Store 上架 SVN 插件。上架要等带 `vcsProviders` 能力的 Lithe 版本发布后再做,否则旧版本用户会在市场里看到装不上的插件;P1 合入后,插件仓库的 `sdk.lock.json` 也要改指向 `preview` 上的提交。 + ### 10. Git 什么时候、怎样接入 Git 接入不在本提案的范围内,这里只记下评审时已经确认的方向,供以后的提案使用: @@ -161,7 +189,11 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 ### SVN 内置到宿主 -首发更快,也不需要先做插件能力。但插件 VCS 这条链路要等到第三种 VCS 出现才会被真实验证,而且宿主里会出现 SVN 专属代码。用 SVN 作为第一个插件 Provider,可以在真实需求下验证整条插件链路,因此选择官方插件。 +首发更快,也不需要先做插件能力。但插件 VCS 这条链路要等到第三种 VCS 出现才会被真实验证,而且宿主里会出现 SVN 专属代码。用 SVN 作为第一个插件 Provider,可以在真实需求下验证整条插件链路,因此做成插件。 + +### SVN 作为官方插件留在主仓库 + +P2 的第一版就是这样:插件放在 `Plugins/mac/Official/SvnSupport/`,用 Lithe 的发布者私钥签名,进官方插件目录。好处是主仓库改 Core 时,SVN 端到端测试会一起跑。但插件每次修改都要走主仓库的 PR、CI 和官方签名发布,和 Lithe 的发版节奏绑在一起,主仓库 CI 还得装 svn;Go、PHP、Cargo 插件已经因为同样的原因搬到了独立仓库。改为社区插件后,插件可以独立发布,信任来自 Lithe Store 的审核、SHA-256 和安装确认框;契约回归由主仓库的样例 Provider 覆盖,SVN 自己的回归由插件仓库 CI 用固定版本 Core 覆盖。因此不采用。 ### 用 SVNKit 或 libsvn 嵌入 SVN @@ -174,7 +206,7 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 ## 验收标准 - P1:清单校验接受和拒绝的情况都有 fixture;根识别覆盖嵌套、歧义和 Git worktree 文件标记;插件回复的校验用例全部通过;用 Node SDK 写的假 Provider 经真实 Core 会话完成协商和每种请求,未协商成功的进程收不到 `lithe/vcs/*` 请求。 -- P2:SVN 插件对录制的 `svn status/info/log/blame --xml` 样例解析正确;在本地 `svnadmin create` 建立的 `file://` 仓库上完成状态、提交、更新、回滚、add/move/delete、Annotate、文件历史和冲突显示。 +- P2(在插件仓库 CI 中运行):SVN 插件对录制的 `svn status/info/log/blame --xml` 样例解析正确;在本地 `svnadmin create` 建立的 `file://` 仓库上完成状态、提交、更新、回滚、add/move/delete、Annotate、文件历史和冲突显示。 - P3:macOS 上安装 SVN 插件后完成安装审查、根识别、文件颜色、gutter、提交、回滚、更新和进程清理;停用或卸载插件后进程退出,它的根从界面消失;Git 项目的行为没有变化;密码不出现在进程参数、控制台和日志中。 - 每个阶段都运行对应的 Core、契约、Note 和平台验证脚本;Windows 的结论只来自 Windows 上的实际运行。 @@ -182,7 +214,7 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 - **统一层只被一种 VCS 验证过。** 第一期只有 SVN 用统一层,模型里可能藏着 SVN 的偏见。缓解办法:模型只收第 2 节表里 Git 和 SVN 都成立的概念,Git 专属的暂存维度已经作为可选能力进入 fixtures;Git 接入时如果发现缺口,按能力版本加字段。 - **Git 仍在快速变化。** Issue 评论里提到 Git 还没稳定。第一期不碰 Git,`git.*` 不冻结。 -- **状态读取是热路径。** SVN 在大工作副本上 `svn status` 较慢,由 Provider 按变化路径做增量查询,宿主合并刷新事件。 +- **状态读取是热路径。** SVN 在大工作副本上 `svn status` 较慢。第一版每次查整个根,宿主先合并刷新事件、限制刷新频率;实测太慢时再按能力版本加按路径的增量查询。 - **混合根和嵌套根。** Git 仓库里嵌套 SVN 工作副本时,文件必须只归属一个根,否则会重复显示或重复提交。识别规则和用户映射都要有 fixture 覆盖。 - **插件进程常驻的成本。** 只在识别到对应根时启动,关闭工作区即回收,规则与构建 Provider 相同。 - **回退方式。** 每个阶段都只新增路径,停用 SVN 插件即回到现状。 @@ -193,11 +225,15 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 - `rust/lithe-core/src/plugins/` - `rust/lithe-core/src/lsp/interface/` - `sdk/node/` +- [SVN 插件仓库](https://github.com/Lithe-IDEA/lithe-plugin-svn) - `rust/lithe-core/src/git/` - `rust/lithe-git-host/` - `macos/Sources/LitheGitModule/` - `macos/Sources/Lithe/Views/Git/` - `macos/Sources/Lithe/Views/Workspace/` +- `macos/Sources/Lithe/Views/Vcs/` +- `macos/Sources/Lithe/Platform/MacOS/Vcs/` +- `macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift` - `macos/Sources/Lithe/Views/Editor/` - `windows/tauri/src/features/git/` - `shared/contracts/plugins/v1/` diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index 6d96c37306..2e484d3698 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -47,6 +47,16 @@ Cargo-specific compatibility tests run in the plugin repository against a pinned Lithe Core through its public JSON API. This repository retains generic plugin contract tests; it does not keep Cargo community plugin snapshots. +## Subversion community plugin + +[Subversion](https://github.com/Lithe-IDEA/lithe-plugin-svn) +(`io.github.lithe-idea.svn-support`) is a VCS provider plugin maintained and +released independently in the same way. It needs a Lithe build that declares +the `vcsProviders` host capability, and runs the user's `svn` 1.8+ through a +Node SDK provider. Its svn output fixtures and Core compatibility tests live in +the plugin repository. This repository keeps the generic VCS provider contract +tests and the SDK's sample provider (`sdk/node/examples/sample-vcs`). + ## Downloadable official plugins No downloadable official plugin is configured right now: diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 34565a849c..325f2f8f32 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3704,3 +3704,72 @@ /* 内嵌 HTML 预览浏览器:右键菜单、打印和 PDF。 */ "Print…" = "打印…"; "Export as PDF…" = "导出为 PDF…"; + +/* 插件 VCS Provider:安装审核、工具窗口、项目树和差异比较。 */ +"%@ provider (inside the plugin)" = "%@ Provider(插件内置)"; +"%@ provider (installed on this Mac)" = "%@ Provider(使用本机已安装的程序)"; +"%1$@ provider (runs with %2$@)" = "%1$@ Provider(使用 %2$@ 运行)"; +"Client: %@" = "客户端:%@"; +"May connect to the repository server" = "可能连接仓库服务器"; +"Show %@ Diff" = "显示 %@ 差异"; +"%1$@ does not provide %2$@ because %3$@ already does." = "%3$@ 已提供 %2$@,因此不再使用 %1$@ 提供的版本。"; +"Committing…" = "正在提交…"; +"Rolling back…" = "正在回滚…"; +"Updating…" = "正在更新…"; +"Marking as resolved…" = "正在标记为已解决…"; +"Cleaning up…" = "正在清理…"; +"Moving…" = "正在移动…"; +"Wait for the running version control action to finish." = "请等待正在执行的版本控制操作完成。"; +"This working copy is no longer available. Refresh and try again." = "此工作副本已不可用。请刷新后重试。"; +"Authentication failed. Lithe cannot ask for credentials yet: sign in once with the command-line client so it stores them, then try again." = "认证失败。Lithe 暂时无法询问凭据:请先用命令行客户端登录一次并保存凭据,然后重试。"; +"The working copy is out of date. Update it, then commit again." = "工作副本已过期。请先更新,再重新提交。"; +"The action stopped on conflicted files. Resolve them, then try again." = "操作因冲突文件而停止。请先解决冲突,然后重试。"; +"The working copy is locked by an interrupted action. Run Cleanup, then try again." = "工作副本被中断的操作锁定。请先执行清理,然后重试。"; +"This working copy format is not supported. Upgrade it with the command-line client." = "不支持此工作副本格式。请用命令行客户端升级。"; +"The version control client could not be run. Check that it is installed." = "无法运行版本控制客户端。请检查它是否已安装。"; +"Lithe could not evaluate the version control data: %@" = "Lithe 无法解析版本控制数据:%@"; +"This version control provider does not support this action." = "此版本控制 Provider 不支持该操作。"; +"The version control provider failed to start." = "版本控制 Provider 启动失败。"; +"The version control provider stopped before it was ready." = "版本控制 Provider 在就绪前已停止。"; +"Version control provider" = "版本控制 Provider"; +"Missing" = "缺失"; +"Replaced" = "已替换"; +"Type changed" = "类型已更改"; +"Unversioned" = "未版本控制"; +"Ignored" = "已忽略"; +"Switched" = "已切换"; +"No revisions" = "没有修订版本"; +"More paths changed than are shown." = "更改的路径多于此处显示的数量。"; +"Open File" = "打开文件"; +"Binary files cannot be compared." = "无法比较二进制文件。"; +"The file is too large to compare." = "文件过大,无法比较。"; +"Base revision" = "基线版本"; +"Roll back the selected changes?" = "回滚选中的更改?"; +"Local edits to these files are lost." = "这些文件的本地修改将会丢失。"; +"Mark as Resolved" = "标记为已解决"; +"Clean Up Working Copy" = "清理工作副本"; +"More Actions" = "更多操作"; +"Working copy" = "工作副本"; +"%@ holds the metadata of several version control systems. Choose the one that manages it." = "%@ 包含多个版本控制系统的元数据。请选择管理它的版本控制系统。"; +"The workspace" = "工作区"; +"The workspace is too large to search completely; some working copies may be missing." = "工作区过大,无法完整搜索;可能有工作副本未被识别。"; +"There are too many changes to list; some are not shown." = "更改过多,部分未显示。"; +"Detecting working copies…" = "正在识别工作副本…"; +"No %@ working copy was detected in this workspace." = "此工作区中没有识别到 %@ 工作副本。"; +"Detect Again" = "重新识别"; +"Moved from %@" = "从 %@ 移动"; +"Tree conflict" = "树冲突"; +"Properties modified" = "属性已修改"; +"Locked in this working copy" = "已在此工作副本中锁定"; +"Locked by another user" = "已被其他用户锁定"; +"Show History" = "显示历史"; +"%lld files selected" = "已选择 %lld 个文件"; +"Committed revision %@." = "已提交修订版本 %@。"; +"Committed." = "已提交。"; +"Updated to revision %@." = "已更新到修订版本 %@。"; +"Updated." = "已更新。"; +"%lld files have conflicts." = "%lld 个文件存在冲突。"; +"The working copy was cleaned up." = "工作副本已清理。"; +"History of %@" = "%@ 的历史"; +"Show All" = "显示全部"; +"Restart Lithe to use %@." = "重启 Lithe 后即可使用 %@。"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index b61a30abda..f66eec1c1f 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -19,6 +19,8 @@ final class AppServices { /// Gives build providers that share a language server the language /// sessions; `nil` when the platform starts no build providers. var connectBuildProviderLanguageSessions: ((@escaping @MainActor () -> LanguageToolingSessionManager?) -> Void)? + /// Plugin VCS providers; `nil` when no installed plugin declares one. + var vcsProviders: VcsProvidersFeatureModel? let moduleRuntime: ModuleRuntime let pluginManager: any PluginManaging let pluginMarketplace: any PluginMarketplaceServicing diff --git a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift index a60d87d5a2..fa2107a67e 100644 --- a/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift +++ b/macos/Sources/Lithe/Application/Composition/WorkspaceProjectionComposition.swift @@ -53,6 +53,9 @@ enum WorkspaceProjectionComposition { guard let feature = await model?.activateHistoryModule() else { return } await feature.relocateHistory(from: source, to: destination) }, + moveTrackedItem: { [weak model] source, destination in + await model?.vcsProvidersFeature?.move(from: source, to: destination) ?? false + }, relocateOpenDocuments: { [weak document] source, destination in document?.relocateOpenDocuments(from: source, to: destination) }, diff --git a/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift new file mode 100644 index 0000000000..c4cdda7911 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift @@ -0,0 +1,717 @@ +import Combine +import Foundation +import LitheCoreContracts + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// The user's choices for ambiguous directories, per workspace. +protocol VcsRootMappingStoring { + func mappings(workspaceURL: URL) -> [VcsRootMapping] + func setMappings(_ mappings: [VcsRootMapping], workspaceURL: URL) +} + +/// A working-copy root a plugin provider owns, with the status last read. +struct VcsRootState: Identifiable, Equatable { + let root: VcsRoot + let pluginID: String + /// The provider's display name, such as "Subversion". + let displayName: String + /// Built from the workspace URL, so paths below it match the project + /// tree's even when Core reports the canonical path. + let url: URL + let metadataPaths: [String] + /// Directory markers of the VCS, such as `.svn`; files below them are + /// metadata, not working files. + let metadataDirectories: Set + var negotiation: VcsProviderNegotiation? + var snapshot: VcsStatusSnapshot? + var isRefreshing = false + var failure: String? + + var id: String { root.id } + + /// The workspace folder's name for the workspace root and enclosing + /// working copies; the relative path for nested ones. + var title: String { + root.id.isEmpty || root.id.hasPrefix("..") ? url.lastPathComponent : root.id + } + + func supports(_ capability: VcsCapability) -> Bool { + negotiation?.supports(capability) == true + } +} + +/// One file compared with its base revision. +struct VcsFileDiff: Identifiable, Equatable { + let id = UUID() + let rootID: String + let path: String + let fileURL: URL + /// Empty for a file with no base; nil when it could not be shown. + let base: String? + let current: String? + let isBinary: Bool + let isTooLarge: Bool +} + +/// Status of project tree rows inside plugin-owned roots, by absolute path. +/// Rows below an unversioned or ignored directory share its status. +struct VcsTreeStatusProjection: Equatable { + private var statuses: [String: VcsChangeStatus] = [:] + private var containers: [String: VcsChangeStatus] = [:] + + init(_ statuses: [String: VcsChangeStatus] = [:]) { + self.statuses = statuses + containers = statuses.filter { $0.value == .unversioned || $0.value == .ignored } + } + + var isEmpty: Bool { statuses.isEmpty } + + func status(for url: URL) -> VcsChangeStatus? { + let path = url.standardizedFileURL.path + if let status = statuses[path] { return status } + guard !containers.isEmpty else { return nil } + var ancestor = Substring(path) + while let separator = ancestor.lastIndex(of: "/"), separator > ancestor.startIndex { + ancestor = ancestor[.. [VcsProviderPluginSource] + private let readFile: @MainActor (URL) -> Data? + private let sleep: @Sendable (Duration) async throws -> Void + private let now: @Sendable () -> ContinuousClock.Instant + + /// Bumped whenever the workspace changes, so late results are dropped. + private var generation = 0 + /// Every detected root, Git included, for ownership. + private var allRootIDs: [String] = [] + private var markerNames: Set = [] + private var detectionTask: Task? + private var refreshTasks: [String: Task] = [:] + private var pendingRefreshes: Set = [] + private var scheduledTask: Task? + private var scheduledRoots: Set = [] + private var scheduledDetection = false + private var activeRequests = 0 + private var metadataQuietUntil: ContinuousClock.Instant? + private var rootTreeStatuses: [String: [String: VcsChangeStatus]] = [:] + /// Bumped per root on every status read, so an older ownership answer + /// cannot replace a newer one. + private var treeRevisions: [String: Int] = [:] + + init( + planner: any VcsRootPlanning, + providers: any VcsProviderRequesting, + mappingStore: any VcsRootMappingStoring, + readFile: @escaping @MainActor (URL) -> Data? = { _ in nil }, + sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }, + now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, + sources: @escaping @MainActor () -> [VcsProviderPluginSource] + ) { + self.planner = planner + self.providers = providers + self.mappingStore = mappingStore + self.readFile = readFile + self.sleep = sleep + self.now = now + self.sources = sources + } + + /// True when an enabled plugin declares a VCS provider, detected or not. + var hasProviders: Bool { !sources().isEmpty } + + func root(_ id: String) -> VcsRootState? { + roots.first { $0.id == id } + } + + // MARK: Workspace + + /// Forgets the previous workspace, stops its providers, and detects the + /// new one. + func reset(workspaceURL: URL?) { + generation += 1 + detectionTask?.cancel(); detectionTask = nil + refreshTasks.values.forEach { $0.cancel() } + refreshTasks = [:] + scheduledTask?.cancel(); scheduledTask = nil + pendingRefreshes = []; scheduledRoots = []; scheduledDetection = false + self.workspaceURL = workspaceURL?.standardizedFileURL + roots = []; ambiguous = []; issues = []; vcsNames = [:] + allRootIDs = []; markerNames = [] + isTruncated = false; isDetecting = false + runningOperation = nil; failure = nil + rootTreeStatuses = [:]; treeStatus = VcsTreeStatusProjection() + commitMessageDrafts = [:]; presentedDiff = nil + retainProviders() + detect() + } + + /// Detects the roots again with the plugins enabled now. Status already + /// read is kept for roots that stay. + func detect() { + guard let workspaceURL else { return } + detectionTask?.cancel() + let sources = sources() + guard !sources.isEmpty else { + isDetecting = false + apply(nil, workspaceURL: workspaceURL, sources: [], mappings: []) + return + } + isDetecting = true + let mappings = mappingStore.mappings(workspaceURL: workspaceURL) + let planner = planner + let generation = generation + detectionTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + Result { + try planner.detectRoots( + workspaceURL: workspaceURL, manifests: sources.map(\.manifest), mappings: mappings + ) + } + }.value + guard let self, self.generation == generation, !Task.isCancelled else { return } + self.detectionTask = nil + self.isDetecting = false + switch result { + case .success(let detection): + self.apply(detection, workspaceURL: workspaceURL, sources: sources, mappings: mappings) + case .failure(let error): + self.apply(nil, workspaceURL: workspaceURL, sources: sources, mappings: []) + self.issues = [Self.message(for: error)] + } + } + } + + /// Stops what a now disabled plugin started and detects again. + func pluginsChanged() { + retainProviders() + detect() + } + + /// Chooses the VCS of an ambiguous directory and detects again. + func map(_ directoryID: String, to vcs: String) { + guard let workspaceURL else { return } + var mappings = mappingStore.mappings(workspaceURL: workspaceURL).filter { $0.id != directoryID } + mappings.append(VcsRootMapping(id: directoryID, vcs: vcs)) + mappingStore.setMappings(mappings, workspaceURL: workspaceURL) + detect() + } + + private func apply( + _ detection: VcsDetection?, + workspaceURL: URL, + sources: [VcsProviderPluginSource], + mappings: [VcsRootMapping] + ) { + let plugins = Dictionary( + (detection?.providers ?? []).compactMap { provider -> (String, VcsProviderDescriptor)? in + guard provider.origin.kind == "plugin", provider.origin.pluginID != nil else { return nil } + return (provider.vcs, provider) + }, + uniquingKeysWith: { first, _ in first } + ) + vcsNames = Dictionary( + (detection?.providers ?? []).map { ($0.vcs, $0.displayName) }, + uniquingKeysWith: { first, _ in first } + ) + markerNames = Set(plugins.values.flatMap { $0.markers.map(\.name) }) + let previous = Dictionary(uniqueKeysWithValues: roots.map { ($0.id, $0) }) + roots = (detection?.roots ?? []).compactMap { root in + guard let provider = plugins[root.vcs], let pluginID = provider.origin.pluginID else { return nil } + var state = VcsRootState( + root: root, pluginID: pluginID, displayName: provider.displayName, + url: Self.rootURL(id: root.id, workspaceURL: workspaceURL), + metadataPaths: provider.metadataPaths ?? [], + metadataDirectories: Set(provider.markers.filter { $0.type == "directory" }.map(\.name)) + ) + if let old = previous[root.id], old.root.vcs == root.vcs, old.pluginID == pluginID { + state.negotiation = old.negotiation + state.snapshot = old.snapshot + state.isRefreshing = old.isRefreshing + state.failure = old.failure + } + return state + } + allRootIDs = detection?.roots.map(\.id) ?? [] + ambiguous = detection?.ambiguous ?? [] + isTruncated = detection?.truncated ?? false + issues = (detection?.rejected ?? []).map { rejected in + let pluginID = rejected.index < sources.count ? sources[rejected.index].pluginID : "?" + return "\(pluginID): \(rejected.error.message)" + } + (detection?.conflicts ?? []).map { conflict in + String( + format: String(localized: "%1$@ does not provide %2$@ because %3$@ already does."), + conflict.pluginID, vcsNames[conflict.vcs] ?? conflict.vcs, conflict.by + ) + } + let kept = Set(roots.map(\.id)) + rootTreeStatuses = rootTreeStatuses.filter { kept.contains($0.key) } + publishTreeStatus() + if let unused = detection?.unusedMappings, !unused.isEmpty { + mappingStore.setMappings(mappings.filter { !unused.contains($0) }, workspaceURL: workspaceURL) + } + retainProviders() + roots.forEach { refresh($0.id) } + } + + /// Stops processes of other workspaces, disabled or replaced plugins, + /// and VCSs with no root left. + private func retainProviders() { + let workspaceURL = workspaceURL + let packages = Set(sources().map { [$0.pluginID, $0.packageURL.standardizedFileURL.path] }) + let used = Set(roots.map { [$0.pluginID, $0.root.vcs] }) + providers.retainProviders { target in + target.workspaceURL == workspaceURL + && packages.contains([target.pluginID, target.packageURL.standardizedFileURL.path]) + && used.contains([target.pluginID, target.vcs]) + } + } + + // MARK: File changes + + /// Refreshes the roots whose files changed, shortly after the last + /// change. Metadata changes count only for the provider's declared + /// metadata paths, and not while the host's own requests write them. + /// A marker directory appearing or going away detects again. + func workspaceFilesChanged(_ urls: [URL]) { + guard workspaceURL != nil, hasProviders else { return } + let quiet = activeRequests > 0 || metadataQuietUntil.map { now() < $0 } == true + let byDepth = roots.sorted { $0.url.path.count > $1.url.path.count } + var affected: Set = [] + var redetect = false + for url in urls { + let path = url.standardizedFileURL.path + if markerNames.contains(url.lastPathComponent), !quiet { redetect = true } + guard let root = byDepth.first(where: { path.hasPrefix($0.url.path + "/") }) else { continue } + let relative = String(path.dropFirst(root.url.path.count + 1)) + let first = relative.split(separator: "/", maxSplits: 1).first.map(String.init) ?? relative + if root.metadataDirectories.contains(first) { + guard !quiet, root.metadataPaths.contains(where: { + relative == $0 || relative.hasPrefix($0 + "/") + }) else { continue } + } + affected.insert(root.id) + } + guard redetect || !affected.isEmpty else { return } + scheduledRoots.formUnion(affected) + scheduledDetection = scheduledDetection || redetect + scheduledTask?.cancel() + let sleep = sleep + let generation = generation + scheduledTask = Task { [weak self] in + do { try await sleep(Self.refreshDelay) } catch { return } + guard let self, self.generation == generation, !Task.isCancelled else { return } + let roots = self.scheduledRoots + let detects = self.scheduledDetection + self.scheduledRoots = []; self.scheduledDetection = false; self.scheduledTask = nil + // Detection refreshes every root it keeps. + if detects { self.detect() } else { roots.forEach(self.refresh) } + } + } + + // MARK: Status + + func refreshAll() { + roots.forEach { refresh($0.id) } + } + + /// Reads the root's status. A refresh asked for while one runs, or + /// while an action changes the root, runs once that ends. + func refresh(_ rootID: String) { + guard let index = index(of: rootID) else { return } + guard refreshTasks[rootID] == nil, runningOperation?.rootID != rootID else { + pendingRefreshes.insert(rootID) + return + } + roots[index].isRefreshing = true + let generation = generation + refreshTasks[rootID] = Task { [weak self] in + guard let self else { return } + var snapshot: VcsStatusSnapshot? + var failure: Error? + do { + snapshot = try await self.request( + .status(includeIgnored: false), rootID: rootID, timeout: Self.statusTimeout, + as: VcsStatusSnapshot.self + ) + } catch { + failure = error + } + guard self.generation == generation else { return } + self.refreshTasks[rootID] = nil + if let index = self.index(of: rootID) { + self.roots[index].isRefreshing = false + if let snapshot { + self.roots[index].snapshot = snapshot + self.roots[index].failure = nil + self.updateTreeStatus(rootID: rootID, snapshot: snapshot) + } else if let failure, !(failure is CancellationError) { + self.roots[index].failure = Self.message(for: failure) + } + } + if self.pendingRefreshes.remove(rootID) != nil { self.refresh(rootID) } + } + } + + /// Projects the root's changes onto the project tree. Changes that + /// belong to a nested root, such as a Git repository inside a working + /// copy, or that lie outside the workspace, are left out. + private func updateTreeStatus(rootID: String, snapshot: VcsStatusSnapshot) { + guard let workspaceURL else { return } + var paths: [String] = [] + var statuses: [VcsChangeStatus] = [] + for change in snapshot.changes where change.status != .ignored { + guard let path = Self.workspacePath(change.path, rootID: rootID, workspaceURL: workspaceURL), + !path.isEmpty else { continue } + paths.append(path) + statuses.append(change.hasTreeConflict ? .conflicted : change.status) + } + let revision = (treeRevisions[rootID] ?? 0) + 1 + treeRevisions[rootID] = revision + let planner = planner + let rootIDs = allRootIDs + let ambiguousIDs = ambiguous.map(\.id) + let generation = generation + Task { [weak self] in + let owners = await Task.detached(priority: .utility) { + try? planner.owners(roots: rootIDs, ambiguous: ambiguousIDs, paths: paths) + }.value + guard let self, self.generation == generation, self.treeRevisions[rootID] == revision, + self.index(of: rootID) != nil else { return } + var entries: [String: VcsChangeStatus] = [:] + for (index, path) in paths.enumerated() where owners.map({ $0[index] == rootID }) ?? true { + entries[workspaceURL.appendingPathComponent(path).standardizedFileURL.path] = statuses[index] + } + self.rootTreeStatuses[rootID] = entries + self.publishTreeStatus() + } + } + + private func publishTreeStatus() { + let merged = rootTreeStatuses.values.reduce(into: [String: VcsChangeStatus]()) { result, entries in + result.merge(entries) { current, _ in current } + } + let projection = VcsTreeStatusProjection(merged) + if projection != treeStatus { treeStatus = projection } + } + + // MARK: Actions + + /// Commits `paths`, root-relative. The provider adds selected + /// unversioned files and removes missing ones as part of the check-in. + func checkin(_ paths: [String], message: String, in rootID: String) async -> VcsCheckinResult? { + await perform(.checkin(paths: paths, message: message), in: rootID, + title: String(localized: "Committing…"), as: VcsCheckinResult.self) + } + + func rollback(_ paths: [String], in rootID: String) async -> Bool { + await perform(.rollback(paths: paths), in: rootID, + title: String(localized: "Rolling back…"), as: VcsEmptyResult.self) != nil + } + + func update(_ rootID: String) async -> VcsUpdateResult? { + await perform(.update(revision: nil), in: rootID, + title: String(localized: "Updating…"), as: VcsUpdateResult.self) + } + + func resolve(_ paths: [String], in rootID: String) async -> Bool { + await perform(.resolve(paths: paths), in: rootID, + title: String(localized: "Marking as resolved…"), as: VcsEmptyResult.self) != nil + } + + func cleanup(_ rootID: String) async -> Bool { + await perform(.cleanup, in: rootID, + title: String(localized: "Cleaning up…"), as: VcsEmptyResult.self) != nil + } + + func baseContent(_ path: String, in rootID: String) async throws -> VcsBaseContent { + try await request(.baseContent(path: path), rootID: rootID, timeout: Self.statusTimeout, as: VcsBaseContent.self) + } + + func history(in rootID: String, cursor: String?) async throws -> VcsHistoryPage { + try await request( + .history(cursor: cursor, limit: Self.historyPageSize), rootID: rootID, + timeout: Self.operationTimeout, as: VcsHistoryPage.self + ) + } + + func fileHistory(_ path: String, in rootID: String, cursor: String?) async throws -> VcsHistoryPage { + try await request( + .fileHistory(path: path, cursor: cursor, limit: Self.historyPageSize), rootID: rootID, + timeout: Self.operationTimeout, as: VcsHistoryPage.self + ) + } + + /// The change's base revision next to the file on disk. A file with no + /// base compares with nothing, and so does a deleted one. + func diff(for change: VcsChange, in rootID: String) async throws -> VcsFileDiff { + guard let root = root(rootID) else { throw CancellationError() } + let fileURL = root.url.appendingPathComponent(change.path).standardizedFileURL + var current: String? = "" + var isBinary = false + var isTooLarge = false + if ![.deleted, .missing].contains(change.status) { + let data = readFile(fileURL) + isTooLarge = (data?.count ?? 0) > Self.diffByteLimit + current = isTooLarge ? nil : data.flatMap { String(data: $0, encoding: .utf8) } + isBinary = data != nil && !isTooLarge && current == nil + } + var base: String? = "" + if ![.added, .unversioned, .ignored].contains(change.status), !isBinary, !isTooLarge { + let content = try await baseContent(change.path, in: rootID) + isBinary = content.binary + isTooLarge = (content.content?.utf8.count ?? 0) > Self.diffByteLimit + base = isBinary || isTooLarge ? nil : content.content ?? "" + } + return VcsFileDiff( + rootID: rootID, path: change.path, fileURL: fileURL, + base: base, current: isBinary || isTooLarge ? nil : current, + isBinary: isBinary, isTooLarge: isTooLarge + ) + } + + /// Loads the change's diff and presents it; a failure is reported. + func showDiff(_ change: VcsChange, in rootID: String) { + let generation = generation + Task { + do { + let diff = try await diff(for: change, in: rootID) + if self.generation == generation { presentedDiff = diff } + } catch is CancellationError { + } catch { + if self.generation == generation { report(Self.message(for: error), rootID: rootID) } + } + } + } + + /// The change the last status of `url`'s root lists for it, for the + /// project tree. + func change(at url: URL) -> (root: VcsRootState, change: VcsChange)? { + guard let (root, path) = rootPath(of: url), !path.isEmpty, + let change = root.snapshot?.changes.first(where: { $0.path == path }) else { return nil } + return (root, change) + } + + /// Moves a project item through the provider of the root that holds + /// both locations, so the VCS records the move. Returns false when no + /// such root tracks the item, or the provider refused; the caller then + /// moves it on disk, and a refusal is reported. + func move(from source: URL, to destination: URL) async -> Bool { + guard runningOperation == nil, + let (root, from) = rootPath(of: source), let (destinationRoot, to) = rootPath(of: destination), + root.id == destinationRoot.id, root.supports(.fileOperations), !from.isEmpty, !to.isEmpty, + !isUnversioned(source, in: root), !isUnversioned(destination.deletingLastPathComponent(), in: root) + else { return false } + return await perform(.move(from: from, to: to), in: root.id, + title: String(localized: "Moving…"), as: VcsEmptyResult.self) != nil + } + + func dismissFailure() { + failure = nil + } + + func report(_ message: String, rootID: String?) { + failure = Failure(rootID: rootID, message: message) + } + + /// Runs an action that changes the working copy, then reads its status + /// again. A failure is reported through `failure`. + private func perform( + _ operation: VcsOperation, + in rootID: String, + title: String, + as type: Value.Type + ) async -> Value? { + guard runningOperation == nil else { + report(String(localized: "Wait for the running version control action to finish."), rootID: rootID) + return nil + } + runningOperation = RunningOperation(rootID: rootID, title: title) + failure = nil + let generation = generation + defer { + if self.generation == generation { + runningOperation = nil + refresh(rootID) + } + } + do { + return try await request(operation, rootID: rootID, timeout: Self.operationTimeout, as: type) + } catch { + if self.generation == generation, !(error is CancellationError) { + report(Self.message(for: error), rootID: rootID) + } + return nil + } + } + + /// Sends one operation to the root's provider and has Core validate the + /// reply. + private func request( + _ operation: VcsOperation, + rootID: String, + timeout: Duration, + as type: Value.Type + ) async throws -> Value { + guard let state = root(rootID), let target = target(for: state) else { + throw VcsProviderFailure( + code: "vcs.provider.unavailable", + message: String(localized: "This working copy is no longer available. Refresh and try again.") + ) + } + let generation = generation + activeRequests += 1 + defer { + activeRequests -= 1 + metadataQuietUntil = now() + Self.metadataQuietPeriod + } + let reply = try await providers.send(operation, root: state.root.path, to: target, timeout: timeout) + guard self.generation == generation else { throw CancellationError() } + if let index = index(of: rootID), roots[index].negotiation != reply.negotiation { + roots[index].negotiation = reply.negotiation + } + let planner = planner + return try await Task.detached(priority: .userInitiated) { + try planner.decodeReply( + operation, capabilities: reply.negotiation.capabilities, reply: reply.value, as: Value.self + ) + }.value + } + + private func target(for state: VcsRootState) -> VcsProviderTarget? { + guard let workspaceURL, + let source = sources().first(where: { $0.pluginID == state.pluginID }) else { return nil } + return VcsProviderTarget( + pluginID: source.pluginID, vcs: state.root.vcs, manifest: source.manifest, + packageURL: source.packageURL, workspaceURL: workspaceURL + ) + } + + private func index(of rootID: String) -> Int? { + roots.firstIndex { $0.id == rootID } + } + + // MARK: Paths + + /// The deepest plugin root holding `url`, with the root-relative path. + func rootPath(of url: URL) -> (VcsRootState, String)? { + let path = url.standardizedFileURL.path + let byDepth = roots.sorted { $0.url.path.count > $1.url.path.count } + for root in byDepth { + if path == root.url.path { return (root, "") } + if path.hasPrefix(root.url.path + "/") { + return (root, String(path.dropFirst(root.url.path.count + 1))) + } + } + return nil + } + + private func isUnversioned(_ url: URL, in root: VcsRootState) -> Bool { + guard let status = treeStatus.status(for: url) else { return false } + return status == .unversioned || status == .ignored + } + + static func rootURL(id: String, workspaceURL: URL) -> URL { + id.isEmpty ? workspaceURL.standardizedFileURL : workspaceURL.appendingPathComponent(id).standardizedFileURL + } + + /// The workspace-relative path of a root-relative one; nil outside the + /// workspace, which only an enclosing working copy can report. + nonisolated static func workspacePath(_ path: String, rootID: String, workspaceURL: URL) -> String? { + guard rootID.hasPrefix("..") else { return rootID.isEmpty ? path : rootID + "/" + path } + let levels = rootID.split(separator: "/").count + let inside = workspaceURL.standardizedFileURL.pathComponents.suffix(levels).joined(separator: "/") + if path == inside { return "" } + return path.hasPrefix(inside + "/") ? String(path.dropFirst(inside.count + 1)) : nil + } + + // MARK: Messages + + /// The provider's message, after a hint for the failures the contract + /// names. + static func message(for error: Error) -> String { + guard let failure = error as? VcsProviderFailure else { return error.localizedDescription } + let hint: String? = switch failure.code { + case "vcs.authentication.failed": + String(localized: "Authentication failed. Lithe cannot ask for credentials yet: sign in once with the command-line client so it stores them, then try again.") + case "vcs.outOfDate": + String(localized: "The working copy is out of date. Update it, then commit again.") + case "vcs.conflict": + String(localized: "The action stopped on conflicted files. Resolve them, then try again.") + case "vcs.workingCopy.locked": + String(localized: "The working copy is locked by an interrupted action. Run Cleanup, then try again.") + case "vcs.workingCopy.unsupported": + String(localized: "This working copy format is not supported. Upgrade it with the command-line client.") + case "vcs.tool.unavailable": + String(localized: "The version control client could not be run. Check that it is installed.") + default: + nil + } + guard let hint else { return failure.message } + return failure.message.isEmpty ? hint : hint + "\n" + failure.message + } +} diff --git a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift index bfef1676d8..4d84321261 100644 --- a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift @@ -39,6 +39,7 @@ extension LitheWorkspaceModule.WorkspaceFeatureModel { notify: @escaping @MainActor @Sendable (String) -> Void, recordHistory: @escaping @MainActor @Sendable (URL, LocalHistoryReason) async -> Void, relocateHistory: @escaping @MainActor @Sendable (URL, URL) async -> Void, + moveTrackedItem: @escaping @MainActor @Sendable (URL, URL) async -> Bool = { _, _ in false }, relocateOpenDocuments: @escaping @MainActor @Sendable (URL, URL) -> Void, closeDocuments: @escaping @MainActor @Sendable (URL) -> Void, processExternalChanges: @escaping @MainActor @Sendable ([URL]) -> Bool, @@ -68,6 +69,7 @@ extension LitheWorkspaceModule.WorkspaceFeatureModel { notify: notify, recordHistory: recordHistory, relocateHistory: relocateHistory, + moveTrackedItem: moveTrackedItem, relocateOpenDocuments: relocateOpenDocuments, closeDocuments: closeDocuments, processExternalChanges: processExternalChanges, diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 0769932e3f..cc1a306c8d 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2034,6 +2034,16 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let timeoutMilliseconds: Int } + private struct LspVcsRequest: Encodable { + let sessionId: String + let vcs: String + let operation: String + let root: String + let tool: String? + let arguments: ToolingJSONValue + let timeoutMilliseconds: Int + } + private struct LspFunctionRequest: Encodable { struct Reference: Encodable { let pluginID: String @@ -3923,6 +3933,32 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } + /// Sends one VCS-provider operation. Core plans the method and params + /// and refuses the request unless the session negotiated a provider of + /// `vcs` with the operation's capability. + func lspVcsRequest( + sessionID: String, + vcs: String, + operation: String, + root: String, + tool: String?, + arguments: ToolingJSONValue, + timeout: TimeInterval + ) -> Result { + executeResult( + command: "lsp.vcsRequest", + payload: LspVcsRequest( + sessionId: sessionID, + vcs: vcs, + operation: operation, + root: root, + tool: tool, + arguments: arguments, + 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/RustDeclarativePluginOperations.swift b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift index ef25cf02f0..d155dc9268 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift @@ -122,6 +122,20 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec )) } + func vcsProviderLaunchPlan( + manifest: Data, + vcs: String, + request: DeclarativeLanguageServerLaunchRequest + ) throws -> DeclarativeLanguageServerLaunchPlan { + try launchPlan(LaunchPlanPayload( + manifest: try Self.json(manifest), + languageID: nil, + buildSystemID: nil, + vcsProviderID: vcs, + request: LaunchRequest(request) + )) + } + private func launchPlan(_ payload: LaunchPlanPayload) throws -> DeclarativeLanguageServerLaunchPlan { let outcome: Outcome = try execute( "plugins.declarative.languageServerLaunchPlan", @@ -162,6 +176,14 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec } ) result.workspaceConfiguration = plan.workspaceConfiguration + result.vcsProvider = plan.vcsProvider.map { + .init( + vcs: $0.vcs, + toolCommands: $0.tool?.commands ?? [], + toolValidationArguments: $0.tool?.validationArguments ?? [], + mayUseNetwork: $0.mayUseNetwork ?? false + ) + } return result } @@ -370,6 +392,18 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec let requiredDirectories: [String] let requiredPackagePaths: [String] let buildProvider: BuildProvider? + let vcsProvider: VcsProvider? + + struct VcsProvider: Decodable { + struct Tool: Decodable { + let commands: [String] + let validationArguments: [String]? + } + + let vcs: String + let tool: Tool? + let mayUseNetwork: Bool? + } struct BuildProvider: Decodable { let buildSystemID: String @@ -405,6 +439,7 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec let manifest: ToolingJSONValue let languageID: String? let buildSystemID: String? + var vcsProviderID: String? = nil let request: LaunchRequest } diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index 79ad280000..c03d81498b 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -218,6 +218,23 @@ extension RustCoreBridge: LanguageServerRuntimeCore { .mapError(Self.runtimeFailure) } + func requestVcsProvider( + sessionID: String, + vcs: String, + operation: String, + root: String, + tool: String?, + arguments: ToolingJSONValue, + timeout: TimeInterval + ) -> Result { + lspVcsRequest( + sessionID: sessionID, vcs: vcs, operation: operation, root: root, tool: tool, + arguments: arguments, timeout: timeout + ) + .map { LanguageServerRuntimeOperation(operationID: $0.operationId) } + .mapError(Self.runtimeFailure) + } + func startPluginLanguageServer( pluginID: String, providerID: String, diff --git a/macos/Sources/Lithe/Core/Rust/RustVcsOperations.swift b/macos/Sources/Lithe/Core/Rust/RustVcsOperations.swift new file mode 100644 index 0000000000..d111517621 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustVcsOperations.swift @@ -0,0 +1,151 @@ +import Foundation +import LitheCoreContracts + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// Typed `vcs.*` operations. Rust Core owns root detection, ownership, and +/// reply validation for both products; this adapter only translates +/// payloads and outcomes. +struct RustVcsOperations: VcsRootPlanning { + let core: RustCoreBridge + + func detectRoots( + workspaceURL: URL, + manifests: [Data], + mappings: [VcsRootMapping] + ) throws -> VcsDetection { + let outcome: Outcome = try execute( + "vcs.detectRoots", + payload: DetectPayload( + workspaceRoot: workspaceURL.standardizedFileURL.path, + plugins: try manifests.map { DetectPayload.Plugin(manifest: try Self.json($0)) }, + mappings: mappings + ) + ) + return outcome.value.detection + } + + func owners(roots: [String], ambiguous: [String], paths: [String]) throws -> [String?] { + guard !paths.isEmpty else { return [] } + let outcome: Outcome = try execute( + "vcs.ownerOf", + payload: OwnerPayload(roots: roots, ambiguous: ambiguous, paths: paths) + ) + return outcome.value.owners.map(\.root) + } + + func decodeReply( + _ operation: VcsOperation, + capabilities: [String: Int], + reply: ToolingJSONValue, + as type: Value.Type + ) throws -> Value { + let outcome: Outcome> = try execute( + "vcs.decodeReply", + payload: DecodePayload( + operation: operation.name, + capabilities: capabilities, + request: operation.arguments, + reply: reply + ) + ) + return outcome.value.result + } + + private func execute( + _ command: String, + payload: Payload + ) throws -> Outcome { + let outcome: Outcome + do { + outcome = try core.executeResult(command: command, payload: payload).get() + } catch { + throw DeclarativePluginRejection( + code: "vcs.provider.failed", + message: String(localized: "Lithe could not evaluate the version control data: \(error.localizedDescription)") + ) + } + if let rejection = outcome.error { + throw VcsProviderFailure(code: rejection.code, message: rejection.message) + } + return outcome + } + + private static func json(_ data: Data) throws -> ToolingJSONValue { + do { + return try JSONDecoder().decode(ToolingJSONValue.self, from: data) + } catch { + throw DeclarativePluginRejection(code: "plugin.manifest.invalid", message: "plugin.json is not valid JSON.") + } + } + + // MARK: Wire types + + private struct DetectPayload: Encodable { + struct Plugin: Encodable { + let manifest: ToolingJSONValue + } + + let workspaceRoot: String + let plugins: [Plugin] + let mappings: [VcsRootMapping] + } + + private struct DetectionResult: Decodable { + let detection: VcsDetection + } + + private struct OwnerPayload: Encodable { + let roots: [String] + let ambiguous: [String] + let paths: [String] + } + + private struct OwnersResult: Decodable { + struct Owner: Decodable { + let root: String? + } + + let owners: [Owner] + } + + private struct DecodePayload: Encodable { + let operation: String + let capabilities: [String: Int] + let request: ToolingJSONValue + let reply: ToolingJSONValue + } + + private struct DecodedReply: Decodable { + let result: Value + } + + private struct Rejection: Decodable { + let code: String + let message: String + } + + /// `{status: "ok", …}` with the value at the top level, or + /// `{status: "rejected", error}`. + private struct Outcome: Decodable { + let error: Rejection? + private let decodedValue: Value? + + var value: Value { decodedValue! } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if try container.decode(String.self, forKey: .status) == "rejected" { + error = try container.decode(Rejection.self, forKey: .error) + decodedValue = nil + } else { + error = nil + decodedValue = try Value(from: decoder) + } + } + + private enum CodingKeys: String, CodingKey { + case status, error + } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift index a931b5fcdd..cb72079ad8 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -201,6 +201,7 @@ extension AppModel { guard let workspaceURL else { return } runFeatureIfActive?.markDependencyFilesChanged(changes) handleBuildSystemFileChanges(changes) + handleVcsProviderFileChanges(changes) let maven = mavenFeatureIfActive let forwarded = changes.filter { change in if Self.isWorkspaceDependencyMetadata( diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift index 98ab3d9e0f..639ee91191 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift @@ -40,6 +40,7 @@ extension AppModel { do { try await services.pluginManager.uninstall(pluginID) buildSystemsFeatureIfActive?.pluginsChanged() + vcsProvidersFeature?.pluginsChanged() closeStalePluginWebPages() objectWillChange.send() } catch { @@ -72,6 +73,7 @@ extension AppModel { } if !appliedPluginIDs.isEmpty { buildSystemsFeatureIfActive?.pluginsChanged() + vcsProvidersFeature?.pluginsChanged() closeStalePluginWebPages() } objectWillChange.send() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift index 8f4df738d3..d82c636dad 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift @@ -143,6 +143,7 @@ extension AppModel { ($0.url.standardizedFileURL, $0.text) }) ) + loadVcsProviders(at: target) guard let execution = await activateExecutionModule() else { return } guard isCurrentWorkspace(identity) else { return } loadBuildSystems(execution) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift new file mode 100644 index 0000000000..4cd1460459 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift @@ -0,0 +1,22 @@ +import Foundation +import LitheCoreContracts + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +@MainActor +extension AppModel { + /// Plugin VCS providers; nil when no installed plugin declares one. + var vcsProvidersFeature: VcsProvidersFeatureModel? { services.vcsProviders } + + /// Detects the workspace's plugin VCS roots once per workspace; later + /// changes arrive through `handleVcsProviderFileChanges`. + func loadVcsProviders(at workspaceURL: URL) { + guard let feature = vcsProvidersFeature, + feature.workspaceURL != workspaceURL.standardizedFileURL else { return } + feature.reset(workspaceURL: workspaceURL) + } + + func handleVcsProviderFileChanges(_ changes: [WorkspaceFileChange]) { + vcsProvidersFeature?.workspaceFilesChanged(changes.map(\.fileURL)) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 05e17da80a..4bcb5bb2ce 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -944,6 +944,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { tests: languageTestServiceIfActive, buildSystems: buildSystemsFeatureIfActive ) + vcsProvidersFeature?.reset(workspaceURL: nil) runWorkflowCoordinator.resetPendingAction() debugModuleCoordinator.resetFeature(genericDebugFeatureIfActive) debugBreakpointPresentation.reset() @@ -1010,6 +1011,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { tests: languageTestServiceIfActive, buildSystems: buildSystemsFeatureIfActive ) + vcsProvidersFeature?.reset(workspaceURL: nil) runWorkflowCoordinator.resetPendingAction() debugModuleCoordinator.resetFeature(genericDebugFeatureIfActive) debugBreakpointPresentation.reset() diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 898b37fe63..1b93079230 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -264,6 +264,16 @@ final class MacServiceContainer { core: rustCore, processRegistry: processRegistry ) + // Provider processes of plugin VCS providers, one per plugin, VCS, + // and workspace; one starts on the first request for a detected root. + let vcsProviderSessions = MacVcsProviderSessions( + launchResolver: pluginLaunchResolver, + tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage), + versionChecker: declarativePluginOperations, + runtime: runtimeService, + core: rustCore, + processRegistry: processRegistry + ) let rustLanguageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore) // Installation owns the process-backed language boundary even when a // package is disabled, quarantined, or failed to load. Only the active @@ -670,6 +680,7 @@ final class MacServiceContainer { // build systems' plugin list is a startup snapshot and does // not see the replacement. buildProviderSessions.stopProviders(ofPlugin: id.rawValue) + vcsProviderSessions.stopProviders(ofPlugin: id.rawValue) }, reviewPrograms: { [pluginMarketplaceEvaluator] manifest, id, version in try pluginMarketplaceEvaluator.installReview( @@ -778,6 +789,32 @@ final class MacServiceContainer { services.connectBuildProviderLanguageSessions = { sessions in buildProviderSessions.languageSessions = sessions } + let vcsProviderPlugins = pluginStartup.registeredPlugins.filter { !$0.descriptor.vcsProviders.isEmpty } + if !vcsProviderPlugins.isEmpty { + services.vcsProviders = VcsProvidersFeatureModel( + planner: RustVcsOperations(core: rustCore), + providers: vcsProviderSessions, + mappingStore: MacVcsRootMappingStore(store: store), + readFile: { url in try? fileStorage.readData(from: url, options: .mappedIfSafe) }, + sources: { + // Enablement is read on every detection, so turning a + // plugin off takes its roots away at once. + vcsProviderPlugins.compactMap { plugin -> VcsProviderPluginSource? in + let id = plugin.descriptor.id.rawValue + guard let snapshot = try? moduleRuntime.snapshot(for: .vcsProvidersExtension(id)), + snapshot.state != .disabled, + !snapshot.isQuarantined, + !snapshot.isSuppressedBySafeMode else { return nil } + return VcsProviderPluginSource( + pluginID: id, + manifest: plugin.descriptor.normalizedManifest, + packageURL: plugin.packageURL, + providers: plugin.descriptor.vcsProviders + ) + } + } + ) + } services.processExitCoordinator = ProcessExitCoordinator( core: RustProcessLifecycleDecider(core: rustCore), presenter: MacProcessExitConfirmationPresenter.shared @@ -867,10 +904,13 @@ final class MacServiceContainer { ) } } - // The build-systems module also carries the enabled state; the - // execution module reads it before handing the plugin to Rust Core. + // The build-systems and VCS-providers modules also carry the + // enabled state; the host reads it before handing the plugin to + // Rust Core. let workbench = descriptor.pluginManifest.modules.filter { - !$0.contributions.isEmpty || $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) + !$0.contributions.isEmpty + || $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) + || $0.manifest.id == .vcsProvidersExtension(descriptor.id.rawValue) }.map { declaration in ModuleFactory(manifest: declaration.manifest, contributions: declaration.contributions) { DeclarativeWorkbenchModule(declaration: declaration) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift index a4d5618ecb..8ed9c8537e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift @@ -53,6 +53,20 @@ struct MacPluginStateDirectories: Sendable { .appendingPathComponent(Self.workspaceDigest(workspaceRootURL), isDirectory: true) } + /// `${stateDir}` of one VCS provider process. + func vcsProviderStateDirectory(pluginID: String, vcs: String) -> URL { + pluginDirectory(pluginID: pluginID) + .appendingPathComponent("vcs-providers", isDirectory: true) + .appendingPathComponent(vcs, isDirectory: true) + } + + /// `${workspaceStateDir}` of one VCS provider. + func vcsProviderWorkspaceStateDirectory(pluginID: String, vcs: String, workspaceRootURL: URL) -> URL { + vcsProviderStateDirectory(pluginID: pluginID, vcs: vcs) + .appendingPathComponent("workspaces", isDirectory: true) + .appendingPathComponent(Self.workspaceDigest(workspaceRootURL), isDirectory: true) + } + static func workspaceDigest(_ workspaceRootURL: URL) -> String { SHA256.hash(data: Data(workspaceRootURL.standardizedFileURL.path.utf8)) .map { String(format: "%02x", $0) } @@ -209,6 +223,75 @@ struct MacPluginLanguageServerLaunchResolver { ) } if let languageID = provider.languageServer { return .languageServer(languageID) } + return .process(try providerProcess( + plan, request: request, packageURL: packageURL, + executesProjectCode: provider.executesProjectCode, mayUseNetwork: provider.mayUseNetwork, + settings: launchSettings + )) + } + + /// A VCS provider process with every package path verified and its + /// state directories created. The VCS client is resolved separately and + /// named in each request. + struct VcsProviderProcess: Equatable { + let process: BuildProviderProcess + let vcs: String + let toolCommands: [String] + let toolValidationArguments: [String] + } + + /// Expands the provider launch of one VCS for one workspace. Its state + /// directories belong to the provider, apart from the plugin's language + /// servers and build systems. + func resolveVcsProvider( + pluginID: String, + vcs: String, + manifest: Data, + packageURL: URL, + workspaceRootURL: URL + ) throws -> VcsProviderProcess { + let launchSettings = self.settingValues(forPlugin: pluginID, workspaceRootURL: workspaceRootURL) + let request = DeclarativeLanguageServerLaunchRequest( + pluginDirectory: packageURL.standardizedFileURL, + stateDirectory: directories.vcsProviderStateDirectory(pluginID: pluginID, vcs: vcs), + workspaceStateDirectory: directories.vcsProviderWorkspaceStateDirectory( + pluginID: pluginID, + vcs: vcs, + workspaceRootURL: workspaceRootURL + ), + architecture: Self.hostArchitecture, + settings: settings.values(forPlugin: pluginID), + projectSettings: settings.projectValues(forPlugin: pluginID, projectRoot: workspaceRootURL) + ) + let plan = try planner.vcsProviderLaunchPlan(manifest: manifest, vcs: vcs, request: request) + guard let provider = plan.vcsProvider, provider.vcs == vcs else { + throw DeclarativePluginRejection( + code: "plugin.launch.unavailable", + message: "Rust Core returned no VCS provider for \(vcs)." + ) + } + let process = try providerProcess( + plan, request: request, packageURL: packageURL, + executesProjectCode: false, mayUseNetwork: provider.mayUseNetwork, settings: launchSettings + ) + return VcsProviderProcess( + process: process, + vcs: vcs, + toolCommands: provider.toolCommands, + toolValidationArguments: provider.toolValidationArguments + ) + } + + /// The executable of a plan's own process, with its package paths + /// verified and state directories created. + private func providerProcess( + _ plan: DeclarativeLanguageServerLaunchPlan, + request: DeclarativeLanguageServerLaunchRequest, + packageURL: URL, + executesProjectCode: Bool, + mayUseNetwork: Bool, + settings launchSettings: [String: ToolingJSONValue] + ) throws -> BuildProviderProcess { try verifyPackagePaths(plan.requiredPackagePaths, packageURL: packageURL) let root = packageURL.standardizedFileURL let executable: BuildProviderProcess.Executable @@ -236,15 +319,15 @@ struct MacPluginLanguageServerLaunchResolver { } try fileManager.createDirectory(at: url, withIntermediateDirectories: true) } - return .process(BuildProviderProcess( + return BuildProviderProcess( executable: executable, arguments: plan.arguments, initializationOptions: plan.initializationOptions, runtimeTools: plan.runtimeRequirements.map(\.tool), - executesProjectCode: provider.executesProjectCode, - mayUseNetwork: provider.mayUseNetwork, + executesProjectCode: executesProjectCode, + mayUseNetwork: mayUseNetwork, settings: launchSettings - )) + ) } /// A declared host integration with its resources located in the package. diff --git a/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift new file mode 100644 index 0000000000..7560275235 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift @@ -0,0 +1,340 @@ +import Foundation +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import LitheModuleAPI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// Provider processes of plugin VCS providers. Each process belongs to one +/// VCS of one plugin in one workspace and talks only to the host over its +/// own stdio channel, so no plugin can reach another plugin's provider. +/// +/// A process starts on the first request for a detected root of its VCS and +/// runs until `retainProviders` drops its target (a workspace switch, a +/// disabled plugin) or `stopProviders(ofPlugin:)` stops it before its +/// package is replaced or removed. A change of the plugin's settings +/// restarts it on the next request. Its writable state lives in the +/// provider's plugin state directory, never in the app bundle or the plugin +/// package. +@MainActor +final class MacVcsProviderSessions: VcsProviderRequesting { + struct UnavailableError: LocalizedError, Equatable { + let message: String + var errorDescription: String? { message } + } + + private let launchResolver: MacPluginLanguageServerLaunchResolver + private let tools: any BuildToolResolving + private let versionChecker: any LanguageServerRuntimeVersionChecking + private let runtime: any LanguageToolRuntimePort + private let core: any LanguageServerRuntimeCore + private weak var processRegistry: (any LanguageServerProcessRegistry)? + /// Ends the processes carrying a backend's token; `immediately` skips the + /// graceful stop. See `MacPluginBackendProcesses`. + private let endBackendProcesses: @MainActor (_ token: String, _ immediately: Bool) -> Void + private var running: [VcsProviderTarget: Running] = [:] + + /// One process, from its start until it is dropped. + private final class Running { + let settings: [String: ToolingJSONValue] + var task: Task? + var session: LanguageServerRuntimeSession? + + init(settings: [String: ToolingJSONValue]) { + self.settings = settings + } + } + + /// A ready session with what it negotiated and the client every request + /// names. + private struct Started { + let session: LanguageServerRuntimeSession + let negotiation: VcsProviderNegotiation + let toolPath: String? + } + + init( + launchResolver: MacPluginLanguageServerLaunchResolver, + tools: any BuildToolResolving, + versionChecker: any LanguageServerRuntimeVersionChecking, + runtime: any LanguageToolRuntimePort, + core: any LanguageServerRuntimeCore, + processRegistry: (any LanguageServerProcessRegistry)?, + endBackendProcesses: @escaping @MainActor (_ token: String, _ immediately: Bool) -> Void = { token, immediately in + MacPluginBackendProcesses.end( + token: token, + gracePeriod: immediately ? 0 : TimeInterval(MacProcessSupervisor.storedSettings().gracefulStopSeconds) + ) + } + ) { + self.launchResolver = launchResolver + self.tools = tools + self.versionChecker = versionChecker + self.runtime = runtime + self.core = core + self.processRegistry = processRegistry + self.endBackendProcesses = endBackendProcesses + } + + func negotiation(for target: VcsProviderTarget) async throws -> VcsProviderNegotiation { + try await started(for: target).negotiation + } + + func send( + _ operation: VcsOperation, + root: String, + to target: VcsProviderTarget, + timeout: Duration + ) async throws -> VcsProviderReply { + let started = try await started(for: target) + guard started.negotiation.supports(operation.capability) else { + throw VcsProviderFailure( + code: "vcs.capability.unsupported", + message: String(localized: "This version control provider does not support this action.") + ) + } + do { + let value = try await started.session.vcsRequest( + vcs: target.vcs, + operation: operation.name, + root: root, + tool: started.toolPath, + arguments: operation.arguments, + timeout: Self.seconds(timeout) + ) + return VcsProviderReply(value: value, negotiation: started.negotiation) + } catch let failure as LanguageServerRequestFailure { + throw VcsProviderFailure(code: failure.runtimeError.code, message: failure.runtimeError.message) + } + } + + func retainProviders(_ keep: (VcsProviderTarget) -> Bool) { + for (target, entry) in running where !keep(target) { + stop(entry, for: target) + } + } + + /// Stops every process of `pluginID`, in every workspace. The plugin + /// manager calls it before the package is replaced, rolled back, or + /// uninstalled, so no process keeps running the files it replaces. + func stopProviders(ofPlugin pluginID: String) { + retainProviders { $0.pluginID != pluginID } + } + + private func stop(_ entry: Running, for target: VcsProviderTarget) { + if running[target] === entry { running[target] = nil } + entry.task?.cancel() + entry.session?.stop() + } + + /// The target's running process, or a new one. A process that exited is + /// replaced, and so is one started with other setting values. + private func started(for target: VcsProviderTarget) async throws -> Started { + try Task.checkCancellation() + let settings = launchResolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL) + if let entry = running[target], let task = entry.task { + if entry.settings != settings || entry.session.map({ !$0.isRunning }) == true { + stop(entry, for: target) + } else { + let started = try await Self.waitForStart(task) + guard running[target] === entry else { throw CancellationError() } + return started + } + } + let entry = Running(settings: settings) + running[target] = entry + // The start is shared by every request that arrives meanwhile, so no + // single caller's cancellation cancels it; a failed start clears the + // entry itself. + let task = Task { @MainActor [weak self] () throws -> Started in + guard let self else { throw CancellationError() } + do { + return try await self.launch(target, entry: entry) + } catch { + if self.running[target] === entry { self.running[target] = nil } + throw error + } + } + entry.task = task + let started = try await Self.waitForStart(task) + guard running[target] === entry else { throw CancellationError() } + return started + } + + /// Waits for a shared start. A cancelled caller stops waiting at once, + /// while the start goes on for the other callers. + private static func waitForStart(_ task: Task) async throws -> Started { + let waiter = StartWaiter() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waiter.wait(with: continuation) + if Task.isCancelled { + waiter.finish(.failure(CancellationError())) + return + } + Task { @MainActor in waiter.finish(await task.result) } + } + } onCancel: { + Task { @MainActor in waiter.finish(.failure(CancellationError())) } + } + } + + @MainActor + private final class StartWaiter { + private var continuation: CheckedContinuation? + + func wait(with continuation: CheckedContinuation) { + self.continuation = continuation + } + + func finish(_ result: Result) { + continuation?.resume(with: result) + continuation = nil + } + } + + private func launch(_ target: VcsProviderTarget, entry: Running) async throws -> Started { + let provider = try launchResolver.resolveVcsProvider( + pluginID: target.pluginID, + vcs: target.vcs, + manifest: target.manifest, + packageURL: target.packageURL, + workspaceRootURL: target.workspaceURL + ) + var (executableURL, environment) = try await resolveExecutable(provider.process, workspaceURL: target.workspaceURL) + let toolPath = try await resolveTool(provider, workspaceURL: target.workspaceURL) + try Task.checkCancellation() + guard running[target] === entry else { throw CancellationError() } + // Every process the backend starts, the VCS client included, inherits + // this token, so what it leaves behind can be ended when it is gone. + let backendToken = UUID().uuidString + environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } + let session = LanguageServerRuntimeSession( + providerID: "vcsProvider:\(target.pluginID):\(target.vcs)", + executableURL: executableURL, + arguments: provider.process.arguments, + environment: environment, + initializationOptions: provider.process.initializationOptions, + initializeTimeout: 60, + core: core, + processRegistry: processRegistry, + moduleID: .vcsProvidersExtension(target.pluginID), + pluginID: target.pluginID + ) + let endBackendProcesses = endBackendProcesses + session.terminateProcess = { endBackendProcesses(backendToken, true) } + session.onReleased = { endBackendProcesses(backendToken, false) } + entry.session = session + try await waitUntilReady(session, rootURL: target.workspaceURL) + try Task.checkCancellation() + guard running[target] === entry else { throw CancellationError() } + // Core publishes the negotiation before the session is ready. A + // process that answered no provider, or another VCS than the + // manifest entry it was launched for, is stopped. + guard let negotiation = session.vcsNegotiation, negotiation.vcs == target.vcs else { + session.stop() + throw UnavailableError(message: String( + localized: "The plugin's version control provider did not start as declared. Update or reinstall the plugin." + )) + } + return Started(session: session, negotiation: negotiation, toolPath: toolPath) + } + + /// Starts the session and returns once `initialize` succeeded. + private func waitUntilReady(_ session: LanguageServerRuntimeSession, rootURL: URL) async throws { + var finished = false + defer { session.onStateChange = nil } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + func finish(_ result: Result) { + guard !finished else { return } + finished = true + continuation.resume(with: result) + } + session.onStateChange = { state in + switch state { + case .ready: + finish(.success(())) + case .failed(let failure): + finish(.failure(UnavailableError( + message: failure.message ?? String(localized: "The version control provider failed to start.") + ))) + case .stopped: + finish(.failure(UnavailableError( + message: String(localized: "The version control provider stopped before it was ready.") + ))) + case .startingProcess, .initializing, .stopping: + break + } + } + do { + try session.start(rootURL: rootURL, workspaceFingerprint: nil) + } catch { + finish(.failure(error)) + } + } + } + + /// The provider's own executable: a packaged file as it is, commands and + /// runtimes through the build tool discovery, so the provider sees the + /// same runtime settings and `PATH` as other plugin processes. + private func resolveExecutable( + _ process: MacPluginLanguageServerLaunchResolver.BuildProviderProcess, + workspaceURL: URL + ) async throws -> (URL, [String: String]) { + let runtimes = process.runtimeTools.map { + BuildInvocation.RuntimeDependency(id: $0, hostManaged: true) + } + let invocation: BuildInvocation + var requirement: LanguageServerExecutableVersionRequirement? + switch process.executable { + case .file(let url): + return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + case .commands(let commands): + invocation = BuildInvocation(executables: commands.map { .command($0) }, runtimeDependencies: runtimes) + case .runtime(let tool, let versionRequirement): + requirement = versionRequirement + invocation = BuildInvocation( + executables: [.runtime(tool)], + runtimeDependencies: [BuildInvocation.RuntimeDependency( + id: tool, + hostManaged: true, + minimumVersion: versionRequirement?.minimumVersion, + versionArguments: versionRequirement?.probeArguments + )] + runtimes + ) + } + let versionChecker = versionChecker + let resolved = try await tools.resolve( + invocation, + displayName: String(localized: "Version control provider"), + workspaceURL: workspaceURL, + checkVersion: { [requirement] _, output in + guard let requirement else { return BuildVersionCheck(satisfied: true, version: nil) } + let check = try versionChecker.checkRuntimeVersion(requirement, output: output) + return BuildVersionCheck(satisfied: check.satisfied, version: check.version) + } + ) + return (resolved.executableURL, resolved.environment) + } + + /// The VCS client the provider runs, such as `svn`, found like any other + /// command. The provider itself reports a client that is too old. + private func resolveTool( + _ provider: MacPluginLanguageServerLaunchResolver.VcsProviderProcess, + workspaceURL: URL + ) async throws -> String? { + guard !provider.toolCommands.isEmpty else { return nil } + let resolved = try await tools.resolve( + BuildInvocation(executables: provider.toolCommands.map { .command($0) }), + displayName: provider.toolCommands[0], + workspaceURL: workspaceURL, + checkVersion: { _, _ in BuildVersionCheck(satisfied: true, version: nil) } + ) + return resolved.executableURL.path + } + + private static func seconds(_ duration: Duration) -> TimeInterval { + let components = duration.components + return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18 + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsRootMappingStore.swift b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsRootMappingStore.swift new file mode 100644 index 0000000000..22beab8448 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsRootMappingStore.swift @@ -0,0 +1,33 @@ +import Foundation +import LitheCoreContracts + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// The VCS chosen for each ambiguous directory, per workspace, in the app's +/// preferences. Detection drops mappings whose directory is no longer +/// ambiguous. +struct MacVcsRootMappingStore: VcsRootMappingStoring { + private static let keyPrefix = "Vcs.RootMappings." + let store: any KeyValueStore + + func mappings(workspaceURL: URL) -> [VcsRootMapping] { + guard let data = store.data(forKey: Self.key(workspaceURL)), + let mappings = try? JSONDecoder().decode([VcsRootMapping].self, from: data) else { return [] } + return mappings + } + + func setMappings(_ mappings: [VcsRootMapping], workspaceURL: URL) { + let key = Self.key(workspaceURL) + guard !mappings.isEmpty else { + store.set(nil, forKey: key) + return + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + store.set(try? encoder.encode(mappings.sorted { $0.id < $1.id }), forKey: key) + } + + private static func key(_ workspaceURL: URL) -> String { + keyPrefix + MacPluginStateDirectories.workspaceDigest(workspaceURL) + } +} diff --git a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift index 6d66fe3b03..3c230f40a8 100644 --- a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift +++ b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift @@ -819,6 +819,28 @@ enum PluginMarketplacePresentation { String(format: localized("Builds %@ projects", locale: locale), program.buildSystemID ?? ""), lines.joined(separator: "\n") ) + case .packagedVcsProvider, .systemVcsProvider, .runtimeVcsProvider: + let vcs = program.vcs ?? "" + var lines: [String] + let title: String + switch program.kind { + case .packagedVcsProvider: + title = String(format: localized("%@ provider (inside the plugin)", locale: locale), vcs) + lines = [serverDetail([program.path ?? ""])] + case .systemVcsProvider: + title = String(format: localized("%@ provider (installed on this Mac)", locale: locale), vcs) + lines = [serverDetail([program.commands.joined(separator: " | ")])] + default: + title = String(format: localized("%1$@ provider (runs with %2$@)", locale: locale), vcs, program.tool ?? "") + lines = [line([program.tool ?? ""] + program.runtimeArguments + [program.entry ?? ""] + program.arguments)] + } + if !program.vcsClientCommands.isEmpty { + lines.append(labeled("Client: %@", [program.vcsClientCommands.joined(separator: " | ")])) + } + if program.mayUseNetwork { + lines.append(localized("May connect to the repository server", locale: locale)) + } + return (title, lines.joined(separator: "\n")) case .unknown: // The full declaration stays behind the disclosure below so an // unfamiliar kind cannot push the sheet's buttons off screen. diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index c6b81e2db5..7c3e557a94 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -259,6 +259,10 @@ private struct ActiveSessionChrome: View { ProjectLocalHistoryView(request: request, fontFamily: session.settings.editorFontFamily) .environmentObject(session) } + .modifier(VcsDiffPresentation( + feature: session.vcsProvidersFeature, fontFamily: session.settings.editorFontFamily, + openFile: { [weak session] in session?.openFile($0, allowsOutsideWorkspace: true) } + )) } diff --git a/macos/Sources/Lithe/Views/Vcs/VcsChangeStatusPresentation.swift b/macos/Sources/Lithe/Views/Vcs/VcsChangeStatusPresentation.swift new file mode 100644 index 0000000000..ee3b036c6e --- /dev/null +++ b/macos/Sources/Lithe/Views/Vcs/VcsChangeStatusPresentation.swift @@ -0,0 +1,67 @@ +import LitheCoreContracts +import SwiftUI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// How plugin VCS statuses look in the tool window and the project tree. +/// Letters follow the command-line clients most users know; colors follow +/// IDEA's file status colors, as the Git views do. +extension VcsChangeStatus { + var letter: String { + switch self { + case .modified: "M" + case .added, .copied: "A" + case .deleted: "D" + case .missing: "!" + case .renamed, .replaced: "R" + case .typeChanged: "T" + case .unversioned: "?" + case .ignored: "I" + case .conflicted: "C" + case .switched: "S" + } + } + + var title: String { + switch self { + case .modified: String(localized: "Modified") + case .added: String(localized: "Added") + case .copied: String(localized: "Copied") + case .deleted: String(localized: "Deleted") + case .missing: String(localized: "Missing") + case .renamed: String(localized: "Moved") + case .replaced: String(localized: "Replaced") + case .typeChanged: String(localized: "Type changed") + case .unversioned: String(localized: "Unversioned") + case .ignored: String(localized: "Ignored") + case .conflicted: String(localized: "Conflicted") + case .switched: String(localized: "Switched") + } + } + + /// File name color in change lists. + var listColor: Color { + switch self { + case .modified, .typeChanged, .switched, .replaced: LitheTheme.Commit.fileModified + case .added, .copied: LitheTheme.Commit.fileAdded + case .deleted, .missing: LitheTheme.Commit.fileDeleted + case .renamed: LitheTheme.Commit.fileRenamed + case .unversioned: LitheTheme.Commit.fileUntracked + case .ignored: LitheTheme.Commit.fileIgnored + case .conflicted: LitheTheme.Commit.fileConflicted + } + } + + /// Row color in the project tree, matching its Git colors. + var treeColor: Color { + switch self { + case .modified, .typeChanged, .switched, .replaced: LitheTheme.accent + case .added, .copied: LitheTheme.success + case .deleted, .missing: LitheTheme.error + case .renamed: LitheTheme.skill + case .unversioned: LitheTheme.Commit.fileUntracked + case .ignored: LitheTheme.Commit.fileIgnored + case .conflicted: LitheTheme.warning + } + } +} diff --git a/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift b/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift new file mode 100644 index 0000000000..9508972e82 --- /dev/null +++ b/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift @@ -0,0 +1,266 @@ +import LitheCoreContracts +import LitheGitModule // DiffRow, the shared diff row model +import LitheLocalHistoryModule +import SwiftUI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +/// Revisions of a root, or of one file in it, a page at a time. Each row +/// expands to the full message and the paths the revision changed. +struct VcsHistoryListView: View { + @ObservedObject var feature: VcsProvidersFeatureModel + let rootID: String + /// Root-relative file; nil lists the whole root. + let path: String? + @State private var entries: [VcsHistoryEntry] = [] + @State private var nextCursor: String? + @State private var isLoading = false + @State private var failure: String? + @State private var expanded: Set = [] + + var body: some View { + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(entries) { entry in + row(entry) + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + if let failure { + LitheBanner(status: .error, layout: .editor) { + Text(verbatim: failure).lineLimit(6).textSelection(.enabled) + Button(String(localized: "Retry")) { Task { await load() } } + } + } else if isLoading { + ProgressView().controlSize(.small).frame(maxWidth: .infinity).padding(12) + } else if nextCursor != nil { + Button(String(localized: "Load More")) { Task { await load() } } + .buttonStyle(.link) + .frame(maxWidth: .infinity) + .padding(12) + } else if entries.isEmpty { + Text("No revisions") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity) + .padding(20) + } + } + } + .litheWorkbenchSurface(LitheTheme.sidebar) + .task { await load() } + } + + private func row(_ entry: VcsHistoryEntry) -> some View { + let isExpanded = expanded.contains(entry.id) + let firstLine = entry.message.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? "" + return VStack(alignment: .leading, spacing: 3) { + Button { + if isExpanded { expanded.remove(entry.id) } else { expanded.insert(entry.id) } + } label: { + HStack(spacing: 6) { + LitheIDEAIcon(resourcePath: isExpanded ? "expui/general/chevronDown.svg" : "expui/general/chevronRight.svg", + size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) + Text(verbatim: entry.revision.display) + .font(LitheTheme.uiFont(size: 12, weight: .semibold, design: .monospaced)) + if let author = entry.author { + Text(verbatim: author).foregroundStyle(LitheTheme.secondaryText).lineLimit(1) + } + Spacer(minLength: 4) + if let date = entry.date { + Text(verbatim: Self.displayDate(date)) + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + Text(verbatim: isExpanded ? entry.message : firstLine) + .lineLimit(isExpanded ? nil : 1) + .textSelection(.enabled) + .padding(.leading, 22) + if isExpanded { + VStack(alignment: .leading, spacing: 2) { + ForEach(entry.changes, id: \.self) { change in + HStack(spacing: 6) { + Text(verbatim: change.status.letter) + .font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(change.status.listColor) + Text(verbatim: change.oldPath.map { "\(change.path) ← \($0)" } ?? change.path) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + if entry.changesTruncated { + Text("More paths changed than are shown.") + .foregroundStyle(LitheTheme.secondaryText) + } + } + .font(LitheTheme.uiFont(size: 11)) + .padding(.leading, 22) + } + } + .font(LitheTheme.uiFont(size: 12)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + } + + private func load() async { + guard !isLoading else { return } + isLoading = true + failure = nil + defer { isLoading = false } + do { + let page = if let path { + try await feature.fileHistory(path, in: rootID, cursor: nextCursor) + } else { + try await feature.history(in: rootID, cursor: nextCursor) + } + let known = Set(entries.map(\.id)) + entries += page.entries.filter { !known.contains($0.id) } + nextCursor = page.nextCursor + } catch is CancellationError { + } catch { + failure = VcsProvidersFeatureModel.message(for: error) + } + } + + /// Providers send ISO 8601; anything else is shown as sent. + static func displayDate(_ value: String) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date.formatted(date: .abbreviated, time: .shortened) } + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: value) { return date.formatted(date: .abbreviated, time: .shortened) } + return value + } +} + +/// Presents the diff a window's VCS feature asks for, from its tool window +/// or its project tree. +struct VcsDiffPresentation: ViewModifier { + let feature: VcsProvidersFeatureModel? + let fontFamily: String + let openFile: (URL) -> Void + + func body(content: Content) -> some View { + if let feature { + content.modifier(Presenter(feature: feature, fontFamily: fontFamily, openFile: openFile)) + } else { + content + } + } + + private struct Presenter: ViewModifier { + @ObservedObject var feature: VcsProvidersFeatureModel + let fontFamily: String + let openFile: (URL) -> Void + + func body(content: Content) -> some View { + content.sheet(item: $feature.presentedDiff) { diff in + VcsFileDiffSheet(diff: diff, fontFamily: fontFamily) { + feature.presentedDiff = nil + openFile(diff.fileURL) + } + } + } + } +} + +/// A change next to its base revision. Rows are built off the main thread; +/// a very long file is shown as two plain texts instead. +struct VcsFileDiffSheet: View { + /// Above this many lines in total, the line diff takes too long. + static let lineLimit = 20_000 + + let diff: VcsFileDiff + let fontFamily: String + let openFile: () -> Void + @Environment(\.dismiss) private var dismiss + @State private var rows: [DiffRow]? + @State private var isOverLimit = false + + var body: some View { + VStack(spacing: 0) { + HStack { + Text(verbatim: diff.path) + .font(LitheTheme.uiFont(size: 12, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button(String(localized: "Open File"), action: openFile) + .disabled(diff.current == nil && !diff.isBinary && !diff.isTooLarge) + Button("Done") { dismiss() }.keyboardShortcut(.cancelAction) + } + .padding(12) + Rectangle().fill(LitheTheme.divider).frame(height: 1) + content + } + .frame(minWidth: 640, idealWidth: 960, minHeight: 400, idealHeight: 640) + .background(LitheTheme.editor) + .task(id: diff.id) { await buildRows() } + } + + @ViewBuilder + private var content: some View { + if diff.isBinary { + message(String(localized: "Binary files cannot be compared.")) + } else if diff.isTooLarge { + message(String(localized: "The file is too large to compare.")) + } else if isOverLimit { + HStack(alignment: .top, spacing: 0) { + textColumn(String(localized: "Base revision"), text: diff.base ?? "") + Rectangle().fill(LitheTheme.divider).frame(width: 1) + textColumn(String(localized: "Local"), text: diff.current ?? "") + } + } else if let rows { + DiffPaneView(rows: rows, fileExtension: (diff.path as NSString).pathExtension, + minimumWidth: 600, fontFamily: fontFamily) + } else { + ProgressView().controlSize(.small).frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func message(_ text: String) -> some View { + Text(verbatim: text) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func textColumn(_ title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: title).font(LitheTheme.uiFont(size: 12, weight: .semibold)) + ScrollView([.vertical, .horizontal]) { + Text(verbatim: text) + .font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .litheScrollViewChrome() + } + .padding(8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func buildRows() async { + guard !diff.isBinary, !diff.isTooLarge else { return } + let base = diff.base ?? "" + let current = diff.current ?? "" + let limit = Self.lineLimit + let built = await Task.detached(priority: .userInitiated) { () -> [LocalHistoryDiffRow]? in + let lines = base.utf8.lazy.filter { $0 == 0x0A }.count + current.utf8.lazy.filter { $0 == 0x0A }.count + guard lines <= limit else { return nil } + return LocalHistoryDiffBuilder.rows(old: base, current: current) + }.value + guard !Task.isCancelled else { return } + if let built { + rows = built.map(DiffRow.init) + } else { + isOverLimit = true + } + } +} diff --git a/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift new file mode 100644 index 0000000000..4ac576c854 --- /dev/null +++ b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift @@ -0,0 +1,708 @@ +import AppKit +import LitheCoreContracts +import SwiftUI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +struct VcsProviderToolWindowActions { + let openFile: (URL) -> Void + let revealInFinder: (URL) -> Void + let copyPath: (URL) -> Void + let hide: () -> Void +} + +/// The tool window of one plugin VCS, laid out like IDEA's Commit window: +/// a Changes tab with the root's changes, a commit message, and the +/// actions the provider negotiated; a History tab with its revisions. +/// Every provider uses this view, so no VCS is named here; an action shows +/// only when the provider declares its capability. +struct VcsProviderToolWindowView: View { + @ObservedObject var feature: VcsProvidersFeatureModel + let title: String + let pluginID: String + let vcs: String + let actions: VcsProviderToolWindowActions + @Environment(\.controlActiveState) private var controlActiveState + @State private var tab = Tab.changes + @State private var listActive = false + @State private var selectedRootID: String? + /// Selected and explicitly included or excluded paths, root-relative, + /// of the shown root; a root change clears them. + @State private var selection: Set = [] + @State private var inclusion: [String: Bool] = [:] + @State private var collapsedGroups: Set = [] + @State private var message = "" + @State private var messageFocused = false + @State private var pendingRollback: [VcsChange] = [] + @State private var notice: String? + /// The file whose history the History tab shows; nil is the whole root. + @State private var historyPath: String? + + enum Tab: Hashable { case changes, history } + + enum Group: Hashable, CaseIterable { + case conflicts, changes, unversioned + + var title: String { + switch self { + case .conflicts: String(localized: "Conflicts") + case .changes: String(localized: "Changes") + case .unversioned: String(localized: "Unversioned Files") + } + } + } + + var body: some View { + VStack(spacing: 0) { + header + if let root { + toolbar(root) + rootBar(root) + } + banners + if let root { + switch tab { + case .changes: changesPane(root) + case .history: historyPane(root) + } + } else { + emptyState + } + } + .litheWorkbenchSurface(LitheTheme.editor) + .workbenchHoverTooltipScope() + .onAppear(perform: loadDraft) + .onChange(of: root?.id) { _ in + selection = []; inclusion = [:]; notice = nil; historyPath = nil + loadDraft() + } + .onChange(of: message) { text in + guard let root else { return } + feature.commitMessageDrafts[root.id] = text.isEmpty ? nil : text + } + .onChange(of: root?.snapshot?.changes) { changes in + let paths = Set((changes ?? []).map(\.path)) + selection.formIntersection(paths) + inclusion = inclusion.filter { paths.contains($0.key) } + } + .confirmationDialog( + String(localized: "Roll back the selected changes?"), + isPresented: Binding(get: { !pendingRollback.isEmpty }, set: { if !$0 { pendingRollback = [] } }), + titleVisibility: .visible + ) { + Button(String(localized: "Roll Back"), role: .destructive) { + let changes = pendingRollback + pendingRollback = [] + rollback(changes) + } + Button("Cancel", role: .cancel) { pendingRollback = [] } + } message: { + Text(verbatim: pendingRollback.map(\.path).joined(separator: "\n") + "\n\n" + + String(localized: "Local edits to these files are lost.")) + } + } + + // MARK: Roots + + private var roots: [VcsRootState] { + feature.roots.filter { $0.pluginID == pluginID && $0.root.vcs == vcs } + } + + private var root: VcsRootState? { + let roots = roots + return roots.first { $0.id == selectedRootID } ?? roots.first + } + + private var ambiguous: [VcsAmbiguousDirectory] { + feature.ambiguous.filter { $0.vcs.contains(vcs) } + } + + private func loadDraft() { + message = root.flatMap { feature.commitMessageDrafts[$0.id] } ?? "" + } + + // MARK: Header and toolbar + + private var header: some View { + HStack(spacing: 8) { + Text(verbatim: title) + .font(LitheTheme.uiFont(size: LitheTheme.Commit.toolbarFontSize, weight: .semibold)) + .foregroundStyle(LitheTheme.toolWindowText) + .lineLimit(1) + .padding(.trailing, 4) + if root != nil { + tabButton(.changes, title: String(localized: "Changes")) + if root?.supports(.history) == true { + tabButton(.history, title: String(localized: "History")) + } + } + Spacer() + LitheSidebarHideButton(title: title, action: actions.hide) + } + .padding(.leading, 12) + .padding(.trailing, 10) + .frame(height: 41) + .background(LitheTheme.toolHeader) + .overlay(alignment: .bottom) { LitheToolWindowHeaderDivider() } + } + + private func tabButton(_ value: Tab, title: String) -> some View { + Button { + tab = value + if value == .changes { historyPath = nil } + } label: { + Text(verbatim: title) + .font(LitheTheme.uiFont(size: LitheTheme.Commit.toolbarFontSize, weight: .regular)) + .foregroundStyle(tab == value ? LitheTheme.primaryText : LitheTheme.secondaryText) + .padding(.horizontal, LitheTheme.Commit.tabItemHorizontalPadding) + .frame(height: 28) + } + .buttonStyle(.litheNoPress) + .modifier(LitheToolWindowTabStyle(isSelected: tab == value, isActive: listActive)) + } + + private func toolbar(_ root: VcsRootState) -> some View { + let busy = feature.runningOperation != nil + let selected = selectedChanges(root) + return ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + toolbarButton(String(localized: "Refresh"), icon: "expui/general/refresh.svg", + isEnabled: !feature.isDetecting && !root.isRefreshing) { + feature.detect() + } + if root.supports(.update) { + toolbarButton(String(localized: "Update Project"), icon: "expui/vcs/update.svg", isEnabled: !busy) { + update(root) + } + } + if tab == .changes { + if root.supports(.rollback) { + toolbarButton(String(localized: "Roll Back…"), icon: "expui/vcs/revert.svg", + isEnabled: !busy && !rollbackTargets(selected).isEmpty) { + pendingRollback = rollbackTargets(selected) + } + } + if root.supports(.baseContent) { + toolbarButton(String(localized: "Show Diff"), icon: "expui/vcs/diff.svg", + isEnabled: selected.count == 1) { + if let change = selected.first { showDiff(change, in: root) } + } + } + if root.supports(.conflicts) { + toolbarButton(String(localized: "Mark as Resolved"), icon: "expui/actions/checked.svg", + isEnabled: !busy && !conflicted(selected).isEmpty) { + resolve(conflicted(selected), in: root) + } + } + } + if root.supports(.cleanup) { + LitheMenu { + LitheContextMenuItem.action(String(localized: "Clean Up Working Copy"), localizesTitle: false, + isEnabled: !busy) { cleanup(root) } + } label: { + icon("expui/general/moreVertical.svg") + } + .litheToolbarIconButton() + .padding(.horizontal, 2) + .accessibilityLabel(Text("More Actions")) + .workbenchHoverHelp(Text("More Actions")) + } + } + .padding(.horizontal, 7) + .padding(.vertical, 5) + } + .frame(height: LitheTheme.Metrics.toolbarIconButtonSize + 12) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + private func toolbarButton(_ help: String, icon path: String, isEnabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { icon(path) } + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.45) + .accessibilityLabel(Text(verbatim: help)) + .buttonStyle(LitheIconButtonStyle( + size: LitheTheme.Metrics.toolbarIconButtonSize, cornerRadius: 4, + hoverBackground: LitheTheme.toolbarHoverBackground, + pressedBackground: LitheTheme.toolbarPressedBackground + )) + .padding(.horizontal, 2) + .padding(.vertical, 1) + .workbenchHoverHelp(Text(verbatim: help)) + } + + private func icon(_ path: String) -> some View { + LitheIDEAIcon(resourcePath: path, size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) + } + + /// The shown root, with a picker when the workspace has several, and + /// where its working copy points. + private func rootBar(_ root: VcsRootState) -> some View { + HStack(spacing: 6) { + icon("expui/nodes/folder.svg") + if roots.count > 1 { + LitheMenu { + for candidate in roots { + LitheContextMenuItem.action( + candidate.title, localizesTitle: false, + checked: candidate.id == root.id, leadingCheckmark: true, + initiallySelected: candidate.id == root.id + ) { selectedRootID = candidate.id } + } + } label: { + HStack(spacing: 4) { + Text(verbatim: root.title).lineLimit(1) + icon("expui/general/chevronDown.svg") + } + } + .litheToolbarIconButton(isIconOnly: false) + .accessibilityLabel(Text("Working copy")) + } else { + Text(verbatim: root.title).lineLimit(1) + } + Spacer(minLength: 8) + if root.isRefreshing { ProgressView().controlSize(.mini) } + if let head = root.snapshot?.head { + Text(verbatim: Self.headSummary(head)) + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + .help(head.location ?? "") + } + } + .font(LitheTheme.uiFont(size: 12)) + .padding(.horizontal, 10) + .frame(height: 28) + .help(root.url.path) + } + + static func headSummary(_ head: VcsHead) -> String { + [head.revision?.display, head.branch].compactMap { $0 }.joined(separator: " · ") + } + + // MARK: Banners and empty state + + @ViewBuilder + private var banners: some View { + ForEach(feature.issues, id: \.self) { issue in + LitheBanner(status: .warning, layout: .editor) { + Text(verbatim: issue).lineLimit(3).textSelection(.enabled) + } + } + ForEach(ambiguous, id: \.id) { directory in + LitheBanner(status: .info, layout: .editor) { + Text(String(format: String(localized: "%@ holds the metadata of several version control systems. Choose the one that manages it."), + directory.id.isEmpty ? String(localized: "The workspace") : directory.id)) + HStack(spacing: 6) { + ForEach(directory.vcs, id: \.self) { candidate in + Button(feature.vcsNames[candidate] ?? candidate) { feature.map(directory.id, to: candidate) } + } + } + } + } + if feature.isTruncated { + LitheBanner(status: .warning, layout: .editor) { + Text("The workspace is too large to search completely; some working copies may be missing.") + } + } + if let running = feature.runningOperation, roots.contains(where: { $0.id == running.rootID }) { + LitheBanner(status: .info, layout: .editor, isProgress: true) { + Text(verbatim: running.title) + } + } + if let failure = feature.failure, failure.rootID.map({ id in roots.contains { $0.id == id } }) ?? true { + LitheBanner(status: .error, layout: .editor, dismiss: feature.dismissFailure) { + Text(verbatim: failure.message).lineLimit(6).textSelection(.enabled) + } + .id(failure.id) + } + if let root, let message = root.failure { + LitheBanner(status: .error, layout: .editor) { + Text(verbatim: message).lineLimit(6).textSelection(.enabled) + Button(String(localized: "Retry")) { feature.refresh(root.id) } + } + } + if let root, root.snapshot?.truncated == true { + LitheBanner(status: .warning, layout: .editor) { + Text("There are too many changes to list; some are not shown.") + } + } + if let notice { + LitheBanner(status: .success, layout: .editor, dismiss: { self.notice = nil }) { + Text(verbatim: notice) + } + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + if feature.isDetecting { + ProgressView().controlSize(.small) + Text("Detecting working copies…") + } else { + Text(String(format: String(localized: "No %@ working copy was detected in this workspace."), title)) + .multilineTextAlignment(.center) + Button(String(localized: "Detect Again")) { feature.detect() } + } + } + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: Changes + + private func changesPane(_ root: VcsRootState) -> some View { + VStack(spacing: 0) { + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + let groups = Self.groups(root.snapshot?.changes ?? []) + if groups.isEmpty, root.snapshot != nil { + Text("No local changes") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } + ForEach(groups, id: \.group) { entry in + groupHeader(entry.group, changes: entry.changes, root: root) + if !collapsedGroups.contains(entry.group) { + ForEach(entry.changes, id: \.path) { change in + changeRow(change, group: entry.group, root: root) + } + } + } + } + .padding(.horizontal, LitheTheme.Tree.horizontalInset) + .padding(.vertical, LitheTheme.Tree.verticalInset) + } + .litheWorkbenchSurface(LitheTheme.sidebar) + .background(LitheToolWindowActivityTracker(isActive: $listActive)) + if root.supports(.checkin) { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + commitArea(root) + } + } + } + + struct GroupedChanges { + let group: Group + let changes: [VcsChange] + } + + /// Conflicts first, as IDEA lists them; ignored files never reach the + /// list because status asks for none. + static func groups(_ changes: [VcsChange]) -> [GroupedChanges] { + var buckets: [Group: [VcsChange]] = [:] + for change in changes where change.status != .ignored { + buckets[group(of: change), default: []].append(change) + } + return Group.allCases.compactMap { group in + buckets[group].map { GroupedChanges(group: group, changes: $0.sorted { $0.path < $1.path }) } + } + } + + static func group(of change: VcsChange) -> Group { + if change.status == .conflicted || change.hasTreeConflict { return .conflicts } + return change.status == .unversioned ? .unversioned : .changes + } + + /// IDEA includes every versioned change and leaves new files out; a + /// conflict cannot be committed until it is resolved. An ignored file is + /// never listed, so it is never committed either. + static func isIncludedByDefault(_ change: VcsChange) -> Bool { + change.status != .ignored && group(of: change) == .changes + } + + private func isIncluded(_ change: VcsChange) -> Bool { + guard change.status != .ignored, Self.group(of: change) != .conflicts else { return false } + return inclusion[change.path] ?? Self.isIncludedByDefault(change) + } + + private func groupHeader(_ group: Group, changes: [VcsChange], root: VcsRootState) -> some View { + let collapsed = collapsedGroups.contains(group) + let included = changes.filter(isIncluded).count + return HStack(spacing: LitheTheme.Tree.iconTextGap) { + Button { + if collapsed { collapsedGroups.remove(group) } else { collapsedGroups.insert(group) } + } label: { + icon(collapsed ? "expui/general/chevronRight.svg" : "expui/general/chevronDown.svg") + } + .buttonStyle(.litheNoPress) + .accessibilityLabel(Text(collapsed ? "Expand" : "Collapse")) + if group != .conflicts && root.supports(.checkin) { + GitChangeInclusionCheckbox( + state: included == 0 ? .off : included == changes.count ? .on : .mixed, + accessibilityTitle: String(localized: "Include in commit") + ) { + let include = included < changes.count + for change in changes { inclusion[change.path] = include } + } + } + Text(verbatim: group.title) + Text(verbatim: "\(changes.count)") + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } + .litheTreeRow() + } + + private func changeRow(_ change: VcsChange, group: Group, root: VcsRootState) -> some View { + let url = root.url.appendingPathComponent(change.path) + let isSelected = selection.contains(change.path) + let name = (change.path as NSString).lastPathComponent + let directory = (change.path as NSString).deletingLastPathComponent + let detail = change.oldPath.map { "← " + $0 } ?? directory + return HStack(spacing: LitheTheme.Tree.iconTextGap) { + if root.supports(.checkin) { + GitChangeInclusionCheckbox( + state: isIncluded(change) ? .on : .off, + accessibilityTitle: String(localized: "Include in commit") + ) { + inclusion[change.path] = !isIncluded(change) + } + .disabled(group == .conflicts) + } + LitheIcon(kind: LitheIcons.kind(for: url, isDirectory: false), size: LitheTheme.Tree.iconSize) + Text(verbatim: name) + .foregroundStyle(change.status.listColor) + .lineLimit(1) + .layoutPriority(1) + if !detail.isEmpty { + Text(verbatim: detail) + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 4) + if change.flags.contains("locked") || change.flags.contains("lockedByOther") { + icon("expui/general/locked.svg") + } + Text(verbatim: change.hasTreeConflict ? "C" : change.status.letter) + .font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .foregroundStyle(change.status.listColor) + .padding(.trailing, 4) + } + .padding(.leading, LitheTheme.Tree.indent) + .litheTreeRow(isSelected: isSelected, isFocused: listActive && controlActiveState == .key) + .help(Self.rowHelp(change)) + .onTapGesture(count: 2) { open(change, in: root) } + .simultaneousGesture(TapGesture().onEnded { select(change) }) + .litheContextMenu(items: { contextMenu(for: change, root: root) }, onRightClick: { + if !selection.contains(change.path) { selection = [change.path] } + }) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(verbatim: "\(change.path), \(change.status.title)")) + } + + static func rowHelp(_ change: VcsChange) -> String { + var parts = [change.path, change.status.title] + if let oldPath = change.oldPath { parts.append(String(format: String(localized: "Moved from %@"), oldPath)) } + if change.hasTreeConflict { parts.append(String(localized: "Tree conflict")) } + if change.flags.contains("propertiesModified") { parts.append(String(localized: "Properties modified")) } + if change.flags.contains("locked") { parts.append(String(localized: "Locked in this working copy")) } + if change.flags.contains("lockedByOther") { parts.append(String(localized: "Locked by another user")) } + return parts.joined(separator: "\n") + } + + private func select(_ change: VcsChange) { + if NSEvent.modifierFlags.contains(.command) { + if selection.contains(change.path) { selection.remove(change.path) } else { selection.insert(change.path) } + } else { + selection = [change.path] + } + } + + /// Double-click compares with the base when there is one; a new or + /// deleted file has nothing to compare, so it opens or does nothing. + private func open(_ change: VcsChange, in root: VcsRootState) { + selection = [change.path] + if root.supports(.baseContent) && ![.unversioned, .added, .copied].contains(change.status) { + showDiff(change, in: root) + } else if ![.deleted, .missing].contains(change.status) { + actions.openFile(root.url.appendingPathComponent(change.path)) + } + } + + private func contextMenu(for change: VcsChange, root: VcsRootState) -> [LitheContextMenuItem] { + let selected = selectedChanges(root) + let url = root.url.appendingPathComponent(change.path) + let exists = ![.deleted, .missing].contains(change.status) + let busy = feature.runningOperation != nil + var items: [LitheContextMenuItem] = [] + if root.supports(.baseContent) { + items.append(.action(String(localized: "Show Diff"), localizesTitle: false, isEnabled: selected.count == 1) { + showDiff(change, in: root) + }) + } + items.append(.action(String(localized: "Open"), localizesTitle: false, isEnabled: exists) { actions.openFile(url) }) + if root.supports(.fileHistory) && change.status != .unversioned { + items.append(.action(String(localized: "Show History"), localizesTitle: false) { + historyPath = change.path + tab = .history + }) + } + items.append(.separator) + if root.supports(.conflicts) && !conflicted(selected).isEmpty { + items.append(.action(String(localized: "Mark as Resolved"), localizesTitle: false, isEnabled: !busy) { + resolve(conflicted(selected), in: root) + }) + } + if root.supports(.rollback) && !rollbackTargets(selected).isEmpty { + items.append(.action(String(localized: "Roll Back…"), localizesTitle: false, role: .destructive, isEnabled: !busy) { + pendingRollback = rollbackTargets(selected) + }) + } + items.append(.separator) + items.append(.action(String(localized: "Copy Path"), localizesTitle: false) { actions.copyPath(url) }) + items.append(.action(String(localized: "Reveal in Finder"), localizesTitle: false, isEnabled: exists) { + actions.revealInFinder(url) + }) + return items + } + + private func selectedChanges(_ root: VcsRootState) -> [VcsChange] { + (root.snapshot?.changes ?? []).filter { selection.contains($0.path) } + } + + /// Rolling back cannot bring back a file the VCS never had. + private func rollbackTargets(_ changes: [VcsChange]) -> [VcsChange] { + changes.filter { ![.unversioned, .ignored].contains($0.status) } + } + + private func conflicted(_ changes: [VcsChange]) -> [VcsChange] { + changes.filter { Self.group(of: $0) == .conflicts } + } + + // MARK: Commit + + private func commitArea(_ root: VcsRootState) -> some View { + let included = (root.snapshot?.changes ?? []).filter(isIncluded) + let canCommit = !included.isEmpty && !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && feature.runningOperation == nil + return VStack(spacing: 0) { + CommitMessageEditor(text: $message, focused: $messageFocused) + .frame(maxWidth: .infinity, minHeight: 64, maxHeight: 140, alignment: .topLeading) + .background { + RoundedRectangle(cornerRadius: LitheTheme.Commit.controlCornerRadius) + .fill(LitheTheme.editor) + .padding(1.5) + } + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Commit.controlCornerRadius) + .strokeBorder( + messageFocused ? LitheTheme.searchFieldFocusBorder : LitheTheme.searchFieldBorder, + lineWidth: messageFocused ? 2 : 1 + ) + .padding(messageFocused ? 0 : 1) + .allowsHitTesting(false) + } + .padding(.horizontal, LitheTheme.Commit.messageHorizontalGap) + .padding(.vertical, LitheTheme.Commit.messageVerticalGap) + HStack(spacing: 8) { + Button { + commit(included, in: root) + } label: { + HStack(spacing: 6) { + if feature.runningOperation?.rootID == root.id { ProgressView().controlSize(.mini) } + Text("Commit") + } + } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)) + .disabled(!canCommit) + Text(String(format: String(localized: "%lld files selected"), included.count)) + .font(LitheTheme.uiFont(size: LitheTheme.Commit.metadataFontSize)) + .foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 0) + } + .padding(.leading, LitheTheme.Commit.contentInset - LitheTheme.Commit.buttonBorderInset) + .padding(.trailing, LitheTheme.Commit.contentInset) + .padding(.bottom, LitheTheme.Commit.buttonBorderInset + 4) + } + .background(LitheTheme.toolHeader) + } + + /// A move commits both of its paths, so the source is never left behind. + static func commitPaths(_ changes: [VcsChange]) -> [String] { + var seen: Set = [] + return changes.flatMap { [$0.path] + ($0.oldPath.map { [$0] } ?? []) }.filter { seen.insert($0).inserted } + } + + private func commit(_ changes: [VcsChange], in root: VcsRootState) { + let text = message + let rootID = root.id + Task { + guard let result = await feature.checkin(Self.commitPaths(changes), message: text, in: rootID) else { return } + feature.commitMessageDrafts[rootID] = nil + if self.root?.id == rootID { + message = "" + inclusion = [:] + selection = [] + } + notice = result.revision.map { String(format: String(localized: "Committed revision %@."), $0.display) } + ?? String(localized: "Committed.") + } + } + + // MARK: Actions + + private func update(_ root: VcsRootState) { + notice = nil + Task { + guard let result = await feature.update(root.id) else { return } + var text = result.revision.map { String(format: String(localized: "Updated to revision %@."), $0.display) } + ?? String(localized: "Updated.") + if !result.conflicts.isEmpty { + text += " " + String(format: String(localized: "%lld files have conflicts."), result.conflicts.count) + } + notice = text + } + } + + private func rollback(_ changes: [VcsChange]) { + guard let root else { return } + let paths = Self.commitPaths(changes) + Task { + if await feature.rollback(paths, in: root.id) { selection.subtract(paths) } + } + } + + private func resolve(_ changes: [VcsChange], in root: VcsRootState) { + Task { _ = await feature.resolve(changes.map(\.path), in: root.id) } + } + + private func cleanup(_ root: VcsRootState) { + Task { + if await feature.cleanup(root.id) { notice = String(localized: "The working copy was cleaned up.") } + } + } + + private func showDiff(_ change: VcsChange, in root: VcsRootState) { + feature.showDiff(change, in: root.id) + } + + // MARK: History + + @ViewBuilder + private func historyPane(_ root: VcsRootState) -> some View { + VStack(spacing: 0) { + if let historyPath { + HStack(spacing: 6) { + Text(String(format: String(localized: "History of %@"), historyPath)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Button(String(localized: "Show All")) { self.historyPath = nil } + .buttonStyle(.link) + } + .font(LitheTheme.uiFont(size: 12)) + .padding(.horizontal, 10) + .frame(height: 26) + } + VcsHistoryListView(feature: feature, rootID: root.id, path: historyPath) + .id([root.id, historyPath ?? ""]) + } + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift b/macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift new file mode 100644 index 0000000000..26ce916d93 --- /dev/null +++ b/macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift @@ -0,0 +1,95 @@ +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +extension WorkbenchModuleUIRegistry { + /// The tool windows of one plugin's VCS providers. Every provider gets + /// the same window; the contribution's visibility keys say which VCS it + /// lists, so no plugin or VCS name appears here. + static func vcsProviderRegistration(_ declaration: PluginModuleDeclaration) -> Registration { + let owner = declaration.manifest.id + var actions: [Action] = [] + var renderers: [Renderer] = [] + for window in declaration.contributions where window.kind == .toolWindow { + guard let actionID = window.actionID, let rendererID = window.rendererID, + let pluginID = window.visibility[DeclarativeVcsProvider.pluginIDKey], + let vcs = window.visibility[DeclarativeVcsProvider.vcsKey] else { continue } + let tool = WorkbenchFeatureModel.ToolWindow(rawValue: window.id) + // Detection already ran when the workspace opened; opening the + // window only makes sure it ran for this workspace. + let activate: @MainActor (AppModel) async -> Bool = { model in + do { + _ = try await model.services.moduleRuntime.activate(owner) + } catch { + if !Task.isCancelled && !(error is CancellationError) { model.showNotification(error.localizedDescription) } + return false + } + guard !Task.isCancelled, let workspaceURL = model.workspaceURL else { return false } + model.loadVcsProviders(at: workspaceURL) + return true + } + let toggle: @MainActor (AppModel) -> Void = { $0.performToolWindowContribution(window, owner: owner) } + actions.append(Action(id: actionID, perform: toggle, activateOwner: activate, state: { model in + ActionState( + isVisible: model.workspaceURL != nil, isEnabled: model.workspaceURL != nil, + isChecked: model.workbenchFeature.isVisible(tool), + isPending: model.contributionActivationTasks[window.id] != nil + ) + })) + for command in declaration.contributions where command.kind == .command + && command.visibility["toolWindowID"] == window.id { + guard let commandActionID = command.actionID, commandActionID != actionID else { continue } + actions.append(Action(id: commandActionID, perform: toggle, state: { model in + let available = model.workspaceURL != nil + let pending = model.contributionActivationTasks[window.id] != nil + return ActionState( + isVisible: available, isEnabled: available && !pending, + isChecked: model.workbenchFeature.isVisible(tool), isPending: pending + ) + })) + } + renderers.append(Renderer( + id: rendererID, ideaAssetPath: "expui/toolwindows/vcs@20x20.svg", + isVisible: { $0.workspaceURL != nil }, + isSelected: { $0.workbenchFeature.isVisible(tool) }, + content: { model in + // The feature exists when a VCS plugin was installed at + // launch; one installed later needs a restart. + guard let feature = model.vcsProvidersFeature else { + return AnyView(VcsProviderUnavailableView(title: window.title)) + } + return AnyView(VcsProviderToolWindowView( + feature: feature, title: window.title, pluginID: pluginID, vcs: vcs, + actions: VcsProviderToolWindowActions( + // A root may hold the workspace, so its files can lie outside it. + openFile: { [weak model] url in model?.openFile(url, allowsOutsideWorkspace: true) }, + revealInFinder: { [weak model] url in model?.revealProjectItemInFinder(url) }, + copyPath: { [weak model] url in model?.copyProjectItemPath(url, relative: false) }, + hide: { [weak model] in model?.workbenchFeature.setVisibility(tool, isVisible: false) } + ) + )) + }, + contentIdentity: { WorkbenchModuleUIRegistry.Renderer.featureIdentity($0.vcsProvidersFeature) }, + rightSidebarBehavior: .docked, + help: window.title + )) + } + return Registration(contributions: declaration.contributions, actions: actions, renderers: renderers) + } +} + +private struct VcsProviderUnavailableView: View { + let title: String + + var body: some View { + Text(String(format: String(localized: "Restart Lithe to use %@."), title)) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .multilineTextAlignment(.center) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .litheWorkbenchSurface(LitheTheme.editor) + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index cf3728d5ff..1fd032b60d 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -77,13 +77,18 @@ enum WorkbenchModuleUIComposition { static func registry(for catalog: ValidatedPluginCatalog, iconResources: [String: PluginUIIconResources] = [:]) throws -> WorkbenchModuleUIRegistry { let plugins = catalog.manifests.filter { $0.entrypoint.kind == .declarative } let declarations = plugins.flatMap(\.modules).filter { !$0.contributions.isEmpty } - // Build-system windows are derived from the declaration, not - // declared by the plugin, and share one host view. + // Build-system and VCS windows are derived from the declaration, + // not declared by the plugin, and each kind shares one host view. let buildSystemModules = Set(plugins.map { ModuleID.buildSystemsExtension($0.id.rawValue) }) + let vcsProviderModules = Set(plugins.map { ModuleID.vcsProvidersExtension($0.id.rawValue) }) var registry = try WorkbenchModuleUIRegistry(registrations: registrations + declarations.map { - buildSystemModules.contains($0.manifest.id) - ? WorkbenchModuleUIRegistry.buildSystemRegistration($0, iconResources: iconResources) - : WorkbenchModuleUIRegistry.declarativeRegistration($0, iconResources: iconResources) + if buildSystemModules.contains($0.manifest.id) { + return WorkbenchModuleUIRegistry.buildSystemRegistration($0, iconResources: iconResources) + } + if vcsProviderModules.contains($0.manifest.id) { + return WorkbenchModuleUIRegistry.vcsProviderRegistration($0) + } + return WorkbenchModuleUIRegistry.declarativeRegistration($0, iconResources: iconResources) }) registry.registerIcons(declarations.flatMap(\.contributions), resources: iconResources) return registry diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index d3a85c428e..b8d3076b15 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -61,18 +61,13 @@ struct ProjectSidebarView: View { .foregroundStyle(LitheTheme.secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let root = model.rootNode { - ProjectSidebarTree( - model: model, - root: root, - rowHeight: rowHeight, - activeDocumentURL: model.activeDocument?.url, - gitStatus: ProjectGitStatusSnapshot(repositoryRoot: model.gitRepositoryRoot, projection: model.gitTreeStatusProjection), - directoryMarks: model.projectDirectoryMarks, - revealRequest: model.projectTreeRevealRequest, - stateSnapshot: treeState, - state: $treeState - ) - .equatable() + // Plugin VCS statuses publish on their own model, so only + // this reader observes it. + if let feature = model.vcsProvidersFeature { + VcsTreeStatusReader(feature: feature) { tree(root, vcsStatus: $0) } + } else { + tree(root, vcsStatus: VcsTreeStatusProjection()) + } } else if let error = model.workspaceLoadErrorMessage { VStack(spacing: 10) { Image(systemName: "folder.badge.questionmark") @@ -164,6 +159,23 @@ struct ProjectSidebarView: View { } } + private func tree(_ root: FileNode, vcsStatus: VcsTreeStatusProjection) -> some View { + ProjectSidebarTree( + model: model, + root: root, + rowHeight: rowHeight, + activeDocumentURL: model.activeDocument?.url, + gitStatus: ProjectGitStatusSnapshot( + repositoryRoot: model.gitRepositoryRoot, projection: model.gitTreeStatusProjection, vcs: vcsStatus + ), + directoryMarks: model.projectDirectoryMarks, + revealRequest: model.projectTreeRevealRequest, + stateSnapshot: treeState, + state: $treeState + ) + .equatable() + } + private var sidebarHeader: some View { HStack(spacing: 8) { LitheMenu { @@ -480,9 +492,18 @@ private struct ProjectTreeTaskID: Equatable { let revealRequestID: UUID? } +private struct VcsTreeStatusReader: View { + @ObservedObject var feature: VcsProvidersFeatureModel + let content: (VcsTreeStatusProjection) -> Content + + var body: some View { content(feature.treeStatus) } +} + private struct ProjectGitStatusSnapshot: Equatable { let repositoryRoot: URL? let projection: GitTreeStatusProjection + /// Statuses of plugin VCS roots; Git's own win where both report. + var vcs = VcsTreeStatusProjection() func kind(for url: URL, isDirectory: Bool) -> GitChangeKind? { return projection.kind(relativePath: url.standardizedFileURL.path, isDirectory: isDirectory) @@ -492,6 +513,10 @@ private struct ProjectGitStatusSnapshot: Equatable { return projection.change(relativePath: url.standardizedFileURL.path) } + func vcsStatus(for url: URL) -> VcsChangeStatus? { + vcs.isEmpty ? nil : vcs.status(for: url) + } + private static func relativePath(for url: URL, root: URL) -> String? { let normalizedRoot = root.standardizedFileURL.path let normalizedPath = url.standardizedFileURL.path @@ -512,6 +537,16 @@ private final class ProjectTreeActions: @unchecked Sendable { AgentFileAttachmentMenu.items(model: model, urls: urls) } + /// "Show Diff" for a file a plugin VCS lists as changed. + @MainActor + func vcsDiffMenuItem(_ url: URL) -> LitheContextMenuItem? { + guard let feature = model.vcsProvidersFeature, let (root, change) = feature.change(at: url), + root.supports(.baseContent), ![.unversioned, .ignored, .added].contains(change.status) else { return nil } + return .action(String(format: String(localized: "Show %@ Diff"), root.displayName), localizesTitle: false) { + feature.showDiff(change, in: root.id) + } + } + // Button and context-menu closures are not MainActor-isolated under the // Swift 6 test/release check. Keep these methods synchronous and hop. nonisolated func openFile(_ url: URL) { @@ -765,7 +800,7 @@ private struct FileNodeRow: View { .frame(width: LitheTheme.Metrics.treeIconSize, height: LitheTheme.Metrics.treeIconSize) Text(node.name) .font(LitheTheme.uiFont(size: LitheTheme.Metrics.treeFontSize, weight: depth == 0 ? .semibold : .regular)) - .foregroundStyle(gitStatusColor ?? LitheTheme.primaryText) + .foregroundStyle(statusColor ?? LitheTheme.primaryText) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) @@ -804,7 +839,7 @@ private struct FileNodeRow: View { .frame(width: LitheTheme.Metrics.treeIconSize) Text(node.name) .font(LitheTheme.uiFont(size: LitheTheme.Metrics.treeFontSize)) - .foregroundStyle(gitStatusColor ?? LitheTheme.primaryText) + .foregroundStyle(statusColor ?? LitheTheme.primaryText) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) @@ -814,6 +849,11 @@ private struct FileNodeRow: View { .font(LitheTheme.uiFont(size: 9, weight: .bold, design: .monospaced)) .foregroundStyle(gitStatusColor ?? LitheTheme.secondaryText) .accessibilityLabel(status.kind.title) + } else if let status = gitStatus.vcsStatus(for: node.url), status != .ignored { + Text(verbatim: status.letter) + .font(LitheTheme.uiFont(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(status.treeColor) + .accessibilityLabel(status.title) } } .padding(.leading, CGFloat(depth) * LitheTheme.Tree.indent) @@ -1043,6 +1083,8 @@ private struct FileNodeRow: View { actions.selectChange(change) } ] + } else if let item = actions.vcsDiffMenuItem(node.url) { + items.append(item) } // Windows parity: a Git submenu with the remaining single-file actions. @@ -1099,6 +1141,10 @@ private struct FileNodeRow: View { return items } + private var statusColor: Color? { + gitStatusColor ?? gitStatus.vcsStatus(for: node.url)?.treeColor + } + private var gitStatusColor: Color? { guard let kind = gitStatus.kind(for: node.url, isDirectory: node.isDirectory) else { return nil diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 06027af978..f61d8bca86 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -461,6 +461,18 @@ package protocol LanguageServerRuntimeCore: Sendable { params: ToolingJSONValue, timeout: TimeInterval ) -> Result + /// Sends one operation of the negotiated VCS-provider protocol. Its + /// completion arrives as a `requestCompleted` event with the reply under + /// `result.value`; a failure carries a `vcs.*` code. + func requestVcsProvider( + sessionID: String, + vcs: String, + operation: String, + root: String, + tool: String?, + arguments: ToolingJSONValue, + timeout: TimeInterval + ) -> Result /// Starts a process a plugin owns. Only such a session negotiates plugin /// functions, and Core lets it serve that plugin's functions alone. func startPluginLanguageServer( @@ -625,6 +637,21 @@ package extension LanguageServerRuntimeCore { )) } + func requestVcsProvider( + sessionID _: String, + vcs _: String, + operation _: String, + root _: String, + tool _: String?, + arguments _: ToolingJSONValue, + timeout _: TimeInterval + ) -> Result { + .failure(LanguageServerRuntimeFailure( + code: "not_supported", + message: "VCS-provider requests are not supported by this runtime." + )) + } + /// Runtimes without plugin ownership start a host-owned session, which /// serves no plugin functions. func startPluginLanguageServer( diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index 2188331f2c..bc3220eb13 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -443,6 +443,8 @@ public struct DeclarativeLanguageServerLaunchPlan: Equatable, Sendable { public let executable: Executable? /// Set when the plan was selected by build system. public let buildProvider: BuildProvider? + /// Set when the plan was selected by VCS provider. + public var vcsProvider: VcsProvider? = nil public enum Executable: Equatable, Sendable { /// Package-relative file. @@ -468,6 +470,21 @@ public struct DeclarativeLanguageServerLaunchPlan: Equatable, Sendable { } } + /// What the host needs besides the process: the VCS the provider must + /// negotiate and the client tool it resolves for every request. + public struct VcsProvider: Equatable, Sendable { + public let vcs: String + /// Bare command names, resolved like a `systemCommand` launch. + public let toolCommands: [String] + public let toolValidationArguments: [String] + public let mayUseNetwork: Bool + + public init(vcs: String, toolCommands: [String], toolValidationArguments: [String], mayUseNetwork: Bool) { + self.vcs = vcs; self.toolCommands = toolCommands + self.toolValidationArguments = toolValidationArguments; self.mayUseNetwork = mayUseNetwork + } + } + public init( arguments: [String], validationArguments: [String] = [], @@ -559,6 +576,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { public let languageServers: [DeclarativeLanguageServer] public let executionProviders: [DeclarativeExecutionProvider] public let buildSystems: [DeclarativeBuildSystem] + /// VCS providers the plugin adds (`vcsProviders` capability). + public let vcsProviders: [DeclarativeVcsProvider] public let settings: [DeclarativePluginSetting] /// Buttons the plugin adds to settings pages (settings capability 2). public let settingActions: [DeclarativePluginSettingAction] @@ -604,6 +623,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { ) } buildSystems = decoded.buildSystems.map { $0.declaration() } + vcsProviders = decoded.vcsProviders.map { DeclarativeVcsProvider(id: $0.id, displayName: $0.displayName) } ui = decoded.ui settings = try decoded.settings.map { try $0.declaration() } settingActions = decoded.settingActions.map { $0.declaration() } @@ -654,6 +674,20 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { } )) } + if !vcsProviders.isEmpty { + // One version-control window per provider, enabled with the + // plugin. Root detection, launch, and replies belong to Rust Core. + modules.append(PluginModuleDeclaration( + manifest: LanguageExtensionModuleManifests.vcsProviders( + pluginID: decoded.id, + displayName: decoded.displayName, + enabledByDefault: enabledByDefault + ), + contributions: vcsProviders.enumerated().flatMap { index, provider in + provider.contributions(pluginID: decoded.id, order: index) + } + )) + } if let ui = decoded.ui { modules.append(PluginModuleDeclaration( manifest: ModuleManifest( @@ -920,6 +954,13 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { struct JSONPresence: Decodable { init(from decoder: Decoder) throws {} } + /// The parts of a VCS provider the host shows. Root detection, + /// commands, and reply checks stay with Rust Core. + struct VcsProvider: Decodable { + let id: String + let displayName: String + } + /// The parts of a build system the tool window lists. Detection, /// commands, and decoders stay with Rust Core. struct BuildSystem: Decodable { @@ -1037,6 +1078,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let languageServers: [LanguageServer] let executionProviders: [ExecutionProvider] let buildSystems: [BuildSystem] + let vcsProviders: [VcsProvider] let ui: DeclarativeUIContributions? let settings: [Setting] let settingActions: [SettingAction] @@ -1055,13 +1097,14 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { languageServers = try values.decodeIfPresent([LanguageServer].self, forKey: .languageServers) ?? [] executionProviders = try values.decodeIfPresent([ExecutionProvider].self, forKey: .executionProviders) ?? [] buildSystems = try values.decodeIfPresent([BuildSystem].self, forKey: .buildSystems) ?? [] + vcsProviders = try values.decodeIfPresent([VcsProvider].self, forKey: .vcsProviders) ?? [] settings = try values.decodeIfPresent([Setting].self, forKey: .settings) ?? [] settingActions = try values.decodeIfPresent([SettingAction].self, forKey: .settingActions) ?? [] } private enum CodingKeys: String, CodingKey { case id, displayName, version, pluginAPIVersion, vendor, permissions, activationEvents - case languages, languageServers, executionProviders, buildSystems, ui, settings, settingActions + case languages, languageServers, executionProviders, buildSystems, vcsProviders, ui, settings, settingActions } } } @@ -1141,6 +1184,12 @@ public protocol DeclarativeLanguageServerLaunchPlanning: Sendable { buildSystemID: String, request: DeclarativeLanguageServerLaunchRequest ) throws -> DeclarativeLanguageServerLaunchPlan + /// The launch plan of a VCS provider, with `vcsProvider` set. + func vcsProviderLaunchPlan( + manifest: Data, + vcs: String, + request: DeclarativeLanguageServerLaunchRequest + ) throws -> DeclarativeLanguageServerLaunchPlan } public extension DeclarativeLanguageServerLaunchPlanning { @@ -1154,6 +1203,17 @@ public extension DeclarativeLanguageServerLaunchPlanning { message: "This host cannot plan build provider launches." ) } + + func vcsProviderLaunchPlan( + manifest _: Data, + vcs _: String, + request _: DeclarativeLanguageServerLaunchRequest + ) throws -> DeclarativeLanguageServerLaunchPlan { + throw DeclarativePluginRejection( + code: "plugin.launch.unavailable", + message: "This host cannot plan VCS provider launches." + ) + } } /// Reads a launch runtime's version from its probe output and compares it diff --git a/macos/Sources/LitheCoreContracts/Plugins/PluginMarketplaceContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/PluginMarketplaceContracts.swift index eea82719d8..d0e09d167d 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/PluginMarketplaceContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/PluginMarketplaceContracts.swift @@ -242,6 +242,9 @@ public struct PluginProgram: Equatable, Hashable, Sendable { case run case test case buildSystem + case packagedVcsProvider + case systemVcsProvider + case runtimeVcsProvider case unknown } @@ -306,6 +309,27 @@ public struct PluginProgram: Equatable, Hashable, Sendable { } } + /// VCS ID of a VCS provider program. + public var vcs: String? { string("vcs") } + + /// Client commands a VCS provider may run, in resolution order. They + /// belong to the approved program, so another client is a new approval. + public var vcsClientCommands: [String] { + guard case .object(let fields) = json, case .object(let tool)? = fields["vcsTool"], + case .array(let commands)? = tool["commands"] else { return [] } + return commands.compactMap { if case .string(let command) = $0 { command } else { nil } } + } + + /// Script a runtime launch runs, inside the plugin. + public var entry: String? { string("entry") } + public var runtimeArguments: [String] { strings("runtimeArguments") } + + /// The provider may reach a server, such as a Subversion repository. + public var mayUseNetwork: Bool { + guard case .object(let fields) = json, case .bool(let value)? = fields["mayUseNetwork"] else { return false } + return value + } + /// Task IDs of a `buildSystem` program. public var buildTaskIDs: [String] { guard case .object(let fields) = json, case .array(let tasks)? = fields["tasks"] else { return [] } diff --git a/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift b/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift new file mode 100644 index 0000000000..cde546b931 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift @@ -0,0 +1,436 @@ +import Foundation +import LitheModuleAPI + +// Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md + +// MARK: Declarations + +/// A VCS provider a declarative plugin adds (`vcsProviders` capability). +/// Rust Core owns its root detection, launch plan, requests, and reply +/// validation; the host only needs to know which VCS it is and its name. +public struct DeclarativeVcsProvider: Equatable, Sendable { + /// The VCS ID, such as `svn`, that roots and replies carry. + public let id: String + public let displayName: String + + public init(id: String, displayName: String) { + self.id = id + self.displayName = displayName + } + + // MARK: Tool window + + /// Contribution visibility keys naming the provider a host-derived + /// window or command belongs to. + public static let pluginIDKey = "vcsProviderPluginID" + public static let vcsKey = "vcsProviderID" + + /// The host's tool window for one provider. Plugin-declared UI ids + /// cannot contain `:`, so this never collides with the plugin's own windows. + public static func toolWindowID(pluginID: String, vcs: String) -> String { + "\(pluginID).vcs-providers:\(vcs)" + } + + /// The tool window and its toggle command, derived from the declaration + /// like a build system's: every provider gets the same window, which + /// sits next to the Git commit window. + public func contributions(pluginID: String, order: Int) -> [ModuleContribution] { + let windowID = Self.toolWindowID(pluginID: pluginID, vcs: id) + let visibility = [Self.pluginIDKey: pluginID, Self.vcsKey: id] + return [ + ModuleContribution( + id: windowID, kind: .toolWindow, title: displayName, placement: .leftSidebar, + order: 210 + order, actionID: windowID + ".toggle", rendererID: windowID, + visibility: visibility + ), + ModuleContribution( + id: windowID + ".command", kind: .command, title: displayName, placement: .commandPalette, + order: order, actionID: windowID + ".command", + visibility: visibility.merging(["toolWindowID": windowID]) { _, new in new } + ) + ] + } +} + +/// An enabled plugin's VCS providers, as the host hands them to Rust Core. +package struct VcsProviderPluginSource: Equatable, Sendable { + package let pluginID: String + /// Normalized manifest from `plugins.declarative.validate`. + package let manifest: Data + /// Installed package directory, read-only, for `${pluginDir}`. + package let packageURL: URL + package let providers: [DeclarativeVcsProvider] + + package init(pluginID: String, manifest: Data, packageURL: URL, providers: [DeclarativeVcsProvider]) { + self.pluginID = pluginID; self.manifest = manifest; self.packageURL = packageURL + self.providers = providers + } +} + +/// The provider process of one VCS of one plugin in one workspace. Two +/// plugins, or two workspaces, never share a process. +package struct VcsProviderTarget: Hashable, Sendable { + package let pluginID: String + package let vcs: String + package let manifest: Data + package let packageURL: URL + package let workspaceURL: URL + + package init(pluginID: String, vcs: String, manifest: Data, packageURL: URL, workspaceURL: URL) { + self.pluginID = pluginID; self.vcs = vcs; self.manifest = manifest + self.packageURL = packageURL; self.workspaceURL = workspaceURL.standardizedFileURL + } +} + +// MARK: Negotiation + +/// Capabilities of protocol version 1. UI decisions read these only, never +/// the VCS ID. +package enum VcsCapability: String, CaseIterable, Sendable { + case status, baseContent, checkin, rollback, history, fileHistory, annotate, update + case push, staging, fileOperations, conflicts, cleanup +} + +/// What a provider answered in `initialize`, as Core's `vcsProvider` +/// runtime event reports it. +public struct VcsProviderNegotiation: Equatable, Sendable, Decodable { + public let vcs: String + /// Capability ID to version; Core drops what it cannot validate. + public let capabilities: [String: Int] + + public init(vcs: String, capabilities: [String: Int]) { + self.vcs = vcs + self.capabilities = capabilities + } + + package func supports(_ capability: VcsCapability) -> Bool { + capabilities[capability.rawValue] != nil + } +} + +// MARK: Detection + +/// One provider `vcs.detectRoots` knows, the built-in Git included. +package struct VcsProviderDescriptor: Equatable, Sendable, Decodable { + package struct Origin: Equatable, Sendable, Decodable { + /// `builtIn` or `plugin`. + package let kind: String + package let pluginID: String? + } + + package struct Tool: Equatable, Sendable, Decodable { + package let commands: [String] + package let validationArguments: [String]? + } + + /// An entry directly inside a root that marks it, such as `.svn`. + package struct Marker: Equatable, Sendable, Decodable { + package let name: String + /// `directory` or `file`. + package let type: String + } + + package let vcs: String + package let displayName: String + package let origin: Origin + package let markers: [Marker] + /// Root-relative files whose change means the status may be stale, + /// such as `.svn/wc.db`. Absent for the built-in Git. + package let metadataPaths: [String]? + package let tool: Tool? + package let mayUseNetwork: Bool? +} + +/// A working-copy root. `id` is relative to the workspace (`""` is the +/// workspace itself, `..` an enclosing working copy); `path` is absolute. +package struct VcsRoot: Equatable, Hashable, Sendable, Decodable { + package let id: String + package let vcs: String + package let path: String + /// The user chose this VCS for an ambiguous directory. + package let mapped: Bool + + package init(id: String, vcs: String, path: String, mapped: Bool = false) { + self.id = id; self.vcs = vcs; self.path = path; self.mapped = mapped + } +} + +/// A directory with markers of several VCSs. Core never guesses; it stays +/// unowned until the user maps it. +package struct VcsAmbiguousDirectory: Equatable, Sendable, Decodable { + package let id: String + package let vcs: [String] +} + +/// The user's choice for an ambiguous directory. +package struct VcsRootMapping: Equatable, Hashable, Sendable, Codable { + package let id: String + package let vcs: String + + package init(id: String, vcs: String) { + self.id = id; self.vcs = vcs + } +} + +package struct VcsDetection: Equatable, Sendable, Decodable { + package struct Rejected: Equatable, Sendable, Decodable { + package struct Error: Equatable, Sendable, Decodable { + package let code: String + package let message: String + } + + package let index: Int + package let error: Error + } + + package struct Conflict: Equatable, Sendable, Decodable { + package let pluginID: String + package let vcs: String + package let by: String + } + + package let providers: [VcsProviderDescriptor] + package let roots: [VcsRoot] + package let ambiguous: [VcsAmbiguousDirectory] + package let unusedMappings: [VcsRootMapping] + /// The directory budget ran out, so a root may be missing. + package let truncated: Bool + package let rejected: [Rejected] + package let conflicts: [Conflict] +} + +// MARK: Replies + +package struct VcsRevision: Equatable, Hashable, Sendable, Decodable { + package let id: String + package let display: String + /// `hash`, `number`, or `opaque`; presentation only. + package let kind: String +} + +package struct VcsHead: Equatable, Sendable, Decodable { + package let revision: VcsRevision? + package let branch: String? + package let location: String? +} + +package enum VcsChangeStatus: String, Equatable, Hashable, Sendable, Decodable { + case modified, added, deleted, missing, renamed, copied, replaced, typeChanged + case unversioned, ignored, conflicted, switched +} + +/// One changed path, relative to its root. +package struct VcsChange: Equatable, Hashable, Sendable, Decodable { + package let path: String + package let oldPath: String? + package let status: VcsChangeStatus + /// `external`, `locked`, `lockedByOther`, `propertiesModified`, + /// `submodule`, `treeConflict`; unknown flags never reach the host. + package let flags: [String] + + package init(path: String, oldPath: String? = nil, status: VcsChangeStatus, flags: [String] = []) { + self.path = path; self.oldPath = oldPath; self.status = status; self.flags = flags + } + + package var hasTreeConflict: Bool { flags.contains("treeConflict") } +} + +package struct VcsStatusSnapshot: Equatable, Sendable, Decodable { + package let head: VcsHead + package let changes: [VcsChange] + /// The list is incomplete and must not be shown as complete. + package let truncated: Bool +} + +package struct VcsBaseContent: Equatable, Sendable, Decodable { + /// Nil when the file has no base, or is binary. + package let content: String? + package let binary: Bool +} + +package struct VcsHistoryEntry: Equatable, Sendable, Decodable, Identifiable { + package let revision: VcsRevision + package let author: String? + package let date: String? + package let message: String + package let changes: [VcsChange] + package let changesTruncated: Bool + + package var id: String { revision.id } +} + +package struct VcsHistoryPage: Equatable, Sendable, Decodable { + package let entries: [VcsHistoryEntry] + package let nextCursor: String? +} + +package struct VcsCheckinResult: Equatable, Sendable, Decodable { + package let revision: VcsRevision? +} + +package struct VcsUpdateResult: Equatable, Sendable, Decodable { + package let revision: VcsRevision? + package let conflicts: [String] +} + +/// Replies of operations that return nothing. +package struct VcsEmptyResult: Equatable, Sendable, Decodable {} + +// MARK: Operations + +/// One operation of protocol version 1 with its arguments. The host names +/// operations, never methods; Core plans the request. +package enum VcsOperation: Equatable, Sendable { + case status(includeIgnored: Bool) + case baseContent(path: String) + case history(cursor: String?, limit: Int?) + case fileHistory(path: String, cursor: String?, limit: Int?) + case checkin(paths: [String], message: String) + case rollback(paths: [String]) + case update(revision: String?) + case add(paths: [String]) + case delete(paths: [String]) + case move(from: String, to: String) + case resolve(paths: [String]) + case cleanup + + package var name: String { + switch self { + case .status: "status" + case .baseContent: "baseContent" + case .history: "history" + case .fileHistory: "fileHistory" + case .checkin: "checkin" + case .rollback: "rollback" + case .update: "update" + case .add, .delete, .move: "fileOperation" + case .resolve: "resolve" + case .cleanup: "cleanup" + } + } + + package var capability: VcsCapability { + switch self { + case .status: .status + case .baseContent: .baseContent + case .history: .history + case .fileHistory: .fileHistory + case .checkin: .checkin + case .rollback: .rollback + case .update: .update + case .add, .delete, .move: .fileOperations + case .resolve: .conflicts + case .cleanup: .cleanup + } + } + + /// Whether the operation changes the working copy, so status must be + /// read again after it. + package var modifiesWorkingCopy: Bool { + switch self { + case .status, .baseContent, .history, .fileHistory: false + default: true + } + } + + /// The arguments as `lsp.vcsRequest` and `vcs.decodeReply` take them. + package var arguments: ToolingJSONValue { + func paths(_ values: [String]) -> ToolingJSONValue { .array(values.map { .string($0) }) } + func optional(_ value: String?) -> ToolingJSONValue { value.map { .string($0) } ?? .null } + var members: [String: ToolingJSONValue] = [:] + switch self { + case .status(let includeIgnored): + if includeIgnored { members["includeIgnored"] = .bool(true) } + case .baseContent(let path): + members["path"] = .string(path) + case .history(let cursor, let limit): + if let cursor { members["cursor"] = .string(cursor) } + if let limit { members["limit"] = .integer(limit) } + case .fileHistory(let path, let cursor, let limit): + members["path"] = .string(path) + if let cursor { members["cursor"] = .string(cursor) } + if let limit { members["limit"] = .integer(limit) } + case .checkin(let values, let message): + members["paths"] = paths(values) + members["message"] = .string(message) + case .rollback(let values), .resolve(let values): + members["paths"] = paths(values) + case .update(let revision): + if let revision { members["revision"] = optional(revision) } + case .add(let values): + members["kind"] = .string("add") + members["paths"] = paths(values) + case .delete(let values): + members["kind"] = .string("delete") + members["paths"] = paths(values) + case .move(let from, let to): + members["kind"] = .string("move") + members["from"] = .string(from) + members["to"] = .string(to) + case .cleanup: + break + } + return .object(members) + } +} + +/// A failed VCS request. `code` is a contract code such as `vcs.outOfDate`, +/// or `plugin.vcs.invalidOutput` when Core rejected the reply. +package struct VcsProviderFailure: LocalizedError, Equatable, Sendable { + package let code: String + package let message: String + + package init(code: String, message: String) { + self.code = code; self.message = message + } + + package var errorDescription: String? { message } +} + +// MARK: Ports + +/// Rust Core's `vcs.*` commands. Implementations throw +/// `DeclarativePluginRejection` when Core cannot be reached and +/// `VcsProviderFailure` for a rejected reply. +package protocol VcsRootPlanning: Sendable { + func detectRoots( + workspaceURL: URL, + manifests: [Data], + mappings: [VcsRootMapping] + ) throws -> VcsDetection + /// The root ID owning each workspace-relative path, in request order. + func owners(roots: [String], ambiguous: [String], paths: [String]) throws -> [String?] + func decodeReply( + _ operation: VcsOperation, + capabilities: [String: Int], + reply: ToolingJSONValue, + as type: Value.Type + ) throws -> Value +} + +/// A provider's reply with the negotiation of the session that sent it. +package struct VcsProviderReply: Sendable { + package let value: ToolingJSONValue + package let negotiation: VcsProviderNegotiation + + package init(value: ToolingJSONValue, negotiation: VcsProviderNegotiation) { + self.value = value; self.negotiation = negotiation + } +} + +/// Provider processes. A request starts the target's process when none +/// runs; the reply is the provider's raw value, which the caller decodes +/// through `VcsRootPlanning.decodeReply`. +@MainActor +package protocol VcsProviderRequesting: AnyObject { + /// The target's negotiation, starting its process when needed. + func negotiation(for target: VcsProviderTarget) async throws -> VcsProviderNegotiation + func send( + _ operation: VcsOperation, + root: String, + to target: VcsProviderTarget, + timeout: Duration + ) async throws -> VcsProviderReply + /// Stops every process whose target `keep` rejects. + func retainProviders(_ keep: (VcsProviderTarget) -> Bool) +} diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 481c520337..71afb65c7d 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -91,6 +91,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// `functionsChanged` event listed them. package private(set) var functionIDs: [String] = [] package var onFunctionsChange: (([String]) -> Void)? + /// The VCS provider the process negotiated in `initialize`, as Core's + /// `vcsProvider` event reported it; nil for every other session. + package private(set) var vcsNegotiation: VcsProviderNegotiation? package var onServerInfoChange: ((LanguageServerInfo?) -> Void)? package var javaTestRunnerURL: URL? { hostIntegrationLaunch?.coreLaunch?.jdtlsLaunchResources?.javaTestRunnerURL @@ -582,6 +585,51 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } } + /// Sends one operation of the negotiated VCS-provider protocol and + /// returns the provider's raw reply, which the caller passes to + /// `vcs.decodeReply`. A refusal or failure throws + /// `LanguageServerRequestFailure` with the `vcs.*` code. + package func vcsRequest( + vcs: String, + operation: String, + root: String, + tool: String?, + arguments: ToolingJSONValue, + 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.requestVcsProvider( + sessionID: sessionID, vcs: vcs, operation: operation, root: root, tool: tool, + arguments: arguments, 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: "vcs.\(operation)") { 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. @@ -994,6 +1042,12 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { if case .integer(let value)? = object["percentage"] { percentage = value } listener(PluginFunctionProgress(message: message, percentage: percentage)) return false + case "vcsProvider": + switch Self.decodeEventResult(event, as: VcsProviderNegotiation.self) { + case .success(let negotiation): vcsNegotiation = negotiation + case .failure(let error): onLog?(.error, "Invalid VCS provider event", error.localizedDescription, nil) + } + return false case "featuresChanged": updateFeatures(capabilityNames: event.capabilities ?? []) return false @@ -1068,6 +1122,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { functionIDs = [] onFunctionsChange?([]) } + vcsNegotiation = nil let released = onReleased onReleased = nil released?() diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 3e41a999ab..7531ddd32d 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -347,4 +347,22 @@ public enum LanguageExtensionModuleManifests { dependencies: [.module(.workspace)] ) } + + /// One module for all VCS providers of a plugin. Like build systems, it + /// owns no process: the host starts a provider only for a root of its VCS + /// while the module is enabled. + public static func vcsProviders( + pluginID: String, + displayName: String, + enabledByDefault: Bool = false + ) -> ModuleManifest { + ModuleManifest( + id: .vcsProvidersExtension(pluginID), + displayName: "\(displayName) Version Control", + scope: .workspace, + defaultState: enabledByDefault ? .enabled : .disabled, + activationPolicy: .onDemand, + dependencies: [.module(.workspace)] + ) + } } diff --git a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift index 63ec8f2b24..17e0f23364 100644 --- a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift +++ b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift @@ -304,6 +304,11 @@ public extension ModuleID { static func buildSystemsExtension(_ pluginID: String) -> ModuleID { ModuleID("\(pluginID).build-systems") } + + /// The VCS providers one declarative plugin declares, enabled together. + static func vcsProvidersExtension(_ pluginID: String) -> ModuleID { + ModuleID("\(pluginID).vcs-providers") + } } public extension ModuleCapabilityID { diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index c3c8a9e3ae..ba437a2a4e 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -497,7 +497,8 @@ public struct PluginManifest: Equatable, Codable, Sendable { "languages": [1], "serverProcess": [1], "settings": [1, 2], - "ui": [1, 2] + "ui": [1, 2], + "vcsProviders": [1] ] public let schemaVersion: Int diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 99318b8c44..c6b5e4cb66 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -67,6 +67,9 @@ package final class WorkspaceFeatureModel: ObservableObject { private var notify: (@MainActor (String) -> Void)? private var recordHistory: (@MainActor (URL, LocalHistoryReason) async -> Void)? private var relocateHistory: (@MainActor (URL, URL) async -> Void)? + /// Moves an item through the version control that tracks it, so the + /// move is recorded; false leaves the move to the file system. + private var moveTrackedItem: (@MainActor (URL, URL) async -> Bool)? private var relocateOpenDocuments: (@MainActor (URL, URL) -> Void)? private var closeDocuments: (@MainActor (URL) -> Void)? private var processExternalChanges: (@MainActor ([URL]) -> Bool)? @@ -108,6 +111,7 @@ package final class WorkspaceFeatureModel: ObservableObject { notify: @escaping @MainActor (String) -> Void, recordHistory: @escaping @MainActor (URL, LocalHistoryReason) async -> Void, relocateHistory: @escaping @MainActor (URL, URL) async -> Void, + moveTrackedItem: @escaping @MainActor (URL, URL) async -> Bool = { _, _ in false }, relocateOpenDocuments: @escaping @MainActor (URL, URL) -> Void, closeDocuments: @escaping @MainActor (URL) -> Void, processExternalChanges: @escaping @MainActor ([URL]) -> Bool, @@ -129,6 +133,7 @@ package final class WorkspaceFeatureModel: ObservableObject { self.notify = notify self.recordHistory = recordHistory self.relocateHistory = relocateHistory + self.moveTrackedItem = moveTrackedItem self.relocateOpenDocuments = relocateOpenDocuments self.closeDocuments = closeDocuments self.processExternalChanges = processExternalChanges @@ -557,7 +562,12 @@ package final class WorkspaceFeatureModel: ObservableObject { } let fileOperations = self.fileOperations - let errorMessage = await Task.detached(priority: .userInitiated) { () -> String? in + var movedByVersionControl = false + if request.kind == .rename, let moveTrackedItem { + let exists = await Task.detached(priority: .userInitiated) { fileOperations.fileExists(at: destination) }.value + if !exists { movedByVersionControl = await moveTrackedItem(request.targetURL, destination) } + } + let errorMessage = movedByVersionControl ? nil : await Task.detached(priority: .userInitiated) { () -> String? in guard !fileOperations.fileExists(at: destination) else { return "An item named '\(name)' already exists" } @@ -662,7 +672,14 @@ package final class WorkspaceFeatureModel: ObservableObject { } let historyFiles = projectFiles.filter { urlContains(source, child: $0) } let previousMarks = directoryMarks - let error = await Task.detached(priority: .userInitiated) { () -> String? in + var movedByVersionControl = false + if let moveTrackedItem { + let preflight = await Task.detached(priority: .userInitiated) { + Self.projectMoveError([source], to: target, workspace: workspace, files: fileOperations) + }.value + if preflight == nil { movedByVersionControl = await moveTrackedItem(source, destination) } + } + let error = movedByVersionControl ? nil : await Task.detached(priority: .userInitiated) { () -> String? in do { // Recheck just before writing; the native move also refuses overwrite. if let error = Self.projectMoveError([source], to: target, workspace: workspace, files: fileOperations) { diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index 17a302dfc0..3d75f5bc71 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -309,6 +309,48 @@ struct AppLocalizationTests { #expect(translations["Developer Tools"] == "开发者工具") } + @Test + func simplifiedChineseResourcesCoverPluginVcsProviders() throws { + let translations = try simplifiedChineseTranslations() + let requiredKeys = [ + // Install review. + "%@ provider (inside the plugin)", "%@ provider (installed on this Mac)", + "%1$@ provider (runs with %2$@)", "Client: %@", "May connect to the repository server", + // Tool window. + "Conflicts", "Changes", "Unversioned Files", "History", "Update Project", "Roll Back…", + "Show Diff", "Mark as Resolved", "Clean Up Working Copy", "More Actions", "Working copy", + "%@ holds the metadata of several version control systems. Choose the one that manages it.", + "Detecting working copies…", "No %@ working copy was detected in this workspace.", "Detect Again", + "Moved from %@", "Tree conflict", "Locked in this working copy", "Locked by another user", + "%lld files selected", "Committed revision %@.", "Updated to revision %@.", "%lld files have conflicts.", + "Roll back the selected changes?", "Local edits to these files are lost.", "History of %@", + "Restart Lithe to use %@.", + // Diff and history. + "Show %@ Diff", "Base revision", "Binary files cannot be compared.", + "The file is too large to compare.", "No revisions", "More paths changed than are shown.", + // Provider failures. + "Wait for the running version control action to finish.", + "The working copy is out of date. Update it, then commit again.", + "The working copy is locked by an interrupted action. Run Cleanup, then try again.", + "Authentication failed. Lithe cannot ask for credentials yet: sign in once with the command-line client so it stores them, then try again.", + "%1$@ does not provide %2$@ because %3$@ already does." + ] + + for key in requiredKeys { + let value = translations[key] + #expect(value != nil, "Missing VCS provider translation: \(key)") + // A translation must keep every placeholder, or String(format:) misreads its arguments. + #expect(value.map(Self.placeholders) == Self.placeholders(key), "Placeholders differ: \(key)") + } + #expect(translations["Base revision"] == "基线版本") + #expect(translations["Mark as Resolved"] == "标记为已解决") + } + + private static func placeholders(_ format: String) -> [String] { + let regex = /%(?:\d+\$)?(?:@|lld)/ + return format.matches(of: regex).map { String($0.output) }.sorted() + } + private func simplifiedChineseTranslations() throws -> [String: String] { let repositoryRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() diff --git a/macos/Tests/LitheTests/PluginManagerTests.swift b/macos/Tests/LitheTests/PluginManagerTests.swift index fe55a47fc0..06a52d62bc 100644 --- a/macos/Tests/LitheTests/PluginManagerTests.swift +++ b/macos/Tests/LitheTests/PluginManagerTests.swift @@ -66,6 +66,26 @@ struct PluginManagerTests { ) } + @Test + func vcsProviderPluginGetsOneDisabledModuleForItsProviders() throws { + // VCS providers come from community plugins; the SDK's sample + // provider stands in for one. Core accepts it because this host + // declares `vcsProviders`, and one module carries its enablement. + #expect(PluginManifest.hostCapabilities["vcsProviders"] == [1]) + let manifest = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("sdk/node/examples/sample-vcs/plugin.json")) + let sample = try DeclarativePluginTestSupport.operations.validate(manifest: manifest) + #expect(sample.vcsProviders == [DeclarativeVcsProvider(id: "sample", displayName: "Sample VCS")]) + #expect(sample.pluginManifest.modules.map(\.manifest.id) == [.vcsProvidersExtension(sample.id.rawValue)]) + #expect(sample.pluginManifest.modules.allSatisfy { $0.manifest.defaultState == .disabled }) + _ = try ValidatedPluginCatalog( + manifests: BuiltInPluginCatalog.manifests + + BundledLanguagePluginCatalog.manifests + + [sample.pluginManifest], + hostVersion: BuiltInPluginCatalog.hostVersion + ) + } + @Test func internalLifecycleModulesAreNotShownAsInstalledPlugins() { let root = temporaryRoot() diff --git a/macos/Tests/LitheTests/VcsProviderHostTests.swift b/macos/Tests/LitheTests/VcsProviderHostTests.swift new file mode 100644 index 0000000000..858ff9eff6 --- /dev/null +++ b/macos/Tests/LitheTests/VcsProviderHostTests.swift @@ -0,0 +1,608 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import Testing +@testable import Lithe + +/// The macOS host side of plugin VCS providers: the feature model against +/// a fake Core and fake provider processes, the real Core adapter, the +/// install review, and the tool window's pure presentation rules. +@Suite("VCS provider host") +@MainActor +struct VcsProviderHostTests { + // MARK: Feature model + + @Test + func detectsPluginRootsReadsStatusAndColorsOnlyTheFilesTheRootOwns() async throws { + // A Git repository nested in the working copy takes part in + // ownership but is not listed; its directory is the Git root's. + let fixture = try VcsFeatureFixture(roots: [("", "svn"), ("vendor/lib", "git")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([ + ("a.txt", "modified"), ("build", "unversioned"), ("vendor/lib", "unversioned"), ("debug.log", "ignored") + ]) + + #expect(await fixture.open()) + #expect(fixture.feature.roots.map(\.id) == [""]) + #expect(fixture.feature.roots.first?.title == "workspace") + #expect(fixture.feature.roots.first?.snapshot?.head.revision?.display == "r7") + #expect(fixture.feature.vcsNames == ["svn": "Subversion", "git": "Git"]) + let feature = fixture.feature + let modifiedURL = fixture.url("a.txt") + let colored = await awaitChange(on: feature) { feature.treeStatus.status(for: modifiedURL) == .modified } + #expect(colored) + let tree = feature.treeStatus + // Rows below an unversioned directory share its status. + #expect(tree.status(for: fixture.url("build/classes/Main.class")) == .unversioned) + #expect(tree.status(for: fixture.url("vendor/lib")) == nil) + #expect(tree.status(for: fixture.url("vendor/lib/x.c")) == nil) + #expect(tree.status(for: fixture.url("debug.log")) == nil) + #expect(fixture.providers.requests == [.init(operation: .status(includeIgnored: false), root: fixture.workspaceURL.path)]) + #expect(fixture.providers.retainCount > 0) + } + + @Test + func refreshesOnlyForWorkingFilesAndDeclaredMetadataOutsideHostActions() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn"), ("module", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + let workspace = fixture.workspaceURL.path + let module = fixture.url("module").path + #expect(await fixture.open()) + #expect(fixture.providers.statusCount(root: workspace) == 1) + #expect(fixture.providers.statusCount(root: module) == 1) + fixture.clock.advance(by: .seconds(3)) + + // Metadata the provider did not declare never refreshes; a working + // file does. Both arrive before the delay, so one refresh covers them. + fixture.feature.workspaceFilesChanged([fixture.url(".svn/tmp/x"), fixture.url("module/File.java")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 2 }) + #expect(fixture.providers.statusCount(root: workspace) == 1) + + // The host's own action writes metadata; those events are ignored + // for a short while, working files still count. + #expect(await fixture.feature.cleanup("module")) + #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 3 }) + fixture.feature.workspaceFilesChanged([fixture.url(".svn/wc.db"), fixture.url("module/Other.java")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 4 }) + #expect(fixture.providers.statusCount(root: workspace) == 1) + + // Once the quiet period ends, the declared metadata path refreshes + // its root, as an update from the command line would. + fixture.clock.advance(by: .seconds(3)) + fixture.feature.workspaceFilesChanged([fixture.url(".svn/wc.db")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: workspace) == 2 }) + #expect(fixture.providers.statusCount(root: module) == 4) + + // A marker directory appearing detects the roots again. + fixture.clock.advance(by: .seconds(3)) + fixture.feature.workspaceFilesChanged([fixture.url("plugins/.svn")]) + #expect(await fixture.settled { fixture.planner.detectCount == 2 }) + } + + @Test + func runsOneWorkingCopyActionAtATime() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("a.txt", "modified")]) + fixture.providers.replies["checkin"] = try VcsTestData.json(["revision": ["id": "8", "display": "r8", "kind": "number"]]) + #expect(await fixture.open()) + let checkinHeld = TestGate() + fixture.providers.holds["checkin"] = checkinHeld + defer { checkinHeld.open() } + + let feature = fixture.feature + let commit = Task { await feature.checkin(["a.txt"], message: "Fix", in: "") } + #expect(await awaitChange(on: feature) { feature.runningOperation?.rootID == "" }) + #expect(await feature.rollback(["a.txt"], in: "") == false) + #expect(feature.failure?.message == String(localized: "Wait for the running version control action to finish.")) + + checkinHeld.open() + #expect(await commit.value?.revision?.display == "r8") + #expect(feature.runningOperation == nil) + #expect(fixture.providers.requests.map(\.operation.name).filter { $0 != "status" } == ["checkin"]) + #expect(fixture.providers.requests.contains( + .init(operation: .checkin(paths: ["a.txt"], message: "Fix"), root: fixture.workspaceURL.path) + )) + } + + @Test + func movesTrackedItemsThroughTheProviderAndLeavesTheRestToTheDisk() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn"), ("module", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([ + ("a.txt", "modified"), ("new.txt", "unversioned"), ("out", "unversioned") + ]) + #expect(await fixture.open()) + let feature = fixture.feature + #expect(await awaitChange(on: feature) { feature.treeStatus.status(for: fixture.url("new.txt")) != nil }) + + #expect(await feature.move(from: fixture.url("a.txt"), to: fixture.url("docs/a.txt"))) + #expect(fixture.providers.requests.contains( + .init(operation: .move(from: "a.txt", to: "docs/a.txt"), root: fixture.workspaceURL.path) + )) + // The VCS does not track an unversioned item, cannot move into an + // unversioned directory, and cannot move between working copies. + #expect(await feature.move(from: fixture.url("new.txt"), to: fixture.url("docs/new.txt")) == false) + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("out/b.txt")) == false) + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("module/b.txt")) == false) + #expect(fixture.providers.requests.filter { $0.operation.name == "fileOperation" }.count == 1) + + // A refusal is reported; the caller then moves the item on disk. + fixture.providers.failures["fileOperation"] = VcsProviderFailure(code: "vcs.provider.failed", message: "E155010") + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("c.txt")) == false) + #expect(feature.failure?.message == "E155010") + } + + @Test + func remembersTheChosenVcsPerWorkspaceAndForgetsChoicesNoLongerNeeded() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")], ambiguous: ["mixed"]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + #expect(await fixture.open()) + #expect(fixture.feature.ambiguous.map(\.id) == ["mixed"]) + let store = MacVcsRootMappingStore(store: fixture.keyValues) + let choice = VcsRootMapping(id: "mixed", vcs: "svn") + + fixture.feature.map("mixed", to: "svn") + #expect(await fixture.settled { fixture.planner.detectCount == 2 }) + #expect(fixture.planner.lastMappings == [choice]) + #expect(store.mappings(workspaceURL: fixture.workspaceURL) == [choice]) + #expect(store.mappings(workspaceURL: fixture.url("other")).isEmpty) + + // Core reports the choice unused once the directory is no longer + // ambiguous, and the host drops it. + fixture.planner.detection = try VcsTestData.detection( + roots: [("", "svn")], workspaceURL: fixture.workspaceURL, unusedMappings: [choice] + ) + fixture.feature.detect() + #expect(await fixture.settled { fixture.planner.detectCount == 3 }) + #expect(store.mappings(workspaceURL: fixture.workspaceURL).isEmpty) + #expect(fixture.keyValues.isEmpty) + } + + @Test + func mappingStoreKeepsChoicesSortedPerWorkspace() { + let keyValues = VcsTestKeyValueStore() + let store = MacVcsRootMappingStore(store: keyValues) + let workspace = URL(fileURLWithPath: "/virtual/a") + let mappings = [VcsRootMapping(id: "z", vcs: "svn"), VcsRootMapping(id: "a", vcs: "git")] + + store.setMappings(mappings, workspaceURL: workspace) + #expect(store.mappings(workspaceURL: workspace) == mappings.reversed()) + #expect(store.mappings(workspaceURL: URL(fileURLWithPath: "/virtual/b")).isEmpty) + store.setMappings([], workspaceURL: workspace) + #expect(keyValues.isEmpty) + } + + @Test + func providerFailuresLeadWithTheHintTheContractNames() { + let outOfDate = VcsProviderFailure(code: "vcs.outOfDate", message: "E160024: out of date") + #expect(VcsProvidersFeatureModel.message(for: outOfDate) + == String(localized: "The working copy is out of date. Update it, then commit again.") + "\nE160024: out of date") + #expect(VcsProvidersFeatureModel.message(for: VcsProviderFailure(code: "vcs.workingCopy.locked", message: "")) + == String(localized: "The working copy is locked by an interrupted action. Run Cleanup, then try again.")) + #expect(VcsProvidersFeatureModel.message(for: VcsProviderFailure(code: "vcs.provider.failed", message: "boom")) + == "boom") + } + + @Test + func rootPathsMapToTheWorkspaceEvenFromAnEnclosingWorkingCopy() { + let workspace = URL(fileURLWithPath: "/virtual/wc/app/web") + #expect(VcsProvidersFeatureModel.workspacePath("a.txt", rootID: "", workspaceURL: workspace) == "a.txt") + #expect(VcsProvidersFeatureModel.workspacePath("a.txt", rootID: "lib", workspaceURL: workspace) == "lib/a.txt") + // The workspace opened two levels inside the working copy. + #expect(VcsProvidersFeatureModel.workspacePath("app/web/src/a.txt", rootID: "../..", workspaceURL: workspace) + == "src/a.txt") + #expect(VcsProvidersFeatureModel.workspacePath("app/web", rootID: "../..", workspaceURL: workspace) == "") + #expect(VcsProvidersFeatureModel.workspacePath("app/webapp/a.txt", rootID: "../..", workspaceURL: workspace) == nil) + #expect(VcsProvidersFeatureModel.workspacePath("README", rootID: "../..", workspaceURL: workspace) == nil) + } + + // MARK: Tool window rules + + @Test + func changeListPutsConflictsFirstAndIncludesOnlyVersionedChangesByDefault() throws { + let changes = [ + VcsChange(path: "z.txt", status: .modified), + VcsChange(path: "new.txt", status: .unversioned), + VcsChange(path: "b.txt", status: .modified, flags: ["treeConflict"]), + VcsChange(path: "a.txt", status: .conflicted), + VcsChange(path: "c.txt", status: .added), + VcsChange(path: "debug.log", status: .ignored) + ] + let groups = VcsProviderToolWindowView.groups(changes) + + #expect(groups.map(\.group) == [.conflicts, .changes, .unversioned]) + #expect(groups.map { $0.changes.map(\.path) } == [["a.txt", "b.txt"], ["c.txt", "z.txt"], ["new.txt"]]) + #expect(changes.filter(VcsProviderToolWindowView.isIncludedByDefault).map(\.path) == ["z.txt", "c.txt"]) + } + + @Test + func commitCarriesBothHalvesOfAMoveOnce() { + let paths = VcsProviderToolWindowView.commitPaths([ + VcsChange(path: "src/New.java", oldPath: "src/Old.java", status: .renamed), + VcsChange(path: "src/Old.java", status: .deleted), + VcsChange(path: "README", status: .modified) + ]) + #expect(paths == ["src/New.java", "src/Old.java", "README"]) + } + + @Test + func headAndHistoryDatesReadAsTheProviderSentThem() throws { + let head = try JSONDecoder().decode(VcsHead.self, from: Data( + #"{"revision":{"id":"7","display":"r7","kind":"number"},"branch":"trunk","location":null}"#.utf8 + )) + #expect(VcsProviderToolWindowView.headSummary(head) == "r7 · trunk") + + let date = try #require(ISO8601DateFormatter().date(from: "2026-10-22T08:30:00Z")) + let expected = date.formatted(date: .abbreviated, time: .shortened) + #expect(VcsHistoryListView.displayDate("2026-10-22T08:30:00Z") == expected) + #expect(VcsHistoryListView.displayDate("2026-10-22T08:30:00.250Z") == expected) + #expect(VcsHistoryListView.displayDate("yesterday") == "yesterday") + } + + // MARK: Rust Core and install review + + @Test + func rustCoreDetectsAPluginRootBesideNestedGitAndValidatesReplies() throws { + let workspace = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-vcs-host-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: workspace) } + try FileManager.default.createDirectory(at: workspace.appendingPathComponent(".svn"), withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: workspace.appendingPathComponent("vendor/lib/.git"), withIntermediateDirectories: true + ) + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: VcsTestData.svnProviderManifest()) + let planner = RustVcsOperations(core: RustCoreBridge()) + + let detection = try planner.detectRoots( + workspaceURL: workspace, manifests: [descriptor.normalizedManifest], mappings: [] + ) + #expect(Set(detection.roots.map { "\($0.vcs):\($0.id)" }) == ["svn:", "git:vendor/lib"]) + let svn = try #require(detection.providers.first { $0.vcs == "svn" }) + #expect(svn.origin.pluginID == descriptor.id.rawValue) + #expect(svn.metadataPaths == [".svn/wc.db"]) + #expect(try planner.owners( + roots: detection.roots.map(\.id), ambiguous: [], paths: ["a.txt", "vendor/lib/b.c", "vendor/c.txt"] + ) == ["", "vendor/lib", ""]) + + let snapshot = try planner.decodeReply( + .status(includeIgnored: false), capabilities: ["status": 1], + reply: VcsTestData.statusReply([("z.txt", "modified"), ("a.txt", "added")]), as: VcsStatusSnapshot.self + ) + #expect(snapshot.changes.map(\.path) == ["a.txt", "z.txt"]) + do { + _ = try planner.decodeReply( + .status(includeIgnored: false), capabilities: ["status": 1], + reply: VcsTestData.statusReply([("a.txt", "obstructed")]), as: VcsStatusSnapshot.self + ) + Issue.record("Core accepted an unknown status") + } catch let failure as VcsProviderFailure { + #expect(failure.code == "plugin.vcs.invalidOutput") + } + } + + @Test + func installReviewShowsTheProviderCommandClientAndNetworkUse() throws { + let review = try PluginMarketplaceTestSupport.evaluator.installReview( + manifest: VcsTestData.svnProviderManifest(), + expectedID: nil, expectedVersion: nil, approvedPrograms: nil + ) + let provider = try #require(review.programs.first { $0.kind == .runtimeVcsProvider }) + #expect(provider.vcs == "svn") + #expect(provider.tool == "node") + #expect(provider.vcsClientCommands == ["svn"]) + #expect(provider.mayUseNetwork) + + let description = PluginMarketplacePresentation.programDescription(provider, locale: Locale(identifier: "en")) + #expect(description.title == "svn provider (runs with node)") + let lines = description.detail.components(separatedBy: "\n") + #expect(lines.first?.hasPrefix("node ") == true, "\(lines)") + #expect(lines.first?.contains("svn-provider.mjs") == true, "\(lines)") + #expect(lines.dropFirst() == ["Client: svn", "May connect to the repository server"]) + } +} + +/// Project tree moves through the production app model: a tracked file goes +/// to the provider, an unversioned one stays a plain disk move. +@Suite("VCS provider project tree moves", .serialized) +@MainActor +struct VcsProviderProjectMoveTests { + @Test + func projectTreeMovesGoThroughTheProviderThatTracksTheFile() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-vcs-move-\(UUID().uuidString)", isDirectory: true) + .standardizedFileURL + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root.appendingPathComponent("docs"), withIntermediateDirectories: true) + try Data("alpha".utf8).write(to: root.appendingPathComponent("a.txt")) + try Data("beta".utf8).write(to: root.appendingPathComponent("b.txt")) + func url(_ path: String) -> URL { root.appendingPathComponent(path).standardizedFileURL } + + let fixture = try VcsFeatureFixture(workspaceURL: root, roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("b.txt", "unversioned")]) + // Like `svn move`, the provider moves the file itself. + fixture.providers.onFileOperation = { operation, rootPath in + guard case .move(let from, let to) = operation else { return } + let base = URL(fileURLWithPath: rootPath) + try FileManager.default.moveItem(at: base.appendingPathComponent(from), to: base.appendingPathComponent(to)) + } + let store = VcsTestKeyValueStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer( + store: store, settings: settings, moduleLaunchMode: .safeMode, platformUI: VcsMoveTestUI() + ).services + services.vcsProviders = fixture.feature + let model = AppModel(settings: settings, services: services) + defer { model.workspaceFeature.reset() } + + model.openProjectDirectly(root) + #expect(await awaitChange(on: model) { model.projectFiles.contains(url("b.txt")) }) + model.loadVcsProviders(at: root) + #expect(await fixture.settled()) + #expect(await awaitChange(on: fixture.feature) { fixture.feature.treeStatus.status(for: url("b.txt")) != nil }) + + await model.moveProjectItems([url("a.txt")], to: url("docs")) + #expect(fixture.providers.requests.contains(.init(operation: .move(from: "a.txt", to: "docs/a.txt"), root: root.path))) + #expect(FileManager.default.fileExists(atPath: url("docs/a.txt").path)) + #expect(!FileManager.default.fileExists(atPath: url("a.txt").path)) + + await model.moveProjectItems([url("b.txt")], to: url("docs")) + #expect(fixture.providers.requests.filter { $0.operation.name == "fileOperation" }.count == 1) + #expect(FileManager.default.fileExists(atPath: url("docs/b.txt").path)) + // Not tracking a file is no failure; the disk move reports as usual. + #expect(fixture.feature.failure == nil) + } +} + +// MARK: - Test doubles + +private enum VcsTestData { + static let pluginID = "dev.example.svn" + + /// The contract's Subversion provider manifest. The real plugin is a + /// community plugin in its own repository. + static func svnProviderManifest() throws -> Data { + try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/svn-provider.plugin.json")) + } + + static func detection( + roots: [(id: String, vcs: String)], + workspaceURL: URL, + ambiguous: [String] = [], + unusedMappings: [VcsRootMapping] = [] + ) throws -> VcsDetection { + let object: [String: Any] = [ + "providers": [ + [ + "vcs": "svn", "displayName": "Subversion", + "origin": ["kind": "plugin", "pluginID": pluginID], + "markers": [["name": ".svn", "type": "directory"]], "metadataPaths": [".svn/wc.db"] + ], + [ + "vcs": "git", "displayName": "Git", "origin": ["kind": "builtIn"], + "markers": [["name": ".git", "type": "directory"]] + ] + ], + "roots": roots.map { root -> [String: Any] in + let path = root.id.isEmpty ? workspaceURL.path : workspaceURL.appendingPathComponent(root.id).path + return ["id": root.id, "vcs": root.vcs, "path": path, "mapped": false] + }, + "ambiguous": ambiguous.map { ["id": $0, "vcs": ["git", "svn"]] }, + "unusedMappings": unusedMappings.map { ["id": $0.id, "vcs": $0.vcs] }, + "truncated": false, "rejected": [], "conflicts": [] + ] + return try JSONDecoder().decode(VcsDetection.self, from: JSONSerialization.data(withJSONObject: object)) + } + + static func json(_ object: Any) throws -> ToolingJSONValue { + try JSONDecoder().decode(ToolingJSONValue.self, from: JSONSerialization.data(withJSONObject: object)) + } + + static func statusReply(_ changes: [(path: String, status: String)]) throws -> ToolingJSONValue { + try json([ + "head": [ + "revision": ["id": "7", "display": "r7", "kind": "number"], + "branch": "trunk", "location": "file:///repo/trunk" + ], + "changes": changes.map { ["path": $0.path, "oldPath": NSNull(), "status": $0.status, "flags": []] }, + "truncated": false + ]) + } +} + +/// Stands in for Rust Core: detection answers from a fixed value, ownership +/// follows Core's deepest-root rule, replies decode as sent. +private final class FakeVcsPlanner: VcsRootPlanning, @unchecked Sendable { + private let lock = NSLock() + private var storedDetection: VcsDetection + private var detections = 0 + private var mappings: [VcsRootMapping] = [] + + init(detection: VcsDetection) { + storedDetection = detection + } + + var detection: VcsDetection { + get { lock.withLock { storedDetection } } + set { lock.withLock { storedDetection = newValue } } + } + + var detectCount: Int { lock.withLock { detections } } + var lastMappings: [VcsRootMapping] { lock.withLock { mappings } } + + func detectRoots(workspaceURL: URL, manifests: [Data], mappings: [VcsRootMapping]) throws -> VcsDetection { + lock.withLock { + detections += 1 + self.mappings = mappings + return storedDetection + } + } + + func owners(roots: [String], ambiguous: [String], paths: [String]) throws -> [String?] { + paths.map { path in + roots.filter { $0.isEmpty || path == $0 || path.hasPrefix($0 + "/") }.max { $0.count < $1.count } + } + } + + func decodeReply( + _ operation: VcsOperation, + capabilities: [String: Int], + reply: ToolingJSONValue, + as type: Value.Type + ) throws -> Value { + try JSONDecoder().decode(Value.self, from: JSONEncoder().encode(reply)) + } +} + +/// Provider processes that answer every capability from canned replies. +@MainActor +private final class FakeVcsProviders: VcsProviderRequesting { + struct Request: Equatable { + let operation: VcsOperation + let root: String + } + + var replies: [String: ToolingJSONValue] = [:] + var failures: [String: VcsProviderFailure] = [:] + /// Requests that wait for the gate before answering. + var holds: [String: TestGate] = [:] + var onFileOperation: ((VcsOperation, String) throws -> Void)? + private(set) var requests: [Request] = [] + private(set) var retainCount = 0 + + func statusCount(root: String) -> Int { + requests.filter { $0.operation.name == "status" && $0.root == root }.count + } + + func negotiation(for target: VcsProviderTarget) async throws -> VcsProviderNegotiation { + Self.negotiation(target.vcs) + } + + func send( + _ operation: VcsOperation, + root: String, + to target: VcsProviderTarget, + timeout: Duration + ) async throws -> VcsProviderReply { + requests.append(Request(operation: operation, root: root)) + if let hold = holds[operation.name] { + _ = await hold.waitUntilOpen(timeout: .seconds(5)) + } + if let failure = failures[operation.name] { throw failure } + if operation.name == "fileOperation" { try onFileOperation?(operation, root) } + return VcsProviderReply(value: replies[operation.name] ?? .object([:]), negotiation: Self.negotiation(target.vcs)) + } + + func retainProviders(_ keep: (VcsProviderTarget) -> Bool) { + retainCount += 1 + } + + private static func negotiation(_ vcs: String) -> VcsProviderNegotiation { + VcsProviderNegotiation( + vcs: vcs, capabilities: Dictionary(uniqueKeysWithValues: VcsCapability.allCases.map { ($0.rawValue, 1) }) + ) + } +} + +/// A feature model over the fakes. The refresh delay passes at once and +/// the clock moves only when a test advances it. +@MainActor +private final class VcsFeatureFixture { + let workspaceURL: URL + let planner: FakeVcsPlanner + let providers = FakeVcsProviders() + let keyValues = VcsTestKeyValueStore() + let clock = VcsTestClock() + let feature: VcsProvidersFeatureModel + + init( + workspaceURL: URL = URL(fileURLWithPath: "/virtual/workspace", isDirectory: true), + roots: [(id: String, vcs: String)], + ambiguous: [String] = [] + ) throws { + self.workspaceURL = workspaceURL.standardizedFileURL + planner = FakeVcsPlanner(detection: try VcsTestData.detection( + roots: roots, workspaceURL: self.workspaceURL, ambiguous: ambiguous + )) + let clock = clock + feature = VcsProvidersFeatureModel( + planner: planner, + providers: providers, + mappingStore: MacVcsRootMappingStore(store: keyValues), + sleep: { _ in try Task.checkCancellation() }, + now: { clock.now }, + sources: { + [VcsProviderPluginSource( + pluginID: VcsTestData.pluginID, manifest: Data("{}".utf8), + packageURL: URL(fileURLWithPath: "/virtual/plugin", isDirectory: true), + providers: [DeclarativeVcsProvider(id: "svn", displayName: "Subversion")] + )] + } + ) + } + + func url(_ path: String) -> URL { + workspaceURL.appendingPathComponent(path).standardizedFileURL + } + + /// Opens the workspace and waits until every root has read its status. + func open() async -> Bool { + feature.reset(workspaceURL: workspaceURL) + return await settled() + } + + /// Waits until detection and every status read have finished and + /// `condition` holds, so a refresh that should not happen would have + /// been requested by then. + func settled(_ condition: @escaping @MainActor @Sendable () -> Bool = { true }) async -> Bool { + await awaitChange(on: feature) { [feature] in + condition() && !feature.isDetecting && !feature.roots.isEmpty + && feature.roots.allSatisfy { $0.snapshot != nil && !$0.isRefreshing } + } + } + + func close() { + feature.reset(workspaceURL: nil) + } +} + +private final class VcsTestClock: @unchecked Sendable { + private let lock = NSLock() + private var instant = ContinuousClock.now + + var now: ContinuousClock.Instant { lock.withLock { instant } } + + func advance(by duration: Duration) { + lock.withLock { instant += duration } + } +} + +private final class VcsTestKeyValueStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + var isEmpty: Bool { values.isEmpty } + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} + +private final class VcsMoveTestUI: PlatformUI { + func copyFilesToClipboard(_ urls: [URL]) -> Bool { false } + func fileURLsFromClipboard() -> [URL] { [] } + func activateApplication() {} + func chooseDirectory(title: String, prompt: String) -> URL? { nil } + func chooseFile(title: String, prompt: String) -> URL? { nil } + func revealInFileBrowser(_ url: URL) {} + func open(_ url: URL) {} + func copyToClipboard(_ value: String) {} + func markdownImageFromClipboard() -> MarkdownImageSource? { nil } + func startAccessingProject(_ url: URL) -> Bool { false } + func stopAccessingProject(_ url: URL) {} +} diff --git a/sdk/node/examples/sample-vcs/provider/main.mjs b/sdk/node/examples/sample-vcs/provider/main.mjs index aba848e933..52203f8c4d 100644 --- a/sdk/node/examples/sample-vcs/provider/main.mjs +++ b/sdk/node/examples/sample-vcs/provider/main.mjs @@ -2,7 +2,7 @@ // version control system: a working copy is a directory with a // `.samplevcs/revisions.json` file that stores every revision as a full // snapshot of the files' text, so the sample shows the protocol without a -// VCS client. A real provider, such as the official Subversion plugin, runs +// VCS client. A real provider, such as the Subversion community plugin, runs // its client through `context.run` and parses the output instead. // // Rust Core's end-to-end test (rust/lithe-core/tests/plugin_sdk/vcs.rs) diff --git a/shared/contracts/vcs-provider-v1.md b/shared/contracts/vcs-provider-v1.md index 999c334b56..02e0c37fa3 100644 --- a/shared/contracts/vcs-provider-v1.md +++ b/shared/contracts/vcs-provider-v1.md @@ -88,7 +88,7 @@ the process and adds `vcsProvider`, the descriptor below. { "vcs": "svn", "displayName": "Subversion", - "origin": { "kind": "plugin", "pluginID": "dev.lithe.plugin.svn-support" }, + "origin": { "kind": "plugin", "pluginID": "io.github.lithe-idea.svn-support" }, "markers": [{ "name": ".svn", "type": "directory" }], "metadataPaths": [".svn/wc.db"], "tool": { "commands": ["svn"], "validationArguments": ["--version", "--quiet"] }, @@ -169,8 +169,8 @@ directory it belongs to no root (`null`), not to an enclosing one. At most ```json { - "sessionID": "…", - "operationID": "…", + "sessionId": "…", + "operationId": "…", "vcs": "svn", "operation": "checkin", "root": "/abs/workspace/services/legacy", @@ -354,8 +354,9 @@ must not contain credentials. ## Subversion provider notes -The official Subversion plugin calls the user's `svn` client and parses its -`--xml` output (`status`, `info`, `log`, `blame`). Every invocation uses -`--non-interactive`, so only Subversion's own credential cache answers. -Working copies older than format 1.7 (one `.svn` per directory) are reported -as `vcs.workingCopy.unsupported`. +Subversion is provided by the community plugin +[lithe-plugin-svn](https://github.com/Lithe-IDEA/lithe-plugin-svn) +(`io.github.lithe-idea.svn-support`), released independently of Lithe. It calls +the user's `svn` client, 1.8 or newer, and parses its `--xml` output. Its README +documents how each operation maps to svn and how svn errors map to contract +codes; its tests check those replies against a pinned Lithe Core. diff --git a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json index 03858b1e9b..8a03f5e2d9 100644 --- a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json +++ b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json @@ -2,7 +2,7 @@ "id": "vcs-subversion-and-plugin-providers", "area": "版本控制", "group": "统一 VCS", - "capability": "统一 VCS 模型与插件 VCS Provider:同一工作区可识别 Git 与 Subversion 等不同 VCS 的根,变更列表、提交、回滚、更新、文件历史、Annotate、项目树文件状态和 gutter 按 Provider 声明的能力工作;Subversion 由官方插件通过系统 svn 命令行接入", + "capability": "统一 VCS 模型与插件 VCS Provider:同一工作区可识别 Git 与 Subversion 等不同 VCS 的根,变更列表、提交、回滚、更新、文件历史、Annotate、项目树文件状态和 gutter 按 Provider 声明的能力工作;Subversion 由独立仓库的社区插件通过系统 svn 命令行接入", "macos": { "evidence": [ "shared/contracts/vcs-provider-v1.md", @@ -15,10 +15,22 @@ "sdk/node/test/vcs.test.mjs", "sdk/node/examples/sample-vcs", "rust/lithe-core/tests/plugin_sdk/vcs.rs", + "Plugins/mac/README.md", + "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift", + "macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift", + "macos/Sources/Lithe/Core/Rust/RustVcsOperations.swift", + "macos/Sources/Lithe/Platform/MacOS/Vcs", + "macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift", + "macos/Sources/Lithe/Views/Vcs", + "macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift", + "macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift", + "macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift", + "macos/Tests/LitheTests/VcsProviderHostTests.swift", ".agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "windows": { "evidence": [ @@ -37,6 +49,6 @@ "verificationStatus": "not-applicable" }, "owner": "Git", - "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp、npm test --prefix sdk/node、LITHE_PLUGIN_SDK_NODE=$(command -v node) cargo test -p lithe-core --test plugin_sdk_node 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例,以及用 Node SDK 写的示例 Provider 经真实 Core 会话完成协商、全部 11 种操作和执行报告。宿主验收:安装 Subversion 官方插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", - "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 已支持 litheVcs(P1);两端宿主尚未声明 vcsProviders,也没有调用这些命令,所以仍记为未实现。分阶段计划见提议 Note。" + "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp、npm test --prefix sdk/node、LITHE_PLUGIN_SDK_NODE=$(command -v node) cargo test -p lithe-core --test plugin_sdk_node 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例,以及用 Node SDK 写的示例 Provider 经真实 Core 会话完成协商、全部 11 种操作和执行报告。Subversion 社区插件:独立源码由 https://github.com/Lithe-IDEA/lithe-plugin-svn 维护;插件 CI 校验固定 SDK 的源码哈希,用 svn 录制的 status/info/log/blame --xml 与错误输出样例覆盖解析和错误码映射,并按 sdk.lock.json 的宿主提交构建 Core 桥接,在 svnadmin create 建立的 file:// 仓库上经 Core 跑通状态、提交、过期提交、更新与冲突、resolve、回滚、移动、删除、历史分页、Annotate 和二进制文件;固定宿主基线不代表已验证未来 preview。主仓库只保留通用 VCS 契约回归(样例 Provider 与清单 fixture)。宿主验收:从磁盘或 Lithe Store 安装 Subversion 社区插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", + "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 已支持 litheVcs(P1);Subversion 社区插件在独立仓库 Lithe-IDEA/lithe-plugin-svn 实现全部 11 种操作,ID 为 io.github.lithe-idea.svn-support,不需要 Lithe 签名(P2)。macOS 宿主已声明 vcsProviders:打开工作区时识别根并记住歧义目录的选择,插件的左侧工具窗口列出冲突、变更和未版本化文件,支持勾选提交、更新、回滚、标记解决、清理、文件与工作副本历史、与基线比较;项目树按 Provider 状态着色并在右键菜单提供比较,项目树中的重命名和移动交给 Provider 跟踪;安装审核显示 Provider 的启动命令、客户端命令和是否联网(P3)。macOS 尚未接入编辑器 gutter 与 Annotate,所以记为部分实现;插件要等带 vcsProviders 能力的 Lithe 版本发布后再在 Lithe Store 上架。Windows 宿主尚未接入。分阶段计划见提议 Note。" } From f76025c18017e919f7314230c07f178d2872d788 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 23 Oct 2026 17:30:53 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(vcs):=20=E6=8C=89=E8=87=AA=E5=AE=A1?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20VCS=20Provider=20=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=EF=BC=88#827=20P3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 包住工作区的工作副本(如 ../.svn)单独监听其声明的元数据目录, 终端里的提交或更新也会触发刷新。 - 移动前按 Core 的归属规则确认两端都属于同一根,嵌套的 Git 仓库或 未选择 VCS 的歧义目录不会被当成 svn move。 - 窗口关闭时调用 shutdown():停掉所有 Provider,之后迟到的工作区 扫描也不会再把 Provider 拉起来。 - 工具窗口与命令沿用宿主版本控制图标;首次读取时显示“正在读取更改…”; 变更行补充选择、打开/查看差异、纳入提交的辅助功能操作。 - 同步 Agent Note、能力版本 Note 与跨平台功能矩阵。 --- .../2026-10-17-plugin-capability-versions.md | 2 +- .../2026-10-22-unified-vcs-provider-model.md | 7 +- .../zh-Hans.lproj/Localizable.strings | 1 + .../Features/VcsProvidersFeatureModel.swift | 86 +++++++- .../Lithe/Models/AppModel/AppModel.swift | 3 + .../Platform/MacOS/MacServiceContainer.swift | 1 + .../Views/Vcs/VcsProviderToolWindowView.swift | 30 ++- .../Vcs/VcsProviderContracts.swift | 8 +- .../LitheTests/VcsProviderHostTests.swift | 191 ++++++++++++++++-- .../vcs-subversion-and-plugin-providers.json | 4 +- 10 files changed, 302 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md index 2572ea2c9b..c5b2791381 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md +++ b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md @@ -59,7 +59,7 @@ Core 能校验一个能力,不代表宿主能运行它。宿主在 `plugins.de 能力表放在宿主代码里,写在能力实现的旁边,Core 不再按平台去猜。原来按 `darwin-` 前缀和级别写死的判断,就是 Core 替宿主猜、又容易和宿主实现漂移的例子。 -macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,但 macOS 尚未实现 Provider,所以不声明 `buildProviders`;市场过滤、清单校验和安装审查都会拒绝需要它的插件,纯模板构建插件仍可用。Core 自己支持 `buildProviders: 1`,不传宿主表时仍可校验并生成 Provider 计划。`vcsProviders: 1` 也是这样:Core 已经能校验清单、识别工作副本根并转发请求,但 macOS 和 Windows 都还没有接入 VCS Provider 会话,所以两端的能力表都不包含它,需要它的插件在两端都不可安装。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 +macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]`),能力表是 `PluginManifest.hostCapabilities`,在校验、索引和安装审查时都发给 Core;其中的 `buildSystems: [1]` 随 macOS 的 Build 工具窗口一起加入,`buildProviders: [1]` 在 macOS 接入构建 Provider 会话后才加入。`vcsProviders: [1]` 同样等 macOS 接入 VCS Provider 会话(版本控制工具窗口、项目树状态和文件移动)后才加入,见[统一 VCS 模型提案](../../proposed/architecture/2026-10-22-unified-vcs-provider-model.md)的 P3。Core 自己支持 `buildProviders: 1` 和 `vcsProviders: 1`,不传宿主表时仍可校验清单、生成 Provider 计划、识别工作副本根并转发请求。Windows 还没有声明式插件宿主,不发送能力表,所以 Windows 不列出级别 3 的插件。宿主接入某个能力时,要做两件事:让宿主接受级别 3,并在宿主自己的能力表里加上这个能力的版本,而且只在宿主真正能运行它时才加。 ### 3. 市场索引带上能力版本 diff --git a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md index e018434fee..72fdfbc8cf 100644 --- a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md +++ b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md @@ -148,11 +148,12 @@ P3 第一部分已完成(2026-10-23),在 macOS 上: - 宿主在 `PluginManifest.hostCapabilities` 声明 `vcsProviders: [1]`,声明了 VCS Provider 的插件从这时起能安装。宿主读清单时给这类插件生成一个 `<插件 ID>.vcs-providers` 模块,只承载插件的启用状态,默认关闭,做法和构建系统插件的 `.build-systems` 模块一样。安装审查给三种 Provider 程序(插件内、系统已装、运行时启动)显示启动命令、`vcsTool` 里的客户端命令,以及 `mayUseNetwork` 声明的“可能连接仓库服务器”,让用户批准前知道插件会运行哪个 `svn`。 - Provider 会话按窗口创建:启动时已安装的插件声明了 `vcsProviders`,`MacServiceContainer` 才建 `VcsProvidersFeatureModel`。启动后再安装的插件要重启才生效,工具窗口这时只显示“重启 Lithe 后使用”。这样做是因为服务在窗口组合时一次建好,运行中插桩会话要改动整个组合根,收益只是省一次重启。 -- 每个 Provider 贡献一个左侧工具窗口,宿主只认贡献上的 `vcsProviderPluginID`、`vcsProviderID` 两个可见性键,代码里没有任何 VCS 或插件的名字(与构建系统窗口同一套注册方式)。窗口按冲突、变更、未版本化文件分组:默认只勾选“变更”组,未版本化文件要用户勾选才提交,冲突文件不能勾选,要先“标记为已解决”;移动的文件提交时连同原路径一起提交,否则 SVN 会只提交一半。按钮是否出现看 Provider 协商的能力,不看 VCS 名字。 +- 每个 Provider 贡献一个左侧工具窗口,宿主只认贡献上的 `vcsProviderPluginID`、`vcsProviderID` 两个可见性键,代码里没有任何 VCS 或插件的名字(与构建系统窗口同一套注册方式)。Provider 不声明图标,派生的窗口和命令统一用宿主的版本控制图标(`git` 宿主图标,即 `vcs@20x20.svg`),不用通用工具窗口图标。第一次读状态时 Provider 进程才启动,可能要几秒,变更列表这时显示“正在读取更改…”,不留空白。变更行在辅助功能里是一个合并元素,默认动作是选中,另有“显示差异/打开文件”和“包含在提交中”两个命名动作,否则辅助功能只能切换复选框。窗口按冲突、变更、未版本化文件分组:默认只勾选“变更”组,未版本化文件要用户勾选才提交,冲突文件不能勾选,要先“标记为已解决”;移动的文件提交时连同原路径一起提交,否则 SVN 会只提交一半。按钮是否出现看 Provider 协商的能力,不看 VCS 名字。 - 与基线比较做成窗口级的弹出面板(`VcsProvidersFeatureModel.presentedDiff`,由 `RootView` 显示),不挂在工具窗口里,因为项目树右键的“显示差异”在工具窗口隐藏时也要能用。比较复用本地历史的 `LocalHistoryDiffBuilder` 和 Git 的 `DiffPaneView`;超过 2 万行时退成左右两栏纯文本,避免逐行比较卡住界面。 - 项目树颜色:插件根的状态通过一个只观察 `VcsProvidersFeatureModel` 的包装视图传给项目树,项目树本身仍是 `.equatable()`,刷新状态不重绘整棵树。同一文件 Git 和插件都有状态时用 Git 的,因为第一期 Git 不在统一层,嵌套时以 Git 的显示为准,不让两套颜色互相覆盖。 -- 文件操作跟踪:工作区模块只多一个注入的 `moveTrackedItem` 回调,不认识任何 VCS。项目树里的重命名和移动先交给拥有该文件的 Provider(`svn move`);Provider 不跟踪这个文件或拒绝时,退回原来的磁盘移动。 -- 刷新:沿用工作区的文件变化事件,合并后再刷新受影响的根;元数据目录里只有 Provider 声明的 `metadataPaths`(SVN 是 `.svn/wc.db`)会触发刷新。宿主自己的请求执行期间和结束后的短暂时间内忽略元数据变化,否则每次提交都会再刷新一遍。出现或消失标记目录时重新识别根。 +- 文件操作跟踪:工作区模块只多一个注入的 `moveTrackedItem` 回调,不认识任何 VCS。项目树里的重命名和移动先交给拥有该文件的 Provider(`svn move`);Provider 不跟踪这个文件或拒绝时,退回原来的磁盘移动。源路径和目标路径都要用 Core 的 `vcs.ownerOf` 判断归属,两端都属于同一个插件根才交给 Provider:嵌套的 Git 仓库、还没选 VCS 的歧义目录都不属于外层工作副本。只按“最深的插件根”判断会把移进嵌套 Git 仓库的文件也交给 `svn move`。 +- 刷新:沿用工作区的文件变化事件,合并后再刷新受影响的根;元数据目录里只有 Provider 声明的 `metadataPaths`(SVN 是 `.svn/wc.db`)会触发刷新。宿主自己的请求执行期间和结束后的短暂时间内忽略元数据变化,否则每次提交都会再刷新一遍。出现或消失标记目录时重新识别根。工作区开在工作副本子目录里时,`../.svn` 不在工作区监听范围内,所以给这类外层根单独监听元数据目录(只监听 `metadataPaths` 所在的标记目录,不监听整个外层工作副本),事件走同一条过滤和合并逻辑;这个监听丢了事件时按标记变化处理,重新识别根。 +- 生命周期:关闭工作区、切换工作区,以及原生项目窗口关闭(`shutdownProjectSession`,这时工作区可能被设置窗口保留而不关闭)都会在第一次挂起前清空 `VcsProvidersFeatureModel`:取消识别和刷新任务,停止该工作区启动中和运行中的 Provider 进程,停掉外层元数据监听。窗口关闭走 `shutdown()`,之后这个窗口的模型不再加载任何工作区:关闭时还在进行的工作区扫描随后调用 `loadVcsProviders` 也不会把 Provider 重新拉起来;关闭的会话模型不会被复用,所以不需要恢复入口。 - 歧义目录的选择按工作区存在应用偏好里(`Vcs.RootMappings.<工作区摘要>`),不写工作区,也不写 app bundle。识别时目录不再歧义,就丢掉旧选择。 P3 剩下的部分:编辑器 gutter 与 Annotate、交互式认证(`lithe/vcs/authenticate` 仍按未知请求回绝)、把 `lithe/vcs/execution` 执行报告接到项目控制台,以及在 Lithe Store 上架 SVN 插件。上架要等带 `vcsProviders` 能力的 Lithe 版本发布后再做,否则旧版本用户会在市场里看到装不上的插件;P1 合入后,插件仓库的 `sdk.lock.json` 也要改指向 `preview` 上的提交。 diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 325f2f8f32..07e56b2da8 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3755,6 +3755,7 @@ "The workspace is too large to search completely; some working copies may be missing." = "工作区过大,无法完整搜索;可能有工作副本未被识别。"; "There are too many changes to list; some are not shown." = "更改过多,部分未显示。"; "Detecting working copies…" = "正在识别工作副本…"; +"Reading changes…" = "正在读取更改…"; "No %@ working copy was detected in this workspace." = "此工作区中没有识别到 %@ 工作副本。"; "Detect Again" = "重新识别"; "Moved from %@" = "从 %@ 移动"; diff --git a/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift index c4cdda7911..6e7c988b88 100644 --- a/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift @@ -144,9 +144,15 @@ final class VcsProvidersFeatureModel: ObservableObject { private let readFile: @MainActor (URL) -> Data? private let sleep: @Sendable (Duration) async throws -> Void private let now: @Sendable () -> ContinuousClock.Instant + private let metadataWatcherFactory: (any DirectoryWatcherFactory)? + /// Watchers of metadata directories outside the workspace, by path. + private var metadataWatchers: [String: any DirectoryChangeSource] = [:] /// Bumped whenever the workspace changes, so late results are dropped. private var generation = 0 + /// Set when the project window's session ends; the feature then stays + /// empty, whatever late workspace load still asks for. + private var isShutDown = false /// Every detected root, Git included, for ownership. private var allRootIDs: [String] = [] private var markerNames: Set = [] @@ -170,6 +176,7 @@ final class VcsProvidersFeatureModel: ObservableObject { readFile: @escaping @MainActor (URL) -> Data? = { _ in nil }, sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }, now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, + metadataWatcherFactory: (any DirectoryWatcherFactory)? = nil, sources: @escaping @MainActor () -> [VcsProviderPluginSource] ) { self.planner = planner @@ -178,6 +185,7 @@ final class VcsProvidersFeatureModel: ObservableObject { self.readFile = readFile self.sleep = sleep self.now = now + self.metadataWatcherFactory = metadataWatcherFactory self.sources = sources } @@ -191,8 +199,9 @@ final class VcsProvidersFeatureModel: ObservableObject { // MARK: Workspace /// Forgets the previous workspace, stops its providers, and detects the - /// new one. + /// new one. After `shutdown()` it only clears. func reset(workspaceURL: URL?) { + let workspaceURL = isShutDown ? nil : workspaceURL generation += 1 detectionTask?.cancel(); detectionTask = nil refreshTasks.values.forEach { $0.cancel() } @@ -206,10 +215,20 @@ final class VcsProvidersFeatureModel: ObservableObject { runningOperation = nil; failure = nil rootTreeStatuses = [:]; treeStatus = VcsTreeStatusProjection() commitMessageDrafts = [:]; presentedDiff = nil + watchOutsideMetadata() retainProviders() detect() } + /// Ends the feature with its project session: stops every provider and + /// ignores later requests to load a workspace, such as one from a scan + /// that finishes while the window closes. A closed session's model is not + /// reused, so nothing turns it back on. + func shutdown() { + isShutDown = true + reset(workspaceURL: nil) + } + /// Detects the roots again with the plugins enabled now. Status already /// read is kept for roots that stay. func detect() { @@ -314,6 +333,7 @@ final class VcsProvidersFeatureModel: ObservableObject { if let unused = detection?.unusedMappings, !unused.isEmpty { mappingStore.setMappings(mappings.filter { !unused.contains($0) }, workspaceURL: workspaceURL) } + watchOutsideMetadata() retainProviders() roots.forEach { refresh($0.id) } } @@ -333,6 +353,47 @@ final class VcsProvidersFeatureModel: ObservableObject { // MARK: File changes + /// Watches the metadata directories of working copies that enclose the + /// workspace, such as `../.svn`. The workspace watcher sees only the + /// workspace, so a commit or update from a terminal would otherwise go + /// unnoticed. Only the directory holding a declared metadata path is + /// watched, never the enclosing working copy itself. + private func watchOutsideMetadata() { + guard let metadataWatcherFactory else { return } + var directories: [String: URL] = [:] + for root in roots where root.id.hasPrefix("..") { + for path in root.metadataPaths { + guard let first = path.split(separator: "/").first.map(String.init), + root.metadataDirectories.contains(first) else { continue } + let url = root.url.appendingPathComponent(first, isDirectory: true).standardizedFileURL + directories[url.path] = url + } + } + for (path, watcher) in metadataWatchers where directories[path] == nil { + watcher.stop() + metadataWatchers[path] = nil + } + for (path, url) in directories where metadataWatchers[path] == nil { + let watcher = metadataWatcherFactory.make( + configuration: DirectoryWatchConfiguration(workspaceRoot: url, gitContext: nil), + visibilityRules: .default + ) { [weak self] batch in + Task { @MainActor [weak self] in self?.outsideMetadataChanged(batch, in: url) } + } + metadataWatchers[path] = watcher + watcher.start() + } + } + + /// A metadata directory that lost track of its events, or went away, + /// counts as the marker changing, so the roots are detected again. + private func outsideMetadataChanged(_ batch: DirectoryChangeBatch, in directory: URL) { + guard metadataWatchers[directory.path] != nil else { return } + workspaceFilesChanged( + batch.requiresFullRescan ? [directory] : batch.workspacePaths.map { URL(fileURLWithPath: $0) } + ) + } + /// Refreshes the roots whose files changed, shortly after the last /// change. Metadata changes count only for the provider's declared /// metadata paths, and not while the host's own requests write them. @@ -561,11 +622,24 @@ final class VcsProvidersFeatureModel: ObservableObject { /// such root tracks the item, or the provider refused; the caller then /// moves it on disk, and a refusal is reported. func move(from source: URL, to destination: URL) async -> Bool { - guard runningOperation == nil, + guard runningOperation == nil, let workspaceURL, let (root, from) = rootPath(of: source), let (destinationRoot, to) = rootPath(of: destination), root.id == destinationRoot.id, root.supports(.fileOperations), !from.isEmpty, !to.isEmpty, - !isUnversioned(source, in: root), !isUnversioned(destination.deletingLastPathComponent(), in: root) + !isUnversioned(source, in: root), !isUnversioned(destination.deletingLastPathComponent(), in: root), + let sourcePath = Self.workspaceRelativePath(source, workspaceURL: workspaceURL), + let destinationPath = Self.workspaceRelativePath(destination, workspaceURL: workspaceURL) else { return false } + // Both ends must belong to the root by Core's rule: a nested root of + // another VCS, such as a Git repository, or an ambiguous directory + // with no VCS chosen takes its paths away from the working copy. + let planner = planner + let rootIDs = allRootIDs + let ambiguousIDs = ambiguous.map(\.id) + let generation = generation + let owners = await Task.detached(priority: .userInitiated) { + try? planner.owners(roots: rootIDs, ambiguous: ambiguousIDs, paths: [sourcePath, destinationPath]) + }.value + guard self.generation == generation, let owners, owners == [root.id, root.id] else { return false } return await perform(.move(from: from, to: to), in: root.id, title: String(localized: "Moving…"), as: VcsEmptyResult.self) != nil } @@ -670,6 +744,12 @@ final class VcsProvidersFeatureModel: ObservableObject { return nil } + private static func workspaceRelativePath(_ url: URL, workspaceURL: URL) -> String? { + let path = url.standardizedFileURL.path + let base = workspaceURL.standardizedFileURL.path + return path.hasPrefix(base + "/") ? String(path.dropFirst(base.count + 1)) : nil + } + private func isUnversioned(_ url: URL, in root: VcsRootState) -> Bool { guard let status = treeStatus.status(for: url) else { return false } return status == .unversioned || status == .ignored diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 4bcb5bb2ce..cad7939a8e 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -699,6 +699,9 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { // closing its workspace. Revoke page requests before teardown can yield. services.pluginWebPages.closeAll() workbenchFeature.endLayoutSession() + // VCS providers too: their pending refreshes, or a scan still loading + // the workspace, would otherwise start a process for a closed window. + vcsProvidersFeature?.shutdown() await ideCapabilities.shutdown() shortcutSessionCoordinator?.shutdown() documentLanguageCoordinator?.stop() diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 1b93079230..2486dd31c2 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -796,6 +796,7 @@ final class MacServiceContainer { providers: vcsProviderSessions, mappingStore: MacVcsRootMappingStore(store: store), readFile: { url in try? fileStorage.readData(from: url, options: .mappedIfSafe) }, + metadataWatcherFactory: MacDirectoryWatcherFactory(), sources: { // Enablement is read on every detection, so turning a // plugin off takes its roots away at once. diff --git a/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift index 4ac576c854..bb683141d8 100644 --- a/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift +++ b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift @@ -358,7 +358,18 @@ struct VcsProviderToolWindowView: View { ScrollView(.vertical) { LazyVStack(alignment: .leading, spacing: 0) { let groups = Self.groups(root.snapshot?.changes ?? []) - if groups.isEmpty, root.snapshot != nil { + if root.snapshot == nil, root.isRefreshing { + // The first read also starts the provider process, + // which can take a few seconds. + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Reading changes…") + } + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } else if groups.isEmpty, root.snapshot != nil { Text("No local changes") .font(LitheTheme.uiFont(size: 12)) .foregroundStyle(LitheTheme.secondaryText) @@ -493,8 +504,19 @@ struct VcsProviderToolWindowView: View { .litheContextMenu(items: { contextMenu(for: change, root: root) }, onRightClick: { if !selection.contains(change.path) { selection = [change.path] } }) + // The combined row would otherwise only toggle its checkbox, so + // selecting, opening and including are separate actions. .accessibilityElement(children: .combine) .accessibilityLabel(Text(verbatim: "\(change.path), \(change.status.title)")) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityAction { select(change) } + .accessibilityAction(named: Text(Self.opensDiff(change, in: root) ? "Show Diff" : "Open File")) { + open(change, in: root) + } + .accessibilityAction(named: Text("Include in commit")) { + guard root.supports(.checkin), group != .conflicts else { return } + inclusion[change.path] = !isIncluded(change) + } } static func rowHelp(_ change: VcsChange) -> String { @@ -519,13 +541,17 @@ struct VcsProviderToolWindowView: View { /// deleted file has nothing to compare, so it opens or does nothing. private func open(_ change: VcsChange, in root: VcsRootState) { selection = [change.path] - if root.supports(.baseContent) && ![.unversioned, .added, .copied].contains(change.status) { + if Self.opensDiff(change, in: root) { showDiff(change, in: root) } else if ![.deleted, .missing].contains(change.status) { actions.openFile(root.url.appendingPathComponent(change.path)) } } + static func opensDiff(_ change: VcsChange, in root: VcsRootState) -> Bool { + root.supports(.baseContent) && ![.unversioned, .added, .copied].contains(change.status) + } + private func contextMenu(for change: VcsChange, root: VcsRootState) -> [LitheContextMenuItem] { let selected = selectedChanges(root) let url = root.url.appendingPathComponent(change.path) diff --git a/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift b/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift index cde546b931..4df9e73d73 100644 --- a/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift +++ b/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift @@ -33,20 +33,22 @@ public struct DeclarativeVcsProvider: Equatable, Sendable { /// The tool window and its toggle command, derived from the declaration /// like a build system's: every provider gets the same window, which - /// sits next to the Git commit window. + /// sits next to the Git commit window and shares its version control + /// icon (the `git` host icon), since a provider declares none. public func contributions(pluginID: String, order: Int) -> [ModuleContribution] { let windowID = Self.toolWindowID(pluginID: pluginID, vcs: id) let visibility = [Self.pluginIDKey: pluginID, Self.vcsKey: id] + let icon = PluginUIIcon(host: "git") return [ ModuleContribution( id: windowID, kind: .toolWindow, title: displayName, placement: .leftSidebar, order: 210 + order, actionID: windowID + ".toggle", rendererID: windowID, - visibility: visibility + visibility: visibility, uiIcon: icon ), ModuleContribution( id: windowID + ".command", kind: .command, title: displayName, placement: .commandPalette, order: order, actionID: windowID + ".command", - visibility: visibility.merging(["toolWindowID": windowID]) { _, new in new } + visibility: visibility.merging(["toolWindowID": windowID]) { _, new in new }, uiIcon: icon ) ] } diff --git a/macos/Tests/LitheTests/VcsProviderHostTests.swift b/macos/Tests/LitheTests/VcsProviderHostTests.swift index 858ff9eff6..fe1e9667d9 100644 --- a/macos/Tests/LitheTests/VcsProviderHostTests.swift +++ b/macos/Tests/LitheTests/VcsProviderHostTests.swift @@ -80,6 +80,39 @@ struct VcsProviderHostTests { #expect(await fixture.settled { fixture.planner.detectCount == 2 }) } + /// The workspace watcher never sees `../.svn` of a working copy that + /// encloses the workspace, so that metadata directory is watched on its + /// own while the enclosing root is detected. + @Test + func watchesTheMetadataOfAWorkingCopyThatEnclosesTheWorkspace() async throws { + let fixture = try VcsFeatureFixture( + workspaceURL: URL(fileURLWithPath: "/virtual/wc/app", isDirectory: true), + roots: [("..", "svn"), ("lib", "svn")] + ) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + let enclosing = fixture.workspaceURL.appendingPathComponent("..").path + let metadata = URL(fileURLWithPath: "/virtual/wc/.svn", isDirectory: true) + #expect(await fixture.open()) + // `lib/.svn` is inside the workspace, which the workspace watcher covers. + #expect(fixture.metadataWatchers.running == [metadata.path]) + #expect(fixture.providers.statusCount(root: enclosing) == 1) + fixture.clock.advance(by: .seconds(3)) + + fixture.metadataWatchers.deliver([metadata.appendingPathComponent("wc.db")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: enclosing) == 2 }) + #expect(fixture.providers.statusCount(root: fixture.url("lib").path) == 1) + + // A watch that lost its events detects the roots again. + fixture.clock.advance(by: .seconds(3)) + fixture.metadataWatchers.deliver(DirectoryChangeBatch(requiresFullRescan: true)) + #expect(await fixture.settled { fixture.planner.detectCount == 2 }) + #expect(fixture.metadataWatchers.running == [metadata.path]) + + fixture.close() + #expect(fixture.metadataWatchers.running.isEmpty) + } + @Test func runsOneWorkingCopyActionAtATime() async throws { let fixture = try VcsFeatureFixture(roots: [("", "svn")]) @@ -108,7 +141,9 @@ struct VcsProviderHostTests { @Test func movesTrackedItemsThroughTheProviderAndLeavesTheRestToTheDisk() async throws { - let fixture = try VcsFeatureFixture(roots: [("", "svn"), ("module", "svn")]) + let fixture = try VcsFeatureFixture( + roots: [("", "svn"), ("module", "svn"), ("lib", "git")], ambiguous: ["mixed"] + ) defer { fixture.close() } fixture.providers.replies["status"] = try VcsTestData.statusReply([ ("a.txt", "modified"), ("new.txt", "unversioned"), ("out", "unversioned") @@ -126,6 +161,12 @@ struct VcsProviderHostTests { #expect(await feature.move(from: fixture.url("new.txt"), to: fixture.url("docs/new.txt")) == false) #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("out/b.txt")) == false) #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("module/b.txt")) == false) + // A nested Git repository and a directory with no VCS chosen are not + // the working copy's, on either end of the move. + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("lib/b.txt")) == false) + #expect(await feature.move(from: fixture.url("lib/c.txt"), to: fixture.url("c.txt")) == false) + #expect(await feature.move(from: fixture.url("b.txt"), to: fixture.url("mixed/b.txt")) == false) + #expect(await feature.move(from: fixture.url("mixed/c.txt"), to: fixture.url("c.txt")) == false) #expect(fixture.providers.requests.filter { $0.operation.name == "fileOperation" }.count == 1) // A refusal is reported; the caller then moves the item on disk. @@ -218,6 +259,17 @@ struct VcsProviderHostTests { #expect(changes.filter(VcsProviderToolWindowView.isIncludedByDefault).map(\.path) == ["z.txt", "c.txt"]) } + /// A provider declares no icon; its window and command show the host's + /// version control icon, not the generic tool window one. + @Test + func providerWindowsShowTheVersionControlIcon() { + let contributions = DeclarativeVcsProvider(id: "svn", displayName: "Subversion") + .contributions(pluginID: VcsTestData.pluginID, order: 0) + #expect(contributions.map(\.uiIcon) == [PluginUIIcon(host: "git"), PluginUIIcon(host: "git")]) + let asset = WorkbenchContributionIcon.Asset(contributions[0].uiIcon, resources: nil) + #expect(asset.hostPath == "expui/toolwindows/vcs@20x20.svg") + } + @Test func commitCarriesBothHalvesOfAMoveOnce() { let paths = VcsProviderToolWindowView.commitPaths([ @@ -304,16 +356,16 @@ struct VcsProviderHostTests { } } -/// Project tree moves through the production app model: a tracked file goes -/// to the provider, an unversioned one stays a plain disk move. -@Suite("VCS provider project tree moves", .serialized) +/// The provider feature through the production app model: project tree +/// moves and the project window's teardown. +@Suite("VCS providers in the app model", .serialized) @MainActor -struct VcsProviderProjectMoveTests { +struct VcsProviderAppModelTests { + /// A tracked file goes to the provider, an unversioned one stays a plain + /// disk move. @Test func projectTreeMovesGoThroughTheProviderThatTracksTheFile() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-vcs-move-\(UUID().uuidString)", isDirectory: true) - .standardizedFileURL + let root = try Self.makeWorkspace() defer { try? FileManager.default.removeItem(at: root) } try FileManager.default.createDirectory(at: root.appendingPathComponent("docs"), withIntermediateDirectories: true) try Data("alpha".utf8).write(to: root.appendingPathComponent("a.txt")) @@ -329,13 +381,7 @@ struct VcsProviderProjectMoveTests { let base = URL(fileURLWithPath: rootPath) try FileManager.default.moveItem(at: base.appendingPathComponent(from), to: base.appendingPathComponent(to)) } - let store = VcsTestKeyValueStore() - let settings = AppSettings(store: store) - let services = MacServiceContainer( - store: store, settings: settings, moduleLaunchMode: .safeMode, platformUI: VcsMoveTestUI() - ).services - services.vcsProviders = fixture.feature - let model = AppModel(settings: settings, services: services) + let model = Self.makeModel(fixture) defer { model.workspaceFeature.reset() } model.openProjectDirectly(root) @@ -355,6 +401,62 @@ struct VcsProviderProjectMoveTests { // Not tracking a file is no failure; the disk move reports as usual. #expect(fixture.feature.failure == nil) } + + /// Closing the native project window ends the session without closing + /// the workspace. The workspace's providers stop all the same, without + /// waiting for a status read still in flight, and a workspace load that + /// lands after the close does not start them again. + @Test + func closingTheProjectWindowStopsTheWorkspaceProviders() async throws { + let root = try Self.makeWorkspace() + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("a.txt").standardizedFileURL + try Data("alpha".utf8).write(to: file) + let fixture = try VcsFeatureFixture(workspaceURL: root, roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("a.txt", "modified")]) + let model = Self.makeModel(fixture) + defer { model.workspaceFeature.reset() } + + model.openProjectDirectly(root) + #expect(await awaitChange(on: model) { model.projectFiles.contains(file) }) + model.loadVcsProviders(at: root) + #expect(await fixture.settled()) + #expect(fixture.providers.running.map(\.workspaceURL) == [root]) + let statusHeld = TestGate() + fixture.providers.holds["status"] = statusHeld + defer { statusHeld.open() } + let feature = fixture.feature + feature.refresh("") + #expect(feature.root("")?.isRefreshing == true) + + await model.shutdownProjectSession() + // A scan that was still loading the workspace finishes afterwards. + model.loadVcsProviders(at: root) + + #expect(fixture.providers.running.isEmpty) + #expect(feature.workspaceURL == nil) + #expect(feature.roots.isEmpty) + #expect(!feature.isDetecting) + } + + private static func makeWorkspace() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-vcs-model-\(UUID().uuidString)", isDirectory: true) + .standardizedFileURL + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func makeModel(_ fixture: VcsFeatureFixture) -> AppModel { + let store = VcsTestKeyValueStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer( + store: store, settings: settings, moduleLaunchMode: .safeMode, platformUI: VcsMoveTestUI() + ).services + services.vcsProviders = fixture.feature + return AppModel(settings: settings, services: services) + } } // MARK: - Test doubles @@ -443,8 +545,17 @@ private final class FakeVcsPlanner: VcsRootPlanning, @unchecked Sendable { } func owners(roots: [String], ambiguous: [String], paths: [String]) throws -> [String?] { - paths.map { path in - roots.filter { $0.isEmpty || path == $0 || path.hasPrefix($0 + "/") }.max { $0.count < $1.count } + func contains(_ id: String, _ path: String) -> Bool { + id.isEmpty || id.hasPrefix("..") || path == id || path.hasPrefix(id + "/") + } + func depth(_ id: String) -> Int { + id.isEmpty ? 0 : id.split(separator: "/").count * (id.hasPrefix("..") ? -1 : 1) + } + return paths.map { path in + let root = roots.filter { contains($0, path) }.max { depth($0) < depth($1) } + let directory = ambiguous.filter { contains($0, path) }.map(depth).max() + if let root, let directory, directory > depth(root) { return nil } + return root } } @@ -473,6 +584,8 @@ private final class FakeVcsProviders: VcsProviderRequesting { var onFileOperation: ((VcsOperation, String) throws -> Void)? private(set) var requests: [Request] = [] private(set) var retainCount = 0 + /// Targets that answered a request and were not stopped since. + private(set) var running: Set = [] func statusCount(root: String) -> Int { requests.filter { $0.operation.name == "status" && $0.root == root }.count @@ -489,6 +602,7 @@ private final class FakeVcsProviders: VcsProviderRequesting { timeout: Duration ) async throws -> VcsProviderReply { requests.append(Request(operation: operation, root: root)) + running.insert(target) if let hold = holds[operation.name] { _ = await hold.waitUntilOpen(timeout: .seconds(5)) } @@ -499,6 +613,7 @@ private final class FakeVcsProviders: VcsProviderRequesting { func retainProviders(_ keep: (VcsProviderTarget) -> Bool) { retainCount += 1 + running = running.filter(keep) } private static func negotiation(_ vcs: String) -> VcsProviderNegotiation { @@ -508,6 +623,46 @@ private final class FakeVcsProviders: VcsProviderRequesting { } } +/// Metadata directory watchers that deliver only what a test sends. +private final class FakeMetadataWatchers: DirectoryWatcherFactory, @unchecked Sendable { + final class Watcher: DirectoryChangeSource, @unchecked Sendable { + let directory: URL + let onChange: @Sendable (DirectoryChangeBatch) -> Void + private(set) var isRunning = false + + init(directory: URL, onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void) { + self.directory = directory + self.onChange = onChange + } + + func start() { isRunning = true } + func stop() { isRunning = false } + } + + private(set) var watchers: [Watcher] = [] + + /// The paths of the directories watched now. + var running: [String] { watchers.filter(\.isRunning).map(\.directory.path).sorted() } + + func make( + configuration: DirectoryWatchConfiguration, + visibilityRules: FileVisibilityRules, + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void + ) -> any DirectoryChangeSource { + let watcher = Watcher(directory: configuration.workspaceRoot, onChange: onChange) + watchers.append(watcher) + return watcher + } + + func deliver(_ urls: [URL]) { + deliver(DirectoryChangeBatch(workspacePaths: urls.map(\.path))) + } + + func deliver(_ batch: DirectoryChangeBatch) { + watchers.filter(\.isRunning).forEach { $0.onChange(batch) } + } +} + /// A feature model over the fakes. The refresh delay passes at once and /// the clock moves only when a test advances it. @MainActor @@ -515,6 +670,7 @@ private final class VcsFeatureFixture { let workspaceURL: URL let planner: FakeVcsPlanner let providers = FakeVcsProviders() + let metadataWatchers = FakeMetadataWatchers() let keyValues = VcsTestKeyValueStore() let clock = VcsTestClock() let feature: VcsProvidersFeatureModel @@ -535,6 +691,7 @@ private final class VcsFeatureFixture { mappingStore: MacVcsRootMappingStore(store: keyValues), sleep: { _ in try Task.checkCancellation() }, now: { clock.now }, + metadataWatcherFactory: metadataWatchers, sources: { [VcsProviderPluginSource( pluginID: VcsTestData.pluginID, manifest: Data("{}".utf8), diff --git a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json index 8a03f5e2d9..2d6209cf36 100644 --- a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json +++ b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json @@ -49,6 +49,6 @@ "verificationStatus": "not-applicable" }, "owner": "Git", - "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp、npm test --prefix sdk/node、LITHE_PLUGIN_SDK_NODE=$(command -v node) cargo test -p lithe-core --test plugin_sdk_node 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例,以及用 Node SDK 写的示例 Provider 经真实 Core 会话完成协商、全部 11 种操作和执行报告。Subversion 社区插件:独立源码由 https://github.com/Lithe-IDEA/lithe-plugin-svn 维护;插件 CI 校验固定 SDK 的源码哈希,用 svn 录制的 status/info/log/blame --xml 与错误输出样例覆盖解析和错误码映射,并按 sdk.lock.json 的宿主提交构建 Core 桥接,在 svnadmin create 建立的 file:// 仓库上经 Core 跑通状态、提交、过期提交、更新与冲突、resolve、回滚、移动、删除、历史分页、Annotate 和二进制文件;固定宿主基线不代表已验证未来 preview。主仓库只保留通用 VCS 契约回归(样例 Provider 与清单 fixture)。宿主验收:从磁盘或 Lithe Store 安装 Subversion 社区插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", - "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 已支持 litheVcs(P1);Subversion 社区插件在独立仓库 Lithe-IDEA/lithe-plugin-svn 实现全部 11 种操作,ID 为 io.github.lithe-idea.svn-support,不需要 Lithe 签名(P2)。macOS 宿主已声明 vcsProviders:打开工作区时识别根并记住歧义目录的选择,插件的左侧工具窗口列出冲突、变更和未版本化文件,支持勾选提交、更新、回滚、标记解决、清理、文件与工作副本历史、与基线比较;项目树按 Provider 状态着色并在右键菜单提供比较,项目树中的重命名和移动交给 Provider 跟踪;安装审核显示 Provider 的启动命令、客户端命令和是否联网(P3)。macOS 尚未接入编辑器 gutter 与 Annotate,所以记为部分实现;插件要等带 vcsProviders 能力的 Lithe 版本发布后再在 Lithe Store 上架。Windows 宿主尚未接入。分阶段计划见提议 Note。" + "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp、npm test --prefix sdk/node、LITHE_PLUGIN_SDK_NODE=$(command -v node) cargo test -p lithe-core --test plugin_sdk_node 与 scripts/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例,以及用 Node SDK 写的示例 Provider 经真实 Core 会话完成协商、全部 11 种操作和执行报告。Subversion 社区插件:独立源码由 https://github.com/Lithe-IDEA/lithe-plugin-svn 维护;插件 CI 校验固定 SDK 的源码哈希,用 svn 录制的 status/info/log/blame --xml 与错误输出样例覆盖解析和错误码映射,并按 sdk.lock.json 的宿主提交构建 Core 桥接,在 svnadmin create 建立的 file:// 仓库上经 Core 跑通状态(含 changelist 中的路径)、提交、过期提交、更新与冲突、resolve、回滚、移动(未版本化文件不覆盖已有目标)、删除、历史分页、Annotate 和二进制文件;固定宿主基线不代表已验证未来 preview。主仓库只保留通用 VCS 契约回归(样例 Provider 与清单 fixture)。macOS 宿主自动化:VcsProviderHostTests 覆盖根识别与状态着色、包住工作区的工作副本元数据监听、项目树移动两端都归同一个根才交给 Provider、关闭项目窗口后停止 Provider 且之后的工作区加载不再启动、工具窗口使用通用版本控制图标。宿主验收:从磁盘或 Lithe Store 安装 Subversion 社区插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。", + "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 已支持 litheVcs(P1);Subversion 社区插件在独立仓库 Lithe-IDEA/lithe-plugin-svn 实现全部 11 种操作,ID 为 io.github.lithe-idea.svn-support,不需要 Lithe 签名(P2)。macOS 宿主已声明 vcsProviders:打开工作区时识别根并记住歧义目录的选择,插件的左侧工具窗口使用通用版本控制图标,首次读取状态时显示读取中,列出冲突、变更和未版本化文件,行支持辅助功能的选择、打开和勾选操作,支持勾选提交、更新、回滚、标记解决、清理、文件与工作副本历史、与基线比较;项目树按 Provider 状态着色并在右键菜单提供比较,项目树中的重命名和移动在两端都属于同一个根时交给 Provider 跟踪;工作区位于工作副本内部时监听上层 .svn 元数据;关闭项目窗口即停止该窗口的 Provider;安装审核显示 Provider 的启动命令、客户端命令和是否联网(P3)。macOS 尚未接入编辑器 gutter 与 Annotate,所以记为部分实现;插件要等带 vcsProviders 能力的 Lithe 版本发布后再在 Lithe Store 上架。Windows 宿主尚未接入。分阶段计划见提议 Note。" } From 078d0bdb04ed1b50480fab1729a9da3425c3cca0 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 23 Oct 2026 20:39:01 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(vcs):=20=E9=9A=90=E8=97=8F=20.svn=20?= =?UTF-8?q?=E5=B9=B6=E6=8C=89=E6=A0=B9=E7=9B=91=E5=90=AC=E5=85=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=EF=BC=8C=E8=A1=A5=E9=BD=90=E5=B7=A5=E5=85=B7=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E4=B8=8E=E6=AF=94=E8=BE=83=E5=88=A4=E6=96=AD=EF=BC=88?= =?UTF-8?q?#827=20P3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目树、搜索、索引与工作区监听像 .git 一样隐藏 .svn(Swift 与 Rust Core 的内置隐藏列表同步),避免 .svn/pristine 进入索引 - 每个插件根单独监听自己的元数据目录(含包住工作区的 ../.svn);工作区里新出现的条目在后台检查标记,新检出的工作副本会重新识别 - 找不到 VCS 客户端时按契约报 vcs.tool.unavailable,工具窗口先显示安装提示 - 是否有基线统一由 VcsRootState.comparesWithBase 判断,工具窗口双击、辅助功能动作和项目树右键共用 - 等待 Core 判断移动归属期间开始了别的操作时,直接退回磁盘移动 - 同步 Agent Note、功能矩阵与 VcsProviderHostTests --- .../2026-10-22-unified-vcs-provider-model.md | 10 +- .../Features/VcsProvidersFeatureModel.swift | 92 +++++-- .../AppModel/AppModel+VcsProviders.swift | 2 +- .../Platform/MacOS/MacServiceContainer.swift | 1 + .../MacOS/Vcs/MacVcsProviderSessions.swift | 23 +- .../Views/Vcs/VcsProviderToolWindowView.swift | 12 +- .../Views/Workspace/ProjectSidebarView.swift | 2 +- .../Workspace/FileVisibilityRules.swift | 4 +- .../LitheTests/VcsProviderHostTests.swift | 236 ++++++++++++++++-- rust/lithe-core/src/project/files.rs | 3 +- .../vcs-subversion-and-plugin-providers.json | 4 +- 11 files changed, 327 insertions(+), 62 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md index 72fdfbc8cf..690da8bca9 100644 --- a/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md +++ b/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md @@ -146,14 +146,14 @@ P2 已完成(2026-10-23),同日改为社区插件: P3 第一部分已完成(2026-10-23),在 macOS 上: -- 宿主在 `PluginManifest.hostCapabilities` 声明 `vcsProviders: [1]`,声明了 VCS Provider 的插件从这时起能安装。宿主读清单时给这类插件生成一个 `<插件 ID>.vcs-providers` 模块,只承载插件的启用状态,默认关闭,做法和构建系统插件的 `.build-systems` 模块一样。安装审查给三种 Provider 程序(插件内、系统已装、运行时启动)显示启动命令、`vcsTool` 里的客户端命令,以及 `mayUseNetwork` 声明的“可能连接仓库服务器”,让用户批准前知道插件会运行哪个 `svn`。 +- 宿主在 `PluginManifest.hostCapabilities` 声明 `vcsProviders: [1]`,声明了 VCS Provider 的插件从这时起能安装。宿主读清单时给这类插件生成一个 `<插件 ID>.vcs-providers` 模块,只承载插件的启用状态,默认关闭,做法和构建系统插件的 `.build-systems` 模块一样。安装审查给三种 Provider 程序(插件内、系统已装、运行时启动)显示启动命令、`vcsTool` 里的客户端命令,以及 `mayUseNetwork` 声明的“可能连接仓库服务器”,让用户批准前知道插件会运行哪个 `svn`。客户端命令和 Provider 自己的运行时一样经构建工具发现查找,找不到时报契约的 `vcs.tool.unavailable`,工具窗口据此先显示安装提示。 - Provider 会话按窗口创建:启动时已安装的插件声明了 `vcsProviders`,`MacServiceContainer` 才建 `VcsProvidersFeatureModel`。启动后再安装的插件要重启才生效,工具窗口这时只显示“重启 Lithe 后使用”。这样做是因为服务在窗口组合时一次建好,运行中插桩会话要改动整个组合根,收益只是省一次重启。 - 每个 Provider 贡献一个左侧工具窗口,宿主只认贡献上的 `vcsProviderPluginID`、`vcsProviderID` 两个可见性键,代码里没有任何 VCS 或插件的名字(与构建系统窗口同一套注册方式)。Provider 不声明图标,派生的窗口和命令统一用宿主的版本控制图标(`git` 宿主图标,即 `vcs@20x20.svg`),不用通用工具窗口图标。第一次读状态时 Provider 进程才启动,可能要几秒,变更列表这时显示“正在读取更改…”,不留空白。变更行在辅助功能里是一个合并元素,默认动作是选中,另有“显示差异/打开文件”和“包含在提交中”两个命名动作,否则辅助功能只能切换复选框。窗口按冲突、变更、未版本化文件分组:默认只勾选“变更”组,未版本化文件要用户勾选才提交,冲突文件不能勾选,要先“标记为已解决”;移动的文件提交时连同原路径一起提交,否则 SVN 会只提交一半。按钮是否出现看 Provider 协商的能力,不看 VCS 名字。 -- 与基线比较做成窗口级的弹出面板(`VcsProvidersFeatureModel.presentedDiff`,由 `RootView` 显示),不挂在工具窗口里,因为项目树右键的“显示差异”在工具窗口隐藏时也要能用。比较复用本地历史的 `LocalHistoryDiffBuilder` 和 Git 的 `DiffPaneView`;超过 2 万行时退成左右两栏纯文本,避免逐行比较卡住界面。 +- 与基线比较做成窗口级的弹出面板(`VcsProvidersFeatureModel.presentedDiff`,由 `RootView` 显示),不挂在工具窗口里,因为项目树右键的“显示差异”在工具窗口隐藏时也要能用。比较复用本地历史的 `LocalHistoryDiffBuilder` 和 Git 的 `DiffPaneView`;超过 2 万行时退成左右两栏纯文本,避免逐行比较卡住界面。有没有基线由 `VcsRootState.comparesWithBase` 一处判断,工具窗口双击、辅助功能动作和项目树右键共用:新增、未版本化、忽略的文件没有基线,双击直接打开;复制的文件和复制来源比较。 - 项目树颜色:插件根的状态通过一个只观察 `VcsProvidersFeatureModel` 的包装视图传给项目树,项目树本身仍是 `.equatable()`,刷新状态不重绘整棵树。同一文件 Git 和插件都有状态时用 Git 的,因为第一期 Git 不在统一层,嵌套时以 Git 的显示为准,不让两套颜色互相覆盖。 -- 文件操作跟踪:工作区模块只多一个注入的 `moveTrackedItem` 回调,不认识任何 VCS。项目树里的重命名和移动先交给拥有该文件的 Provider(`svn move`);Provider 不跟踪这个文件或拒绝时,退回原来的磁盘移动。源路径和目标路径都要用 Core 的 `vcs.ownerOf` 判断归属,两端都属于同一个插件根才交给 Provider:嵌套的 Git 仓库、还没选 VCS 的歧义目录都不属于外层工作副本。只按“最深的插件根”判断会把移进嵌套 Git 仓库的文件也交给 `svn move`。 -- 刷新:沿用工作区的文件变化事件,合并后再刷新受影响的根;元数据目录里只有 Provider 声明的 `metadataPaths`(SVN 是 `.svn/wc.db`)会触发刷新。宿主自己的请求执行期间和结束后的短暂时间内忽略元数据变化,否则每次提交都会再刷新一遍。出现或消失标记目录时重新识别根。工作区开在工作副本子目录里时,`../.svn` 不在工作区监听范围内,所以给这类外层根单独监听元数据目录(只监听 `metadataPaths` 所在的标记目录,不监听整个外层工作副本),事件走同一条过滤和合并逻辑;这个监听丢了事件时按标记变化处理,重新识别根。 -- 生命周期:关闭工作区、切换工作区,以及原生项目窗口关闭(`shutdownProjectSession`,这时工作区可能被设置窗口保留而不关闭)都会在第一次挂起前清空 `VcsProvidersFeatureModel`:取消识别和刷新任务,停止该工作区启动中和运行中的 Provider 进程,停掉外层元数据监听。窗口关闭走 `shutdown()`,之后这个窗口的模型不再加载任何工作区:关闭时还在进行的工作区扫描随后调用 `loadVcsProviders` 也不会把 Provider 重新拉起来;关闭的会话模型不会被复用,所以不需要恢复入口。 +- 文件操作跟踪:工作区模块只多一个注入的 `moveTrackedItem` 回调,不认识任何 VCS。项目树里的重命名和移动先交给拥有该文件的 Provider(`svn move`);Provider 不跟踪这个文件或拒绝时,退回原来的磁盘移动。源路径和目标路径都要用 Core 的 `vcs.ownerOf` 判断归属,两端都属于同一个插件根才交给 Provider:嵌套的 Git 仓库、还没选 VCS 的歧义目录都不属于外层工作副本。只按“最深的插件根”判断会把移进嵌套 Git 仓库的文件也交给 `svn move`。等待 Core 判断归属期间如果开始了别的操作,移动直接退回磁盘移动,和操作开始前就在运行时一样,不报“等正在运行的操作结束”。 +- 刷新:工作区监听和项目树一样隐藏 VCS 元数据目录。内置隐藏列表里有 `.git` 和 `.svn`,与 IDEA 默认的忽略列表一致;不隐藏的话,`.svn/pristine` 里每个文件的基线副本会进入项目树、搜索、本地历史和索引。所以每个插件根单独监听自己的标记目录,例如 `.svn`,或工作区开在工作副本子目录里时的 `../.svn`。只监听 `metadataPaths` 所在的标记目录,不监听整个外层工作副本。工作文件的变化仍走工作区事件,两路事件走同一条过滤和合并逻辑,合并后再刷新受影响的根。元数据目录里只有 Provider 声明的 `metadataPaths`(SVN 是 `.svn/wc.db`)会触发刷新。宿主自己的请求执行期间和结束后的短暂时间内忽略元数据变化,否则每次提交、甚至每次读状态都会再刷新一遍;代价是命令行在这段时间里的改动,要等下一次刷新才显示。元数据监听丢了事件,或标记目录消失时,按标记变化处理,重新识别根。标记目录隐藏后,工作区事件里看不到新出现的 `.svn`。所以新建的条目和它所在的目录如果不是已知的根,就在后台线程查一次有没有标记,有才重新识别,例如在工作区里 checkout 了新工作副本,或 update 拉下了 external。 +- 生命周期:关闭工作区、切换工作区,以及原生项目窗口关闭(`shutdownProjectSession`,这时工作区可能被设置窗口保留而不关闭)都会在第一次挂起前清空 `VcsProvidersFeatureModel`:取消识别和刷新任务,停止该工作区启动中和运行中的 Provider 进程,停掉元数据监听。窗口关闭走 `shutdown()`,之后这个窗口的模型不再加载任何工作区:关闭时还在进行的工作区扫描随后调用 `loadVcsProviders` 也不会把 Provider 重新拉起来;关闭的会话模型不会被复用,所以不需要恢复入口。 - 歧义目录的选择按工作区存在应用偏好里(`Vcs.RootMappings.<工作区摘要>`),不写工作区,也不写 app bundle。识别时目录不再歧义,就丢掉旧选择。 P3 剩下的部分:编辑器 gutter 与 Annotate、交互式认证(`lithe/vcs/authenticate` 仍按未知请求回绝)、把 `lithe/vcs/execution` 执行报告接到项目控制台,以及在 Lithe Store 上架 SVN 插件。上架要等带 `vcsProviders` 能力的 Lithe 版本发布后再做,否则旧版本用户会在市场里看到装不上的插件;P1 合入后,插件仓库的 `sdk.lock.json` 也要改指向 `preview` 上的提交。 diff --git a/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift index 6e7c988b88..5b40d178e8 100644 --- a/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift @@ -30,8 +30,8 @@ struct VcsRootState: Identifiable, Equatable { var id: String { root.id } - /// The workspace folder's name for the workspace root and enclosing - /// working copies; the relative path for nested ones. + /// The root folder's own name for the workspace root and an enclosing + /// working copy; the relative path for a nested one. var title: String { root.id.isEmpty || root.id.hasPrefix("..") ? url.lastPathComponent : root.id } @@ -39,6 +39,12 @@ struct VcsRootState: Identifiable, Equatable { func supports(_ capability: VcsCapability) -> Bool { negotiation?.supports(capability) == true } + + /// Whether the change has a base revision worth comparing with. A copy + /// compares with its source; a new file has nothing to compare. + func comparesWithBase(_ change: VcsChange) -> Bool { + supports(.baseContent) && ![.unversioned, .ignored, .added].contains(change.status) + } } /// One file compared with its base revision. @@ -142,10 +148,11 @@ final class VcsProvidersFeatureModel: ObservableObject { private let mappingStore: any VcsRootMappingStoring private let sources: @MainActor () -> [VcsProviderPluginSource] private let readFile: @MainActor (URL) -> Data? + private let fileExists: @Sendable (URL) -> Bool private let sleep: @Sendable (Duration) async throws -> Void private let now: @Sendable () -> ContinuousClock.Instant private let metadataWatcherFactory: (any DirectoryWatcherFactory)? - /// Watchers of metadata directories outside the workspace, by path. + /// Watchers of the roots' metadata directories, by path. private var metadataWatchers: [String: any DirectoryChangeSource] = [:] /// Bumped whenever the workspace changes, so late results are dropped. @@ -174,6 +181,7 @@ final class VcsProvidersFeatureModel: ObservableObject { providers: any VcsProviderRequesting, mappingStore: any VcsRootMappingStoring, readFile: @escaping @MainActor (URL) -> Data? = { _ in nil }, + fileExists: @escaping @Sendable (URL) -> Bool = { _ in false }, sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }, now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }, metadataWatcherFactory: (any DirectoryWatcherFactory)? = nil, @@ -183,6 +191,7 @@ final class VcsProvidersFeatureModel: ObservableObject { self.providers = providers self.mappingStore = mappingStore self.readFile = readFile + self.fileExists = fileExists self.sleep = sleep self.now = now self.metadataWatcherFactory = metadataWatcherFactory @@ -215,7 +224,7 @@ final class VcsProvidersFeatureModel: ObservableObject { runningOperation = nil; failure = nil rootTreeStatuses = [:]; treeStatus = VcsTreeStatusProjection() commitMessageDrafts = [:]; presentedDiff = nil - watchOutsideMetadata() + watchMetadata() retainProviders() detect() } @@ -333,7 +342,7 @@ final class VcsProvidersFeatureModel: ObservableObject { if let unused = detection?.unusedMappings, !unused.isEmpty { mappingStore.setMappings(mappings.filter { !unused.contains($0) }, workspaceURL: workspaceURL) } - watchOutsideMetadata() + watchMetadata() retainProviders() roots.forEach { refresh($0.id) } } @@ -353,15 +362,15 @@ final class VcsProvidersFeatureModel: ObservableObject { // MARK: File changes - /// Watches the metadata directories of working copies that enclose the - /// workspace, such as `../.svn`. The workspace watcher sees only the - /// workspace, so a commit or update from a terminal would otherwise go - /// unnoticed. Only the directory holding a declared metadata path is - /// watched, never the enclosing working copy itself. - private func watchOutsideMetadata() { + /// Watches the metadata directory of every root, such as `.svn` or + /// `../.svn`. The workspace watcher hides VCS metadata as the project + /// tree does and never sees a working copy that encloses the workspace, + /// so a commit or update from a terminal would otherwise go unnoticed. + /// Only the directory holding a declared metadata path is watched. + private func watchMetadata() { guard let metadataWatcherFactory else { return } var directories: [String: URL] = [:] - for root in roots where root.id.hasPrefix("..") { + for root in roots { for path in root.metadataPaths { guard let first = path.split(separator: "/").first.map(String.init), root.metadataDirectories.contains(first) else { continue } @@ -378,7 +387,7 @@ final class VcsProvidersFeatureModel: ObservableObject { configuration: DirectoryWatchConfiguration(workspaceRoot: url, gitContext: nil), visibilityRules: .default ) { [weak self] batch in - Task { @MainActor [weak self] in self?.outsideMetadataChanged(batch, in: url) } + Task { @MainActor [weak self] in self?.metadataChanged(batch, in: url) } } metadataWatchers[path] = watcher watcher.start() @@ -387,18 +396,26 @@ final class VcsProvidersFeatureModel: ObservableObject { /// A metadata directory that lost track of its events, or went away, /// counts as the marker changing, so the roots are detected again. - private func outsideMetadataChanged(_ batch: DirectoryChangeBatch, in directory: URL) { + private func metadataChanged(_ batch: DirectoryChangeBatch, in directory: URL) { guard metadataWatchers[directory.path] != nil else { return } - workspaceFilesChanged( + filesChanged( batch.requiresFullRescan ? [directory] : batch.workspacePaths.map { URL(fileURLWithPath: $0) } ) } + /// Workspace changes: the roots whose working files changed are read + /// again. The workspace watcher hides VCS markers, so a new working copy + /// shows only as created entries next to its marker. + func workspaceFilesChanged(_ changes: [WorkspaceFileChange]) { + filesChanged(changes.map(\.fileURL)) + detectNewRoots(near: changes.filter { $0.kind == .created }.map(\.fileURL)) + } + /// Refreshes the roots whose files changed, shortly after the last /// change. Metadata changes count only for the provider's declared /// metadata paths, and not while the host's own requests write them. - /// A marker directory appearing or going away detects again. - func workspaceFilesChanged(_ urls: [URL]) { + /// A marker directory going away detects again. + private func filesChanged(_ urls: [URL]) { guard workspaceURL != nil, hasProviders else { return } let quiet = activeRequests > 0 || metadataQuietUntil.map { now() < $0 } == true let byDepth = roots.sorted { $0.url.path.count > $1.url.path.count } @@ -417,6 +434,42 @@ final class VcsProvidersFeatureModel: ObservableObject { } affected.insert(root.id) } + schedule(roots: affected, detect: redetect) + } + + /// Detects again when a created entry, or the directory holding it, has + /// a marker but is no known root or ambiguous directory, as after a + /// checkout or an update that fetched an external. The markers are + /// looked up off the main thread. + private func detectNewRoots(near created: [URL]) { + guard let workspaceURL, !created.isEmpty, !markerNames.isEmpty else { return } + let known = Set((allRootIDs + ambiguous.map(\.id)).map { Self.rootURL(id: $0, workspaceURL: workspaceURL).path }) + let workspacePath = workspaceURL.path + var directories: Set = [] + for url in created { + let url = url.standardizedFileURL + for path in [url.path, url.deletingLastPathComponent().path] + where (path == workspacePath || path.hasPrefix(workspacePath + "/")) && !known.contains(path) { + directories.insert(path) + } + } + guard !directories.isEmpty else { return } + let markers = markerNames + let fileExists = fileExists + let generation = generation + Task { [weak self] in + let found = await Task.detached(priority: .utility) { + directories.contains { directory in + markers.contains { fileExists(URL(fileURLWithPath: directory).appendingPathComponent($0)) } + } + }.value + guard found, let self, self.generation == generation else { return } + self.schedule(roots: [], detect: true) + } + } + + /// Refreshes `roots`, or detects again, shortly after the last change. + private func schedule(roots affected: Set, detect redetect: Bool) { guard redetect || !affected.isEmpty else { return } scheduledRoots.formUnion(affected) scheduledDetection = scheduledDetection || redetect @@ -639,7 +692,10 @@ final class VcsProvidersFeatureModel: ObservableObject { let owners = await Task.detached(priority: .userInitiated) { try? planner.owners(roots: rootIDs, ambiguous: ambiguousIDs, paths: [sourcePath, destinationPath]) }.value - guard self.generation == generation, let owners, owners == [root.id, root.id] else { return false } + // An action that started meanwhile leaves the move to the disk, as + // one running before would, instead of reporting it as refused. + guard self.generation == generation, runningOperation == nil, + let owners, owners == [root.id, root.id] else { return false } return await perform(.move(from: from, to: to), in: root.id, title: String(localized: "Moving…"), as: VcsEmptyResult.self) != nil } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift index 4cd1460459..0b8d12a7ec 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift @@ -17,6 +17,6 @@ extension AppModel { } func handleVcsProviderFileChanges(_ changes: [WorkspaceFileChange]) { - vcsProvidersFeature?.workspaceFilesChanged(changes.map(\.fileURL)) + vcsProvidersFeature?.workspaceFilesChanged(changes) } } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 2486dd31c2..d9ec4bd159 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -796,6 +796,7 @@ final class MacServiceContainer { providers: vcsProviderSessions, mappingStore: MacVcsRootMappingStore(store: store), readFile: { url in try? fileStorage.readData(from: url, options: .mappedIfSafe) }, + fileExists: { url in fileStorage.fileExists(at: url) }, metadataWatcherFactory: MacDirectoryWatcherFactory(), sources: { // Enablement is read on every detection, so turning a diff --git a/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift index 7560275235..0bbb584f51 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift @@ -318,19 +318,26 @@ final class MacVcsProviderSessions: VcsProviderRequesting { } /// The VCS client the provider runs, such as `svn`, found like any other - /// command. The provider itself reports a client that is too old. + /// command. The provider itself reports a client that is too old; one + /// that cannot be found is `vcs.tool.unavailable`, as the contract names it. private func resolveTool( _ provider: MacPluginLanguageServerLaunchResolver.VcsProviderProcess, workspaceURL: URL ) async throws -> String? { guard !provider.toolCommands.isEmpty else { return nil } - let resolved = try await tools.resolve( - BuildInvocation(executables: provider.toolCommands.map { .command($0) }), - displayName: provider.toolCommands[0], - workspaceURL: workspaceURL, - checkVersion: { _, _ in BuildVersionCheck(satisfied: true, version: nil) } - ) - return resolved.executableURL.path + do { + let resolved = try await tools.resolve( + BuildInvocation(executables: provider.toolCommands.map { .command($0) }), + displayName: provider.toolCommands[0], + workspaceURL: workspaceURL, + checkVersion: { _, _ in BuildVersionCheck(satisfied: true, version: nil) } + ) + return resolved.executableURL.path + } catch is CancellationError { + throw CancellationError() + } catch { + throw VcsProviderFailure(code: "vcs.tool.unavailable", message: error.localizedDescription) + } } private static func seconds(_ duration: Duration) -> TimeInterval { diff --git a/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift index bb683141d8..4be0a91cfe 100644 --- a/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift +++ b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift @@ -510,7 +510,7 @@ struct VcsProviderToolWindowView: View { .accessibilityLabel(Text(verbatim: "\(change.path), \(change.status.title)")) .accessibilityAddTraits(isSelected ? .isSelected : []) .accessibilityAction { select(change) } - .accessibilityAction(named: Text(Self.opensDiff(change, in: root) ? "Show Diff" : "Open File")) { + .accessibilityAction(named: Text(root.comparesWithBase(change) ? "Show Diff" : "Open File")) { open(change, in: root) } .accessibilityAction(named: Text("Include in commit")) { @@ -537,21 +537,17 @@ struct VcsProviderToolWindowView: View { } } - /// Double-click compares with the base when there is one; a new or - /// deleted file has nothing to compare, so it opens or does nothing. + /// Double-click compares with the base when there is one; a new file + /// has nothing to compare, so it opens. private func open(_ change: VcsChange, in root: VcsRootState) { selection = [change.path] - if Self.opensDiff(change, in: root) { + if root.comparesWithBase(change) { showDiff(change, in: root) } else if ![.deleted, .missing].contains(change.status) { actions.openFile(root.url.appendingPathComponent(change.path)) } } - static func opensDiff(_ change: VcsChange, in root: VcsRootState) -> Bool { - root.supports(.baseContent) && ![.unversioned, .added, .copied].contains(change.status) - } - private func contextMenu(for change: VcsChange, root: VcsRootState) -> [LitheContextMenuItem] { let selected = selectedChanges(root) let url = root.url.appendingPathComponent(change.path) diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index b8d3076b15..502958f661 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -541,7 +541,7 @@ private final class ProjectTreeActions: @unchecked Sendable { @MainActor func vcsDiffMenuItem(_ url: URL) -> LitheContextMenuItem? { guard let feature = model.vcsProvidersFeature, let (root, change) = feature.change(at: url), - root.supports(.baseContent), ![.unversioned, .ignored, .added].contains(change.status) else { return nil } + root.comparesWithBase(change) else { return nil } return .action(String(format: String(localized: "Show %@ Diff"), root.displayName), localizesTitle: false) { feature.showDiff(change, in: root.id) } diff --git a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift index 157b0d4283..1f1ad6caa4 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift @@ -1,8 +1,10 @@ import Foundation package struct FileVisibilityRules: Hashable, Sendable { + /// VCS metadata (`.git`, `.svn`) is hidden like IDEA's default ignore + /// list; providers watch their own metadata directories. package static let builtInHiddenDirectories = [ - ".git", ".worktree", ".worktrees", ".build", ".swiftpm", "node_modules", "target", "build", + ".git", ".svn", ".worktree", ".worktrees", ".build", ".swiftpm", "node_modules", "target", "build", "DerivedData", ".gradle", ".next", "dist", "coverage", "design-qa-artifacts", ".artifacts" ] /// Names and globs omitted from the project tree, search index, watchers, diff --git a/macos/Tests/LitheTests/VcsProviderHostTests.swift b/macos/Tests/LitheTests/VcsProviderHostTests.swift index fe1e9667d9..6121e465ed 100644 --- a/macos/Tests/LitheTests/VcsProviderHostTests.swift +++ b/macos/Tests/LitheTests/VcsProviderHostTests.swift @@ -55,7 +55,8 @@ struct VcsProviderHostTests { // Metadata the provider did not declare never refreshes; a working // file does. Both arrive before the delay, so one refresh covers them. - fixture.feature.workspaceFilesChanged([fixture.url(".svn/tmp/x"), fixture.url("module/File.java")]) + fixture.metadataWatchers.deliver([fixture.url(".svn/tmp/x")]) + fixture.changed("module/File.java") #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 2 }) #expect(fixture.providers.statusCount(root: workspace) == 1) @@ -63,28 +64,24 @@ struct VcsProviderHostTests { // for a short while, working files still count. #expect(await fixture.feature.cleanup("module")) #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 3 }) - fixture.feature.workspaceFilesChanged([fixture.url(".svn/wc.db"), fixture.url("module/Other.java")]) + fixture.metadataWatchers.deliver([fixture.url(".svn/wc.db")]) + fixture.changed("module/Other.java") #expect(await fixture.settled { fixture.providers.statusCount(root: module) == 4 }) #expect(fixture.providers.statusCount(root: workspace) == 1) // Once the quiet period ends, the declared metadata path refreshes // its root, as an update from the command line would. fixture.clock.advance(by: .seconds(3)) - fixture.feature.workspaceFilesChanged([fixture.url(".svn/wc.db")]) + fixture.metadataWatchers.deliver([fixture.url(".svn/wc.db")]) #expect(await fixture.settled { fixture.providers.statusCount(root: workspace) == 2 }) #expect(fixture.providers.statusCount(root: module) == 4) - - // A marker directory appearing detects the roots again. - fixture.clock.advance(by: .seconds(3)) - fixture.feature.workspaceFilesChanged([fixture.url("plugins/.svn")]) - #expect(await fixture.settled { fixture.planner.detectCount == 2 }) } - /// The workspace watcher never sees `../.svn` of a working copy that - /// encloses the workspace, so that metadata directory is watched on its - /// own while the enclosing root is detected. + /// The workspace watcher hides VCS metadata as the project tree does, + /// and never sees `../.svn` of a working copy that encloses the + /// workspace, so every root's metadata directory is watched on its own. @Test - func watchesTheMetadataOfAWorkingCopyThatEnclosesTheWorkspace() async throws { + func watchesTheMetadataDirectoryOfEveryRoot() async throws { let fixture = try VcsFeatureFixture( workspaceURL: URL(fileURLWithPath: "/virtual/wc/app", isDirectory: true), roots: [("..", "svn"), ("lib", "svn")] @@ -92,27 +89,55 @@ struct VcsProviderHostTests { defer { fixture.close() } fixture.providers.replies["status"] = try VcsTestData.statusReply([]) let enclosing = fixture.workspaceURL.appendingPathComponent("..").path + let lib = fixture.url("lib").path let metadata = URL(fileURLWithPath: "/virtual/wc/.svn", isDirectory: true) #expect(await fixture.open()) - // `lib/.svn` is inside the workspace, which the workspace watcher covers. - #expect(fixture.metadataWatchers.running == [metadata.path]) + #expect(fixture.metadataWatchers.running == [metadata.path, fixture.url("lib/.svn").path]) #expect(fixture.providers.statusCount(root: enclosing) == 1) fixture.clock.advance(by: .seconds(3)) fixture.metadataWatchers.deliver([metadata.appendingPathComponent("wc.db")]) #expect(await fixture.settled { fixture.providers.statusCount(root: enclosing) == 2 }) - #expect(fixture.providers.statusCount(root: fixture.url("lib").path) == 1) + #expect(fixture.providers.statusCount(root: lib) == 1) + fixture.metadataWatchers.deliver([fixture.url("lib/.svn/wc.db")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: lib) == 2 }) + #expect(fixture.providers.statusCount(root: enclosing) == 2) // A watch that lost its events detects the roots again. fixture.clock.advance(by: .seconds(3)) fixture.metadataWatchers.deliver(DirectoryChangeBatch(requiresFullRescan: true)) #expect(await fixture.settled { fixture.planner.detectCount == 2 }) - #expect(fixture.metadataWatchers.running == [metadata.path]) + #expect(fixture.metadataWatchers.running == [metadata.path, fixture.url("lib/.svn").path]) fixture.close() #expect(fixture.metadataWatchers.running.isEmpty) } + /// The workspace watcher hides `.svn`, so a working copy checked out in + /// the workspace shows only as created entries beside its marker. + @Test + func detectsAWorkingCopyThatAppearsInTheWorkspace() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([]) + fixture.files.add(fixture.url(".svn"), fixture.url("plugins/.svn")) + #expect(await fixture.open()) + fixture.clock.advance(by: .seconds(3)) + + // A created entry and the directory holding it are looked up unless + // they are a known root; a changed file is not looked up at all. + let lookedUp = fixture.files.gate(afterLookups: 3) + fixture.changed("plugins/Plugin.java") + fixture.created("README.md", "src/Main.java") + #expect(await lookedUp.waitUntilOpen(timeout: .seconds(5))) + #expect(Set(fixture.files.lookups) + == Set(["README.md/.svn", "src/Main.java/.svn", "src/.svn"].map { fixture.url($0).path })) + + fixture.created("plugins/Plugin.java") + #expect(await fixture.settled { fixture.planner.detectCount == 2 }) + #expect(!fixture.files.lookups.contains(fixture.url(".svn").path)) + } + @Test func runsOneWorkingCopyActionAtATime() async throws { let fixture = try VcsFeatureFixture(roots: [("", "svn")]) @@ -175,6 +200,43 @@ struct VcsProviderHostTests { #expect(feature.failure?.message == "E155010") } + /// An action that starts while a move asks Core who owns its paths + /// leaves the move to the disk, as one running before would, instead of + /// reporting the move as refused. + @Test + func moveYieldsToAnActionThatStartsWhileOwnershipIsChecked() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("a.txt", "modified")]) + fixture.providers.replies["checkin"] = try VcsTestData.json(["revision": ["id": "8", "display": "r8", "kind": "number"]]) + #expect(await fixture.open()) + let ownersEntered = TestGate() + let ownersReleased = TestGate() + let checkinHeld = TestGate() + fixture.planner.heldOwners = (paths: ["b.txt"], entered: ownersEntered, released: ownersReleased) + fixture.providers.holds["checkin"] = checkinHeld + defer { + ownersReleased.open() + checkinHeld.open() + } + + let feature = fixture.feature + let source = fixture.url("b.txt") + let destination = fixture.url("docs/b.txt") + let move = Task { await feature.move(from: source, to: destination) } + #expect(await ownersEntered.waitUntilOpen(timeout: .seconds(5))) + let commit = Task { await feature.checkin(["a.txt"], message: "Fix", in: "") } + #expect(await awaitChange(on: feature) { feature.runningOperation?.rootID == "" }) + ownersReleased.open() + + #expect(await move.value == false) + #expect(feature.failure == nil) + checkinHeld.open() + #expect(await commit.value?.revision?.display == "r8") + #expect(!fixture.providers.requests.contains { $0.operation.name == "fileOperation" }) + #expect(!fixture.planner.ownersTimedOut) + } + @Test func remembersTheChosenVcsPerWorkspaceAndForgetsChoicesNoLongerNeeded() async throws { let fixture = try VcsFeatureFixture(roots: [("", "svn")], ambiguous: ["mixed"]) @@ -335,6 +397,53 @@ struct VcsProviderHostTests { } } + /// A VCS client that cannot be found is the contract's + /// `vcs.tool.unavailable`, so the tool window leads with its install hint. + @Test + func aVcsClientThatCannotBeFoundIsReportedAsUnavailable() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-vcs-tool-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let packageURL = root.appendingPathComponent("package", isDirectory: true) + let workspaceURL = root.appendingPathComponent("workspace", isDirectory: true) + try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) + let object = try #require( + try JSONSerialization.jsonObject(with: VcsTestData.svnProviderManifest()) as? [String: Any] + ) + _ = try DeclarativePluginTestSupport.writePackage(at: packageURL, manifest: object) + let entry = packageURL.appendingPathComponent("provider/svn-provider.mjs") + try FileManager.default.createDirectory(at: entry.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data().write(to: entry) + let operations = DeclarativePluginTestSupport.operations + let descriptor = try operations.validate(manifest: Data(contentsOf: packageURL.appendingPathComponent("plugin.json"))) + let sessions = MacVcsProviderSessions( + launchResolver: MacPluginLanguageServerLaunchResolver( + planner: operations, + directories: MacPluginStateDirectories(rootURL: root.appendingPathComponent("state", isDirectory: true)), + settings: InMemoryPluginSettings() + ), + tools: VcsTestTools(), + versionChecker: operations, + runtime: VcsTestToolRuntime(), + core: RustCoreBridge(), + processRegistry: nil, + endBackendProcesses: { _, _ in } + ) + let target = VcsProviderTarget( + pluginID: descriptor.id.rawValue, vcs: "svn", manifest: descriptor.normalizedManifest, + packageURL: packageURL, workspaceURL: workspaceURL + ) + defer { sessions.retainProviders { _ in false } } + + do { + _ = try await sessions.negotiation(for: target) + Issue.record("The provider started without its VCS client") + } catch let failure as VcsProviderFailure { + #expect(failure.code == "vcs.tool.unavailable") + #expect(failure.message == VcsTestTools.NotFound().localizedDescription) + } + } + @Test func installReviewShowsTheProviderCommandClientAndNetworkUse() throws { let review = try PluginMarketplaceTestSupport.evaluator.installReview( @@ -523,6 +632,8 @@ private final class FakeVcsPlanner: VcsRootPlanning, @unchecked Sendable { private var storedDetection: VcsDetection private var detections = 0 private var mappings: [VcsRootMapping] = [] + private var hold: (paths: Set, entered: TestGate, released: TestGate)? + private var holdTimedOut = false init(detection: VcsDetection) { storedDetection = detection @@ -535,6 +646,14 @@ private final class FakeVcsPlanner: VcsRootPlanning, @unchecked Sendable { var detectCount: Int { lock.withLock { detections } } var lastMappings: [VcsRootMapping] { lock.withLock { mappings } } + var ownersTimedOut: Bool { lock.withLock { holdTimedOut } } + + /// Ownership asked for any of `paths` opens `entered`, then answers once + /// `released` opens. + var heldOwners: (paths: Set, entered: TestGate, released: TestGate)? { + get { lock.withLock { hold } } + set { lock.withLock { hold = newValue } } + } func detectRoots(workspaceURL: URL, manifests: [Data], mappings: [VcsRootMapping]) throws -> VcsDetection { lock.withLock { @@ -545,6 +664,12 @@ private final class FakeVcsPlanner: VcsRootPlanning, @unchecked Sendable { } func owners(roots: [String], ambiguous: [String], paths: [String]) throws -> [String?] { + if let hold = heldOwners, paths.contains(where: hold.paths.contains) { + hold.entered.open() + // This synchronous protocol runs on the feature's detached ownership task. + let released = hold.released.waitSynchronously(timeout: 5) + lock.withLock { holdTimedOut = holdTimedOut || !released } + } func contains(_ id: String, _ path: String) -> Bool { id.isEmpty || id.hasPrefix("..") || path == id || path.hasPrefix(id + "/") } @@ -654,8 +779,12 @@ private final class FakeMetadataWatchers: DirectoryWatcherFactory, @unchecked Se return watcher } + /// Delivers each path to the running watcher of the directory holding it. func deliver(_ urls: [URL]) { - deliver(DirectoryChangeBatch(workspacePaths: urls.map(\.path))) + for watcher in watchers where watcher.isRunning { + let paths = urls.map(\.path).filter { $0.hasPrefix(watcher.directory.path + "/") } + if !paths.isEmpty { watcher.onChange(DirectoryChangeBatch(workspacePaths: paths)) } + } } func deliver(_ batch: DirectoryChangeBatch) { @@ -671,6 +800,7 @@ private final class VcsFeatureFixture { let planner: FakeVcsPlanner let providers = FakeVcsProviders() let metadataWatchers = FakeMetadataWatchers() + let files = VcsTestFiles() let keyValues = VcsTestKeyValueStore() let clock = VcsTestClock() let feature: VcsProvidersFeatureModel @@ -685,10 +815,12 @@ private final class VcsFeatureFixture { roots: roots, workspaceURL: self.workspaceURL, ambiguous: ambiguous )) let clock = clock + let files = files feature = VcsProvidersFeatureModel( planner: planner, providers: providers, mappingStore: MacVcsRootMappingStore(store: keyValues), + fileExists: { files.exists($0) }, sleep: { _ in try Task.checkCancellation() }, now: { clock.now }, metadataWatcherFactory: metadataWatchers, @@ -706,6 +838,16 @@ private final class VcsFeatureFixture { workspaceURL.appendingPathComponent(path).standardizedFileURL } + /// Reports changed workspace entries, as the workspace watcher does. + func changed(_ paths: String...) { + feature.workspaceFilesChanged(paths.map { WorkspaceFileChange(fileURL: url($0), kind: .changed) }) + } + + /// Reports created workspace entries, as the workspace watcher does. + func created(_ paths: String...) { + feature.workspaceFilesChanged(paths.map { WorkspaceFileChange(fileURL: url($0), kind: .created) }) + } + /// Opens the workspace and waits until every root has read its status. func open() async -> Bool { feature.reset(workspaceURL: workspaceURL) @@ -727,6 +869,66 @@ private final class VcsFeatureFixture { } } +/// Finds the provider's runtime but no VCS client. +private final class VcsTestTools: BuildToolResolving { + struct NotFound: LocalizedError { + var errorDescription: String? { "svn was not found." } + } + + func resolve( + _ invocation: BuildInvocation, displayName _: String, workspaceURL _: URL, + checkVersion _: @escaping @Sendable (String?, String) throws -> BuildVersionCheck + ) async throws -> ResolvedBuildTool { + guard invocation.executables == [.runtime("node")] else { throw NotFound() } + return ResolvedBuildTool( + executableURL: URL(fileURLWithPath: "/usr/bin/false"), environment: [:], executesProjectCode: false, version: nil + ) + } +} + +private final class VcsTestToolRuntime: LanguageToolRuntimePort { + func executableOnPath(_: String) -> URL? { nil } + func executableURL(at _: String) -> URL? { nil } + func executableCandidates(_: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment(runtimeTools _: [String]) -> [String: String] { [:] } + func missingLanguageToolMessage(_ name: String) -> String { name } +} + +/// The paths a marker lookup finds, and the lookups made. +private final class VcsTestFiles: @unchecked Sendable { + private let lock = NSLock() + private var existing: Set = [] + private var recorded: [String] = [] + private var waiter: (count: Int, gate: TestGate)? + + var lookups: [String] { lock.withLock { recorded } } + + func add(_ urls: URL...) { + lock.withLock { existing.formUnion(urls.map(\.path)) } + } + + /// A gate that opens once `count` lookups have been made. + func gate(afterLookups count: Int) -> TestGate { + let gate = TestGate() + let reached = lock.withLock { + waiter = (count, gate) + return recorded.count >= count + } + if reached { gate.open() } + return gate + } + + func exists(_ url: URL) -> Bool { + let (found, gate) = lock.withLock { () -> (Bool, TestGate?) in + recorded.append(url.path) + let gate = waiter.flatMap { recorded.count >= $0.count ? $0.gate : nil } + return (existing.contains(url.path), gate) + } + gate?.open() + return found + } +} + private final class VcsTestClock: @unchecked Sendable { private let lock = NSLock() private var instant = ContinuousClock.now diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index 33f3d28e7b..c9eaed5ac5 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -17,6 +17,7 @@ use std::path::{Path, PathBuf}; /// Default workspace exclusions, also used by Git discovery for generated directories. pub(crate) const BUILT_IN_HIDDEN_DIRECTORIES: &[&str] = &[ ".git", + ".svn", ".worktree", ".worktrees", ".build", @@ -439,7 +440,7 @@ pub fn search_everywhere(request: SearchRequest) -> Result