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 22e9e85898..06dc242628 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 bf907e0b8a..79bf7fd3f9 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([lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) 的 `sdk/node`)编写,以 `runtime` 方式启动(`tool: node`)。SDK 已经负责线协议、协商、取消、子进程清理和错误映射,Cargo 社区插件也走这条路;同一份后端在 macOS 和 Windows 上都能运行,插件包里没有原生二进制,不涉及按架构编译和 Gatekeeper。代价是用户除了 `svn` 还需要 Node 20.3 以上,安装审查和缺少运行时的提示要说清楚。SDK 新增 `litheVcs` 支持,和 `litheBuild` 并列;2026-10-23 起 SDK 不在主仓库,这部分改动在 lithe-plugin-docs 提交(见[插件契约、SDK 与开发者资料的发布](../../implemented/architecture/2026-10-23-plugin-contract-publishing.md))。 @@ -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,35 @@ 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,端到端场景经真实 Core 会话跑通它的协商和全部 11 种操作。SDK、示例和端到端场景随 SDK 迁到 lithe-plugin-docs,在那里用 `scripts/sdk-e2e.mjs --ref <本分支>` 运行;主仓库只保留契约、fixtures 和 Core 测试。 +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 和 Core 测试,以及宿主测试用的 `shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json`(只有清单,没有实现)。样例 Provider `sample-vcs` 和它的端到端场景随 SDK 在 lithe-plugin-docs。 + +正确做法:主仓库改 VCS 契约或 Core 时,用 fixtures 和 Core 测试验证,合并前在 lithe-plugin-docs 用 `scripts/sdk-e2e.mjs --ref <分支>` 跑样例 Provider;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 自己的运行时一样经构建工具发现查找,找不到时报契约的 `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 万行时退成左右两栏纯文本,避免逐行比较卡住界面。有没有基线由 `VcsRootState.comparesWithBase` 一处判断,工具窗口双击、辅助功能动作和项目树右键共用:新增、未版本化、忽略的文件没有基线,双击直接打开;复制的文件和复制来源比较。 +- 项目树颜色:插件根的状态通过一个只观察 `VcsProvidersFeatureModel` 的包装视图传给项目树,项目树本身仍是 `.equatable()`,刷新状态不重绘整棵树。同一文件 Git 和插件都有状态时用 Git 的,因为第一期 Git 不在统一层,嵌套时以 Git 的显示为准,不让两套颜色互相覆盖。 +- 文件操作跟踪:工作区模块只多一个注入的 `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` 上的提交。 + ### 10. Git 什么时候、怎样接入 Git 接入不在本提案的范围内,这里只记下评审时已经确认的方向,供以后的提案使用: @@ -161,7 +190,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 +207,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 +215,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 插件即回到现状。 @@ -192,11 +225,15 @@ Git 接入不在本提案的范围内,这里只记下评审时已经确认的 - `rust/lithe-core/src/vcs/` - `rust/lithe-core/src/plugins/` - `rust/lithe-core/src/lsp/interface/` +- [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 0f69c20fe2..25c8bb4887 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -47,6 +47,18 @@ 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, +its manifest fixture, and the Core tests; the SDK's sample provider +(`sample-vcs`) lives with the SDK in +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs). + ## 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 41c899e7d3..0d6f203a94 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3716,6 +3716,76 @@ /* 内嵌 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…" = "正在识别工作副本…"; +"Reading changes…" = "正在读取更改…"; +"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 后即可使用 %@。"; "Instructions" = "指令"; "Global instructions" = "全局指令"; "Instruction Agent" = "指令所属 Agent"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index d96fd7e853..7819b18fc8 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -21,6 +21,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..67f778377d --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift @@ -0,0 +1,862 @@ +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 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 || isEnclosing ? url.lastPathComponent : root.id + } + + /// Whether the root encloses the workspace (`..`, `../..`). Core's rule: + /// only IDs made entirely of `..` segments, so a folder inside the + /// workspace may still be named `..repo`. + var isEnclosing: Bool { Self.isEnclosingRootID(root.id) } + + nonisolated static func isEnclosingRootID(_ id: String) -> Bool { + !id.isEmpty && id.split(separator: "/", omittingEmptySubsequences: false).allSatisfy { $0 == ".." } + } + + 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. +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 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 the roots' metadata directories, 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 = [] + 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 }, + 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, + sources: @escaping @MainActor () -> [VcsProviderPluginSource] + ) { + self.planner = planner + self.providers = providers + self.mappingStore = mappingStore + self.readFile = readFile + self.fileExists = fileExists + self.sleep = sleep + self.now = now + self.metadataWatcherFactory = metadataWatcherFactory + 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. 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() } + 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 + watchMetadata() + 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() { + 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) + } + watchMetadata() + 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 + + /// 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 { + 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?.metadataChanged(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 metadataChanged(_ batch: DirectoryChangeBatch, in directory: URL) { + guard metadataWatchers[directory.path] != nil else { return } + 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 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 } + 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) + } + 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 + 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 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), + 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 + // 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 + } + + 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 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 + } + + 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 VcsRootState.isEnclosingRootID(rootID) 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..0b8d12a7ec --- /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) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 85a44c8256..d9e0808a35 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -711,6 +711,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() @@ -956,6 +959,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { tests: languageTestServiceIfActive, buildSystems: buildSystemsFeatureIfActive ) + vcsProvidersFeature?.reset(workspaceURL: nil) runWorkflowCoordinator.resetPendingAction() debugModuleCoordinator.resetFeature(genericDebugFeatureIfActive) debugBreakpointPresentation.reset() @@ -1022,6 +1026,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 7f7109edb7..1965db1d0b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -269,6 +269,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 @@ -676,6 +686,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( @@ -790,6 +801,34 @@ 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) }, + fileExists: { url in fileStorage.fileExists(at: url) }, + metadataWatcherFactory: MacDirectoryWatcherFactory(), + 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 @@ -879,10 +918,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..0bbb584f51 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift @@ -0,0 +1,347 @@ +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; 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 } + 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 { + 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 0a65b1d76e..4ce0dbb736 100644 --- a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift +++ b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift @@ -830,6 +830,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 556afae647..9c6fbcc9ce 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -327,6 +327,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..1673ce3fb6 --- /dev/null +++ b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift @@ -0,0 +1,730 @@ +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 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) + .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] } + }) + // 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(root.comparesWithBase(change) ? "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 { + 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 file + /// has nothing to compare, so it opens. + private func open(_ change: VcsChange, in root: VcsRootState) { + selection = [change.path] + if root.comparesWithBase(change) { + 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) + .padding(.trailing, LitheTheme.Commit.contentInset) + .padding(.bottom, LitheTheme.ActionButton.spacing + 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 19b122df17..7b69b515b9 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.comparesWithBase(change) 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..4df9e73d73 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Vcs/VcsProviderContracts.swift @@ -0,0 +1,438 @@ +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 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, 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 }, uiIcon: icon + ) + ] + } +} + +/// 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/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/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 2366cb11ad..2f6d3033f9 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..7a89559bb1 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 contract's + // manifest fixture 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("shared/contracts/plugins/v1/fixtures/vcs-provider.plugin.json")) + let sample = try DeclarativePluginTestSupport.operations.validate(manifest: manifest) + #expect(sample.vcsProviders == [DeclarativeVcsProvider(id: "svn", displayName: "Subversion")]) + #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..bcbd43615a --- /dev/null +++ b/macos/Tests/LitheTests/VcsProviderHostTests.swift @@ -0,0 +1,975 @@ +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.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) + + // 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.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.metadataWatchers.deliver([fixture.url(".svn/wc.db")]) + #expect(await fixture.settled { fixture.providers.statusCount(root: workspace) == 2 }) + #expect(fixture.providers.statusCount(root: module) == 4) + } + + /// 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 watchesTheMetadataDirectoryOfEveryRoot() 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 lib = fixture.url("lib").path + let metadata = URL(fileURLWithPath: "/virtual/wc/.svn", isDirectory: true) + #expect(await fixture.open()) + #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: lib) == 1) + // That status read starts the host's quiet period for every root. + fixture.clock.advance(by: .seconds(3)) + 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, 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")]) + 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"), ("lib", "git")], ambiguous: ["mixed"] + ) + 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) + // 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. + 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") + } + + /// 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"]) + 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) + // Core's rule: only IDs made of `..` segments enclose the workspace; + // a folder inside it named `..repo` is an ordinary nested root. + #expect(VcsProvidersFeatureModel.workspacePath("a.txt", rootID: "..repo", workspaceURL: workspace) == "..repo/a.txt") + #expect(VcsRootState.isEnclosingRootID("..") && VcsRootState.isEnclosingRootID("../..")) + #expect(!VcsRootState.isEnclosingRootID("..repo") && !VcsRootState.isEnclosingRootID("../..repo") + && !VcsRootState.isEnclosingRootID("")) + } + + // 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"]) + } + + /// 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([ + 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") + } + } + + /// 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/main.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( + 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("provider/main.mjs") == true, "\(lines)") + #expect(lines.dropFirst() == ["Client: svn", "May connect to the repository server"]) + } +} + +/// 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 VcsProviderAppModelTests { + /// A tracked file goes to the provider, an unversioned one stays a plain + /// disk move. + @Test + func projectTreeMovesGoThroughTheProviderThatTracksTheFile() async throws { + 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")) + 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 model = Self.makeModel(fixture) + 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) + } + + /// 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 + +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/vcs-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] = [] + private var hold: (paths: Set, entered: TestGate, released: TestGate)? + private var holdTimedOut = false + + 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 } } + 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 { + detections += 1 + self.mappings = mappings + return storedDetection + } + } + + 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 + "/") + } + 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 + } + } + + 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 + /// 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 + } + + 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)) + running.insert(target) + 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 + running = running.filter(keep) + } + + private static func negotiation(_ vcs: String) -> VcsProviderNegotiation { + VcsProviderNegotiation( + vcs: vcs, capabilities: Dictionary(uniqueKeysWithValues: VcsCapability.allCases.map { ($0.rawValue, 1) }) + ) + } +} + +/// 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 + } + + /// Delivers each path to the running watcher of the directory holding it. + func deliver(_ urls: [URL]) { + 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) { + 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 +private final class VcsFeatureFixture { + let workspaceURL: URL + let planner: FakeVcsPlanner + let providers = FakeVcsProviders() + let metadataWatchers = FakeMetadataWatchers() + let files = VcsTestFiles() + 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 + 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, + 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 + } + + /// 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) + 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) + } +} + +/// 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 + + 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/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