diff --git a/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md b/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md index 42ec48023b..e862dfde0b 100644 --- a/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md +++ b/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md @@ -64,6 +64,45 @@ macOS 使用独立进程组;Windows 在恢复挂起进程前把它加入作业 完整行解码后才脱敏;过长行整行省略并标记,解析所用原始输出超限则显式失败, 不能把截断内容继续当作完整 Git 结果解析。 +### macOS 文件操作先发布本仓库状态,再恢复监听 + +GitService 的磁盘和命令工作由后台任务执行,界面状态由主线程模型发布。操作期间,工作区暂缓处理 +文件监听事件,避免把 Git 中间状态送给项目树和项目服务;嵌套操作共用计数,只有最外层结束才恢复。 + +暂存、取消暂存、删除和回滚完成后,在恢复监听之前重读本次涉及的仓库,只替换已成功确认的根。 +Commit 的行、勾选和对应 Worktree 状态先更新,Diff 再按已确认的根刷新;不重新发现无关仓库, +也不等待 Stash、历史或项目服务。后续文件监听沿用 WorkspaceFeatureModel 自己持有的合并任务, +仍更新项目树、搜索和项目服务;工作区关闭时取消这些任务,不另建脱离生命周期的刷新。 + +每根状态请求有独立身份,全工作区读取也检查状态版本。新操作开始和完成都会使较早结果失效, +同根操作以实际完成后的读取为准;工作区重置释放旧刷新等待者,旧任务不得结束或发布到新一轮刷新。 +读不到状态时保留已知行与未确认的勾选,并显示本地化错误,不把“查询失败”当作“仓库干净”。 +局部刷新只能清理对应根已经消失的目录展开状态,不能折叠其他仓库;Worktree 历史窗口和 cursor 保持不变。 +同一批涉及多个根时,一个根被较新请求替代,仍须继续确认其他根。父模型与固定 checkout 模型各自维护展开状态; +等待子模型发布后,要按父模型此刻的展开集合列出内容,不能重新插回用户已经折叠的子项。 + +正确做法:回滚 A 仓库的文件后先确认 A 的状态,B 仓库即使状态读取很慢,也不能延迟 A 的行更新。 +不要这样做:解冻后先等待项目重载和全部仓库扫描,再调用一次全量刷新才移除 A 的行。 +这一顺序改善等待关系,不保证外部 Git 客户端与 Lithe 写入构成原子事务,也不能据组件测试宣称实机延迟数值。 +对应回归见 `GitModuleTests` 的本地操作/竞争请求测试,以及 `LitheCoreLogicTests` 的嵌套解冻、延后刷新和重置测试; +真实磁盘与索引结果由 `GitStatusObservationTests` 使用临时仓库验证,不对用户仓库做破坏性复现。 + +### 打开的提交面板独立处理外部文件变化 + +外部新增、修改和删除文件时,Git 状态不能排在项目树、搜索索引或项目服务重载之后。 +WorkspaceFeatureModel 为同一批文件事件分别调度 Git 和项目内容的合并刷新任务;两者各自取消与合并, +后续文件事件即使取消了尚未完成的项目重载,也不能丢失这批事件的 Git 刷新。Git 写操作结束后的 +非等待式恢复同样调度两项任务,而不是因为存在项目路径就跳过 Git。 + +需要重新发现监听目录或完整扫描时,先确认监听配置和快照,再读取 Git,最后准备项目服务。 +尚无快照的恢复批次由重建流程负责这次 Git 读取,避免在重建前后重复查询同一批状态。 +在途 Git 刷新用独立请求身份和工作区代次标识所有者:旧工作区任务结束不能释放新工作区的刷新, +取消期间积累的请求仍由当前工作区持有的任务补读。关闭工作区或卸载模块必须取消这些任务。 + +正确做法:`pom.xml` 变化引起慢速项目导入时,Commit 仍能收到这次及后续源码编辑的状态。 +不要这样做:只把 Git 子进程放到后台,却继续在项目重载返回后才调用 Git 刷新。 +分开调度增加了一条有所有者的合并任务,但不引入额外 watcher 或定时轮询,也不保证两项视图在同一帧更新。 + ### 配置按来源解释,只在明确保存时持久化 可执行文件解析、能力探测和临时策略集中在共享执行入口。临时配置固定解析 @@ -195,6 +234,8 @@ stdout/stderr 分开,复制原始输出时不混入提示。没有完整变化 保留内存诊断历史,配置的持久化与日志分开。 - 所有 mutation 后的读取都使用独立清理期限:能忽略已取消请求以确认结果, 但会截短正常请求的剩余预算并屏蔽其取消;因此只为已经结束的请求使用。 +- 把普通文件事件的 Git 刷新留在项目重载尾部:能少维护一条任务,但慢导入会阻塞已打开的提交面板, + 后续事件取消重载时还会丢失刷新,因此分别使用已有的 Git 与项目内容合并任务。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md index 28f5f72cba..f1a786f678 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md @@ -72,6 +72,10 @@ macOS 和 Windows 可以用不同的 UI 技术实现,但用户看到的交互 表格横向滚动处理,不挤掉提交说明。 - 如果尺寸需要在重启后保留,只在拖动结束时通过现有布局持久化机制保存 最终尺寸,不要在拖动过程中持续写入持久化存储。 +- macOS 首次打开、尚无已保存尺寸的工作区,左侧栏默认宽度为 360pt,上半区默认占可用高度的 + 72%,让中文 Commit 的“修订提交(上一次)”保持一行,并减少初始底部工具窗占用。 + 默认值由 `WorkbenchLayout` 统一提供,主布局、浮层与功能模型不得各留一份数字。 + 这是初始布局,不是新的最小尺寸;已有工作区宽高和用户拖动结果优先,不迁移或覆盖旧配置。 - Windows 编辑器标签栏的滚轮处理和活动标签可见性判断必须使用内层可滚动 列表,不能使用包含导航按钮的外层工具栏。滚动直接更新 DOM,不写入应用 状态;手动处理滚轮时使用可取消的原生事件监听,避免浏览器再次应用水平 @@ -211,6 +215,10 @@ IDEA 的 [BranchesTreeComponent](https://github.com/JetBrains/intellij-community 关闭后重新打开也会挂回。 - `GitGraphInteractionTests.splitPaneBounds` 在两种方向、两种位置及折叠状态 验证原生子视图身份、可见尺寸和传入的确定尺寸,防止恢复为堆栈尺寸探测。 +- `WorkbenchMavenLayoutTests.initialLayoutWidensTheSidebarWithoutOverwritingSavedDimensions` 验证新默认值及旧尺寸优先; + `CommitActionButtonStyleTests.defaultSidebarKeepsAmendOnOneLineBesideTheOtherActions` 验证中英文、明暗主题下的 + 单行高度、按钮对齐与互不重叠。带实际语言包、字体和图标的临时宿主另检查中文标签及原生截图, + 普通 SwiftPM 几何测试不代替完整工作台视觉验收。 - `LitheSplitPaneCollapseTests.outerDragSuspendsRowChrome` 使用原生分隔条 事件和 69 行嵌套分支样式,检查拖动时右键覆盖层消失、祖先状态传播、行 内容保持挂载、鼠标松开后覆盖层恢复。测试计时仅作诊断,不代表真实帧率。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md b/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md index ede17b03b0..f936eae464 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md +++ b/.agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md @@ -31,6 +31,13 @@ Git 的工作区、工作树、目录、分支、历史提交和 Stash/Shelf 比 所有仓库比较入口通过 `AppModel` 的共享标签动作进入 `EditorTabItem.repositoryDiff(UUID)`。 同一个仓库、文件和版本组合再次打开时切回已有标签;不同文件、不同提交以及不同类型的比较可以并存。 工作区文件身份不包含暂存状态,暂存或后台刷新不会额外产生标签。仓库路径参与身份,避免嵌套仓库同名文件互相覆盖。 +Commit 右键“在新标签页中显示差异”是明确例外:即使已存在同一文件的比较,也创建独立 ID 和会话。 +普通“显示差异”和单击仍按比较身份复用,不因新增入口破坏去重。 + +插件 VCS 的独立比较也使用同一编辑标签与分屏生命周期,但内容来自所属 Provider(版本控制插件后端), +不能为 SVN 等插件激活 Git 或把内容放入 Git 会话。先创建加载中的标签,再异步读取基线; +返回时只填充原标签,不抢回用户已经切走的焦点。标签所有者保存每个读取任务,关闭标签或工作区即取消并移除, +迟到的成功或失败不能复活标签或发布旧错误。隐藏的比较宿主继续保留滚动状态,但不接收命中或辅助功能访问。 每个标签由 `GitRepositoryDiffSession` 单独拥有比较内容、加载状态、文件列表、空白符选项和请求代次。 `GitFeatureModel` 仍共享 Git 服务、监听和写入工作流,不为标签复制整套功能模型。 @@ -78,6 +85,8 @@ Esc、来源卸载和取消均清理面板;无效落点不改布局。焦点 - 用 SwiftUI 位移移动整张标签:会受标签栏裁剪且参与连续布局;保留现有原生浮动预览。 - 一个可复用的 Diff 预览标签:不能同时对照多个文件与版本,用户明确要求多标签,因此拆分比较会话而不是复用一个缓冲区。 - 每标签复制整个 Git 功能模型:会重复仓库监听、服务和写入状态;只拆分比较内容和加载生命周期。 +- 插件比较另开一套覆盖层或借用 Git 的比较模型:会绕过普通标签清理或引入错误的仓库事实; + 复用标签身份和原生内容宿主,只由 Provider 返回比较数据。 ## 后果 @@ -96,6 +105,9 @@ Windows 的既有分栏实现不受此次 macOS 呈现改动影响,完整两 - `./scripts/agent-notes/verify-agent-notes.sh` - 原生事件检查右侧、底部半区、释放提交、Esc 取消、重排、关闭与焦点;实机预览另检查真实字体资源、代码输入、灰色浮动标签及分隔器调整。 - `EditorTabOrderFeatureModelTests` 对工作区、目录、分支/引用、历史、保存更改、冲突与行标记入口在深浅主题下挂载原生编辑分组,检查普通标签、切换保留、分屏与共享关闭;`GitModuleTests` 检查独立会话并发加载、关闭一个会话、刷新及迟到结果隔离。组件验证不替代完整产品与 Windows 实机验收。 +- `EditorTabOrderFeatureModelTests.explicitNewDiffTabKeepsAnIndependentSessionWhileOrdinaryOpenReusesItsPreview` + 验证同文件显式新标签与普通去重共存;`VcsProviderAppModelTests.providerDiffTabsOwnTheirPendingRequestsWithoutStealingFocus` + 用可控 Provider 覆盖返回、关闭标签、关闭工作区的时序与资源释放,不使用真实网络等待。 ## 适用范围 diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md b/.agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md index 3f6483424d..efb9fc32e9 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md +++ b/.agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md @@ -29,6 +29,9 @@ - 目录 diff 遇到无法导出的文件(例如名字含 `:`,路径校验会拒绝)时,整个 diff 报错,不能跳过。Shelve 保存补丁后会把整个目录清掉,跳过的文件就会被删掉而且没有备份。逐文件操作时,这类文件会被路径校验直接拒绝,所以以前不会出问题。 - macOS 的 Shelve 必须区分"读不到补丁"和"补丁为空":`workingDiffPatch` / `stagedDiffPatch` 读不到时返回 nil,任何一条读不到,整个搁置就中止,不保存也不清理。以前把读取失败当成空补丁跳过,接着照样清理这条变更。 - `discardAll` 能把 `dir/` 识别为未跟踪路径,用 `git clean` 删除,而不是对它执行 `git restore`。 +- `discardAll` 的状态查询只传本次路径,不能在每个回滚文件上重复执行全库 `--untracked-files=all`。 + 复用 porcelain v2 解析器消费重命名源路径的 NUL 记录,避免把以 `?? ` 开头的源文件名误当未跟踪文件。 + 查询和清理按现有路径参数预算分批,状态、恢复与清理都采用字面路径语义,保护方括号等特殊文件名的邻居。 **macOS 端:** @@ -39,6 +42,8 @@ - 逐条执行的写操作(Rollback、Shelve)先去掉同一批里被汇总覆盖的子项。否则同一个文件会在搁置补丁里写两次,恢复时 `git apply` 失败;或者汇总被清理后,子项的 `git restore` 报错并中断。 - 把汇总移到 ChangeList 后,ChangeList 只记录 `dir/` 这一个键,目录下的文件继承它的归属。Core 的提交范围检查(`PathScope`)只按字面路径比较,所以 `commitScope` 会把继承归属的文件逐个列出。否则在该 ChangeList 中提交会被拒,而默认列表的提交会把这些文件一起带走。 - 刷新在等待目录内容时,用户可能折叠或展开目录。列出结果后,如果展开集合变了,就按当前集合重建结果(已经读到的列表直接复用),不能用刷新开始时的旧集合覆盖用户的操作。只有本次读取一开始就请求过、但结果里已经没有的汇总,才会被忘掉。Windows 的 `listExpandedUntrackedDirectories` 遵循同样的规则。 +- macOS 文件操作后的局部状态刷新只确认本次仓库;其他根的展开集合和已列出的内容保持不变。 + 等待子项返回时还要检查状态请求身份,较早读取不能借清理展开集合来折叠较新状态。 - `GitChangeSectionsCache.rows` 把仓库标题、ChangeList 标题和每一条变更拍平成一个行数组,`ForEach` 直接放在 `LazyVStack` 中。 **Windows 端:** diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-lsp-project-usage-cancellation.md b/.agents/notes/implemented/bug-fix/2026-10-28-lsp-project-usage-cancellation.md index 6503ea62c1..4d8cfc57b4 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-28-lsp-project-usage-cancellation.md +++ b/.agents/notes/implemented/bug-fix/2026-10-28-lsp-project-usage-cancellation.md @@ -52,6 +52,8 @@ LSP 设置页离开或切换文件快照时,页面应立即结束等待,下 - `./scripts/macos/verify/verify-runtime-bundle-immutability.sh` - 用有界 gate 暂停并忽略旧扫描取消,验证新请求和旧消费方都能在释放 gate 前结束。 原生挂载测试保持同一个 LSP 页面,依次应用新增、重命名和删除后的快照。 +- 命令行测试没有持续的 AppKit 原生事件循环;挂载测试用单调时钟限定等待,驱动非阻塞绘制轮次, + 再检查页面任务实际生成的摘要。不能只等摘要通知,也不能依赖可选截图顺带触发绘制来通过测试。 ## 适用范围 diff --git a/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md index a46549b4ed..de08a793a7 100644 --- a/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md +++ b/.agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md @@ -19,6 +19,11 @@ macOS 和 Windows 都直接消费 Rust Core 的提交计划、依赖排序、失 - 已发现的独立嵌套仓库不会作为父仓库的未跟踪目录参与勾选,避免“全部暂存”意外创建子模块引用。比较前必须统一规范化 Git 返回的路径和平台传入的仓库绑定,兼容 macOS 目录别名及 Windows 短路径/扩展路径。 - Windows 的文件及仓库分组勾选也写入真实暂存区,取消原有仅限单仓库的提交入口。部分暂存文件的提交预览和 AI 说明只读取索引;工作树差异仍可从差异菜单查看。 - 保留暂存区作为选择的唯一来源,外部 `git add` 也会反映到勾选状态。没有另建一份仅属于 UI 的提交选择。双端本地变更列表只限定本次允许提交的路径;Core 用可选 `pathScope` 拦截其它列表的已暂存文件及父引用更新,不自动修改索引。具体取舍见 [本地变更列表](2026-10-03-local-git-changelists.md)。 +- macOS 文件右键的“提交文件…”是显式准备动作,不立即创建提交:先验证所选文件仍存在于状态且属于同一个本地变更列表, + 切到该列表,取消其它文件的暂存,再暂存所选尚未暂存的文件,最后聚焦提交说明。所选文件已经部分暂存时保留现有索引, + 不能为了全勾选把它的剩余修改也暂存。文件和子目录的重复目标先合并;复制记录只处理目标,保护来源文件的独立修改。 + 用户明确执行此动作才改索引,普通行高亮、右键打开菜单不改索引。首错停止并刷新,不宣称多文件写入为原子事务; + 取消、项目切换或重置拒绝后续写入,最终实际索引与选择不符时不聚焦为成功,也不清空提交草稿或修订选项。 - macOS 的待确认暂存状态不能假设变更标识唯一。例如 `git rm --cached example.txt` 保留磁盘文件后,Git 同时返回暂存删除和未跟踪文件,两条记录拥有同一标识。保留真实记录,按标识分组核对;所有记录都符合请求的暂存状态后才清除界面的待确认值,避免打开项目闪退或旧快照提前确认操作。如果文件恢复干净并从所属仓库成功读取的快照消失,也要清除待确认值,否则后续操作会一直被阻止。待确认值同时保留规范化的所属仓库路径;多仓库刷新必须携带本轮成功读取的仓库集合,只有这些仓库可以确认状态或确认路径消失。某个仓库读取失败时保留它的待确认值和提交等待保护,不能把其它仓库的部分快照当作整个工作区的干净状态。不要用要求键唯一的字典构造器,也不要只保留第一条或最后一条记录,这会让结果依赖返回顺序。 - Core 读取 Git 的 porcelain v2 状态,把子模块提交号变化和未提交文件分开。只有文件脏、引用未变的父条目展示提示,不参与批量勾选。 - Core 读取所有已发现仓库的真实引用,再计算子到父的执行顺序。默认自动带上必要的父引用;确认窗口显示仓库、文件、顺序和引用更新,并允许关闭本次自动联动。 @@ -42,6 +47,8 @@ macOS 和 Windows 都直接消费 Rust Core 的提交计划、依赖排序、失 - 把 Git 命令输出解析放在 Swift:改为共享 Core 的类型化状态,保持 Git 自身为索引和引用的事实来源。 - 只共享 Git 原语,把提交计划留在 Swift:没有采用。Windows 会被迫重复实现依赖和重试规则;现在两个端都直接消费 Core 生成的计划和下一步结果。 - 在 Core 中维护常驻批次注册表:没有采用。当前只需要项目会话内恢复,使用无后台资源的 JSON 续接状态即可;每一步仍以真实 Git 状态校验防止重复提交。 +- 右键“提交文件…”只改变高亮或直接提交:都不采用。前者不会排除其它已暂存文件,后者绕过提交说明和最终计划审核; + 显式准备实际索引后继续现有提交流程,不另建提交执行器。 ## 后果 @@ -55,6 +62,9 @@ Git 的多个仓库没有跨仓库原子事务,已成功的提交不会因后 - `windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts` 消费同一份共享夹具,验证计划转发、再次确认、重试、取消与迟到响应;`git-workspace-status-panel.test.tsx` 验证分组勾选写入所属仓库、忽略不可暂存的脏子模块引用。Tauri dispatcher 测试保护原生认证超时与部分结果透传。 - `macos/Tests/LitheGitModuleTests/GitModuleTests.swift` 消费共享夹具和预设 Core 响应,验证确认与再次确认、联动选项与重试转发、步骤驱动、复选框资格和项目切换隔离;不在测试替身中重新实现业务算法。 +- 同文件的 `commitFiles` 用例验证右键准备的执行顺序、变更列表、部分暂存、首错停止、重置与重入保护; + `macos/Tests/LitheTests/GitStatusObservationTests.swift` 的 `realGitCommitFiles` 用例通过真实 Git 验证最终索引路径、 + 已暂存块保留、未跟踪目录展开和复制来源隔离;`CommitDraftFeatureModelTests` 验证焦点请求保留说明与修订选项。 - 同文件暂存删除和未跟踪记录的回归用例覆盖两种返回顺序、首次与重复状态刷新,以及暂存和取消暂存都要等待全部记录确认;`macos/Tests/LitheTests/GitStatusObservationTests.swift` 通过真实 Git → Rust Core → Swift 状态刷新验证打开仓库时不闪退,实际执行暂存或取消暂存后,干净状态正确结束待确认操作。旧快照用例继续保护单记录场景。多仓库回归覆盖 A 读取成功且已干净、B 返回 nil、B 随后返回旧状态、最后成功读取干净状态的顺序,验证暂存和取消暂存期间 B 的乐观状态与提交等待保护都不提前解除。 - `rust/lithe-core/src/git/workspace_commit/tests.rs` 覆盖干净祖先联动、关闭联动、独立嵌套仓库、过期计划、失败依赖阻塞、仅重推和外部推进后再次推送。 - `rust/lithe-core/src/tests/git_workspace_commit.rs` 使用真实 Git 验证过期索引拒绝、子模块状态、只更新引用且不带入父文件、首次提交前取消暂存保留后续编辑,暂存后在工作树删除的文件仍可见、子仓库元数据被删除后拒绝回退父仓库,以及共享夹具序列化。真实 Git 还验证共享计划执行、推送失败后的重试、过期步骤重放拒绝、取消与成功提交返回竞争。 diff --git a/.agents/notes/implemented/feature/2026-10-29-commit-file-context-actions.md b/.agents/notes/implemented/feature/2026-10-29-commit-file-context-actions.md new file mode 100644 index 0000000000..51293bbfdb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-29-commit-file-context-actions.md @@ -0,0 +1,120 @@ +# Agent 笔记:提交文件菜单与插件 VCS 文件操作 + +状态:已实现 + +## 先说结论 + +macOS Commit 和 Worktrees 的文件右键菜单补齐提交文件、回滚、普通差异、新标签差异、删除与添加到 VCS。 +右键目标取被点击行及其所在多选集合,不把行高亮等同于提交复选框。 +插件提供的版本控制系统(VCS,例如 SVN)必须处理自己的添加和已跟踪文件删除;删除先显示明确范围的确认窗口,取消不改文件。 +Git 删除后先确认并发布对应仓库状态;已暂存的新文件还需要清除其残留索引记录,不能只移走磁盘文件。 + +## 问题 + +菜单缺少常用操作时,用户必须逐个打开文件或切到外部工具。 +把“添加到 VCS”当作 Git 暂存的别名会漏掉 SVN 等系统;把跟踪删除退回普通磁盘删除会绕过插件的版本记录。 +删除目录还可能跨越嵌套仓库、删除编辑器中尚未同步的内容,或在工作区切换后继续执行旧请求。 + +## 决策 + +### 上游依据与已有能力 + +参考 IDEA Community revision `0698a5fe297dfa08069f015cc423a6a85ed7fc60`: + +- `platform/vcs-impl/src/com/intellij/openapi/vcs/changes/actions/ScheduleForAdditionAction.kt` + 只处理未版本化文件,按所属 VCS 调用添加接口,不固定成 Git 命令。 +- 同目录 `ScheduleForRemovalAction.java` 把受版本控制的删除交给对应系统; + `RollbackDialogAction.java` 将所选更改送入回滚确认流程。 +- 同目录的兼容入口 `AbstractCommitChangesAction.kt` 限制所选变更列表并转发提交动作, + 不是另建无确认的即时提交器。 + +Lithe 复用既有 `VcsOperation.add/delete`、Provider 能力协商及根归属判断,不新建 SVN 命令执行器, +也不改变插件契约。普通和新标签差异的生命周期见 +[编辑器标签分屏](../architecture/2026-10-12-editor-tab-split-docking.md), +Git 提交准备见[工作区提交计划](2026-09-27-workspace-git-commit-plans.md), +批量回滚见[回滚确认范围](2026-10-29-git-rollback-confirmation.md)。 + +### 目标、能力与所有权 + +- 右键选中集合中的行时作用于该集合;右键集合之外的行时只作用于被点击文件。 + Git 的添加只暂存未跟踪目标。插件的添加只允许全部目标为未版本化状态,并要求 `fileOperations` 能力。 +- 插件已跟踪文件删除使用同一能力下的 `.delete`;未版本化文件使用 macOS 废纸篓。 + Provider 拒绝或失败时不退回磁盘删除,缺少能力时菜单禁用。 +- 发出请求前检查根、状态快照、路径和忙状态;异步根归属检查返回后再次检查工作区代次与根集合。 + 目录内部存在其他已识别或归属歧义的根时拒绝递归操作;根发现被截断时不假定未返回的区域安全。 +- Git 删除在执行前重新发现实际仓库,阻止确认期间才出现的嵌套仓库或 linked worktree 被整目录移走。 + Git 目录删除同样避开 Provider 的普通根和歧义根(歧义目录虽未选定 VCS,仍是独立工作副本)。 +- 废纸篓会移走整个目录,所以 Git 和插件的未版本化目录删除在执行前都在有界预算内重新发现 Provider 根。 + 新结果与缓存不一致、被截断或失败时拒绝本次删除并重新发现,提示用户刷新后重新选择;不在旧缓存上继续执行。 + 这是已发现范围的保护,不是与外部客户端之间的原子文件系统锁。 + +### 删除确认与编辑器保护 + +`CommitFileDeletionFeatureModel` 保存不可变的确认目标和单次请求身份;视图只负责展示路径、取消和确认。 +确认同步消费同意并声明忙状态,再允许原生窗口关闭;取消、被替换或已使用的请求不能重放。 + +目录目标先去重、合并子项,拒绝根目录本身和根外路径。叶子符号链接可移走,父目录解析后必须仍在确认根内。 +先排空目标编辑器的待处理输入,再检查未保存修改、工作区生命周期、最新资格和取消状态。 +存在脏文档或脏后代时拒绝删除,不代替用户保存或丢弃。 +每次原生废纸篓操作前重新检查,首错停止;成功后只关闭相应的干净文档并刷新。 +已跟踪的插件删除只派发一次确认批次,成功导致状态项消失不能反过来阻止清理干净文档。 + +Git 的废纸篓操作不会改索引(Git 保存待提交内容的位置)。删除已暂存的新文件后,Git 仍会报告 +“暂存新增、磁盘已删除”,所以 Commit 会继续显示绿色行。删除动作完成后,仅对本次确认中的新增目标 +重新读取状态;只有 Git 确认它仍是新增且磁盘已删除,才取消暂存。复制只清除目标,不影响源文件的独立编辑。 +即使废纸篓批次中途失败,也对已经消失的新增目标做这一处理;保留未删除目标、普通跟踪文件的删除记录和无关索引内容。 +状态读取失败时保留现有行并报告需刷新确认,不能直接从界面隐藏文件来伪装成功。 + +对应仓库的确认状态先更新 Commit、Worktrees 和已打开 Diff,再允许文件监听继续更新项目树与项目服务。 +不能把其他仓库扫描、历史或 Stash 刷新放在文件状态发布之前。具体异步顺序见 +[Git 执行与项目控制台](../architecture/2026-09-12-git-execution-and-project-console.md)。 + +正确示例:在 SVN 未版本化列表多选 A、B 后添加,宿主向 SVN Provider 发送一次排序去重的 `.add`。 +不要这样做:把 A、B 传给 Git 暂存,或在 SVN 删除失败后用 `removeItem` 强行删掉文件。 + +## 考虑过的备选方案 + +- 删除后直接隐藏所选行:不采用。已跟踪文件的删除仍是需要提交的真实变更,隐藏会让视图与 Git 不一致。 +- 删除整个批次后统一清空索引:不采用。会破坏普通文件的部分暂存以及未删除或无关文件的待提交内容。 + +- 只补菜单文案:无法实现所承诺动作,尤其插件仍没有添加入口,不采用。 +- 统一用磁盘删除:破坏已跟踪文件的 VCS 语义,不采用;普通磁盘删除仅用于 Git 或未版本化文件的废纸篓流程。 +- 弹窗关闭后再从当前高亮读取删除目标:范围可随用户操作变化,也无法撤销已取消的请求,不采用。 +- 自动保存全部脏文件再删除:会未经用户同意写入草稿,不采用;同步输入后仍有修改就要求用户先处理。 +- 为插件比较另开覆盖层:绕过普通标签、取消和分屏清理,不采用。 + +## 后果 + +用户得到完整可执行的菜单,插件仍是自己版本控制语义的唯一执行者。 +代价是含嵌套或歧义工作副本的目录需要拆开选择,脏编辑器必须先由用户处理。 +多文件删除没有跨文件事务;进行中的一次原生废纸篓调用不能被中途撤销,但取消后不再派发后续删除。 +Provider 自身负责服务器与工作副本的原子性,宿主不保证其批次一定全成或全败。 +这些动作只使用平台文件接口、现有插件协议和会话状态,不新增 bundle 写入、下载或共享缓存。 + +## 验证 + +- `macos/Tests/LitheTests/VcsProviderHostTests.swift`:真实菜单回调、精确排序批次、能力缺失、嵌套和歧义根、 + 异步所有权过期、操作互斥与重置后的迟到结果、废纸篓前重新发现确认期间新增的嵌套根;同文件的 + `VcsProviderAppModelTests` 覆盖新标签请求生命周期,以及真实 Git 目录删除避开 Provider 歧义根和确认期间新增的根。 +- `macos/Tests/LitheTests/CommitFileDeletionTests.swift`:取消、替换、同意重放、同步输入后脏/过期/取消/失败, + Provider 批次、首错停止和干净文档清理;原生窗口 Escape/关闭及实际按钮分别验证取消和确认。 +- `macos/Tests/LitheTests/GitStatusObservationTests.swift`:真实 Git 仓库中的提交准备、废纸篓动作前新嵌套仓库保护; + 删除后清理缺失新增项、部分失败时保留未删除项、保留普通跟踪删除与无关部分暂存,以及复制来源保护。 +- `macos/Tests/LitheGitModuleTests/GitModuleTests.swift`:本次操作的根先发布状态、不等待工作区解冻、 + 不扫描同级仓库;竞争写入、旧快照、跨根批次部分请求替代、父/子模型独立展开及迟到结果、读取失败和重置隔离。 +- 使用 `write-stable-tests` 的 macOS 计时入口生成逐项 HTML/JUnit;中英文明暗截图用携带当前语言、字体和图标的临时宿主。 +- `./scripts/architecture/verify-service-boundaries.sh`、`./scripts/architecture/verify-shared-contracts.sh`、 + `./scripts/macos/verify/verify-runtime-bundle-immutability.sh`、`./scripts/agent-notes/verify-agent-notes.sh`、 + `./scripts/platform-feature-matrix/verify-platform-feature-matrix.sh` 和共享 i18n 检查。 +- fake Provider 与原生组件不替代真实 SVN 插件整机验收;Windows 未实现这些新增 macOS 入口,矩阵验证保持 pending。 + +## 适用范围 + +- `macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift` +- `macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift` +- `macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift` +- `macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift` +- `macos/Sources/Lithe/Views/Git/CommitFileDeletionPresentation.swift` +- `macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift` +- `macos/Sources/Lithe/Views/Git/GitWorktreeChangesView.swift` +- `macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift` diff --git a/.agents/notes/implemented/feature/2026-10-29-git-rollback-confirmation.md b/.agents/notes/implemented/feature/2026-10-29-git-rollback-confirmation.md new file mode 100644 index 0000000000..88d38fac8c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-29-git-rollback-confirmation.md @@ -0,0 +1,107 @@ +# Agent 笔记:Git 批量回滚的确认范围 + +状态:已实现 + +## 先说结论 + +macOS 提交面板复用原来的弯箭头按钮,先打开可重新勾选文件的“回滚更改”窗口,最后确认才改文件。 +提交面板的勾选仍表示真实 Git 暂存区;回滚窗口里的勾选只是这次操作的草稿,关闭窗口不写入。 +回滚包含已暂存和未暂存内容;新增文件默认只取消暂存,只有明确勾选删除本地副本才删除磁盘文件。 + +## 问题 + +旧按钮使用行高亮作为范围,而不是提交复选框中的文件集合,导致勾选多个文件后不能直接批量回滚。 +旧的普通丢弃操作只把暂存区内容恢复到磁盘,已经暂存的更改不会恢复到上次提交。 +直接把确认文案改成“回滚”会让用户误以为已经恢复全部内容,因此范围和实际 Git 操作必须一起修改。 + +## 决策 + +- 参考 IntelliJ Community revision `0698a5fe297dfa08069f015cc423a6a85ed7fc60` 的 + `platform/vcs-impl/src/com/intellij/openapi/vcs/changes/ui/RollbackChangesDialog.kt`。 + 上游使用带复选框的文件浏览器,纳入集合与高亮分开;空集合不能确认,新增文件本地删除选项默认关闭。 + 本次只参考公开交互和源码,不复制上游窗口实现。 +- 顶部按钮限定当前 ChangeList(用户组织文件的本地变更列表):有已暂存文件时默认纳入这些文件, + 否则使用行多选,再否则纳入当前列表全部文件。右键从其作用范围初始化。窗口中可以增减纳入文件。 +- 窗口复用 `LitheCenteredPopup` 的有标题原生确认窗口、`LitheTreeList`、`LitheTreeCheckbox` + 和共享动作按钮。文件按仓库分组,分组框是三态,展开、高亮和纳入分别处理;不另建菜单、列表或按钮样式。 +- `GitRollbackSelection` 保存候选文件与纳入标识;`GitFeatureModel` 拥有请求、取消、忙状态和确认动作。 + 点击确认先捕获草稿,再关闭窗口,异步执行使用捕获值,不读取已经清空的窗口绑定。 +- 确认时检查工作区、请求身份及最新已知文件状态,拒绝被替换或失效的草稿。 + 执行期间阻止同模型及 Worktree 提交模型的暂存/提交交错;取消、重置或仓库切换后不派发后续写入。 +- 后台全量 Git 读取不占有索引或工作区写权限,因此不能单凭 `isRefreshingGit` 禁用回滚。 + 已暂存文件仍能打开确认窗口;确认时继续检查最新已知状态,执行写入使较早状态读取失效, + 防止读请求迟到后把已回滚的行加回来。暂存待确认、提交及其他写操作的互斥条件保持不变。 +- 复用共享 Core 的既有 Git 操作:普通文件直接通过 `discardAll` 一次恢复上次提交的暂存区和磁盘内容, + 不再先做一次多余的取消暂存;只有新增文件先取消暂存,再按删除选项保留或调用 `discard` 删除。 + `git add -N` 的意向新增也先清除索引记录。 + 重命名携带新旧两个路径;复制记录只操作目标路径,不能取消暂存或恢复源文件的独立更改。 + 首个失败停止,保留已有诊断,并重新读取状态;不把部分成功伪装成跨文件事务。 +- Git 同时报告“暂存删除”和“保留的未跟踪磁盘副本”时,窗口只保留同标识的已跟踪记录, + 防止把应恢复的删除当作新增文件。已经展开的未跟踪目录使用子项而不是同时保留整目录删除项, + 避免取消某个子项后仍被父项删除。 +- Worktrees 更改页复用相同流程,但使用固定在对应工作树根的提交模型,不改变主工作区的暂存区。 + Windows 沿用原有行选择及确认设置控制的回滚入口,本次没有同步这一确认文件树,不宣称界面一致。 +- 回滚命令已由后台工作线程执行;可见延迟还来自完成后等待项目服务、无关仓库和重复状态扫描。 + 因此先重读本批目标的仓库并发布真实行状态,恢复回滚按钮可用,再调度工作区后续刷新;不先乐观移除行。 + 旧的全工作区读取和较早的同根状态请求不能覆盖这次确认结果,工作区重置也必须隔离在途请求。 + Core 的 `discardAll` 状态查询仅扫描所选路径,按命令行预算分批,并把特殊字符当字面文件名; + 不能对每个文件重复枚举整个仓库的未跟踪目录。 + +正确示例:提交面板勾选 A、B,打开回滚窗口后取消 B,确认只恢复 A 的暂存与磁盘内容。 +不要在回滚窗口内调用暂存服务来实现勾选,也不要把当前高亮文件作为最终范围覆盖用户的确认草稿。 + +## 考虑过的备选方案 + +- 增加一个“批量回滚”按钮:不采用。现有回滚入口就应该正确处理勾选集合,重复按钮会增加歧义。 +- 把提交勾选改成纯界面选择:不采用。会破坏既有真实暂存区语义、外部 Git 同步及部分暂存。 +- 保留只列路径的普通确认框:不采用。用户无法在最终确认前排除文件,也不符合上游纳入集合交互。 +- 直接对所有目标调用 `discardAll`:不采用。新增路径可能不存在于上次提交,且不能表达默认保留新增文件。 +- 新增一套平台 Git 命令执行器:不采用。已有共享 Core 操作和原生执行生命周期足够,保持原有认证、 + 命令记录、取消和有界进程控制。 +- 只增加后台线程而保留原等待顺序:不采用。Git 已不在主线程执行,文件状态仍会被无关刷新阻塞; + 应调整状态确认与文件监听解冻的先后关系,而不是增加无主后台任务。 +- 全量状态读取期间一直禁用回滚:不采用。多根扫描、Stash 或历史读取会让已勾选文件长时间不能操作; + 沿用写入互斥和过期读取保护即可,不把纯查询当作写操作。 + +## 后果 + +用户可以一次恢复多个已暂存或部分暂存文件,并在最后一步重新审核范围。 +默认不删除新增文件,避免因为它没有历史版本而永久丢失内容;选择删除后仍是不可撤销操作。 +多个 Git 写入不是原子事务:中途失败或取消可能已经完成前面的文件,甚至已经取消当前文件暂存。 +确认检查的是模型最新已知状态,不提供跨外部 Git 客户端的文件内容锁;不要宣称可阻止外部同时改写。 +草稿只在当前窗口和工作区会话中存在,不新增下载、缓存、安装目录写入或跨工作树共享资源。 + +## 验证 + +- `macos/Tests/LitheGitModuleTests/GitRollbackSelectionTests.swift`:默认纳入、独立勾选、空集合、同标识删除及目录子项排除。 +- `macos/Tests/LitheGitModuleTests/GitModuleTests.swift`:窗口关闭与异步确认顺序、首错停止、忙时互斥、取消、重置及旧请求隔离; + 本地状态先于解冻发布、同级仓库无额外扫描、竞争写入按实际完成顺序确认、Worktree 展开及历史窗口保持; + 有界阻塞同级仓库读取时仍能确认回滚,释放旧读取后已消失的行不会恢复。 +- `macos/Tests/LitheTests/LitheCoreLogicTests.swift`:嵌套操作最外层解冻后调度已有刷新,操作不等待项目服务,重置取消后续任务; + 阻塞项目重载时普通文件事件与写入后恢复仍独立刷新 Git,取消的读取不吞掉新请求,旧工作区读取不能释放新读取。 +- `rust/lithe-core/src/tests/git_untracked_directories.rs`:回滚状态查询限制所选路径、长参数分批、特殊文件名字面匹配、 + 重命名源记录正确解析,以及暂存删除与未跟踪副本同路径时优先恢复历史版本。 +- `macos/Tests/LitheTests/GitStatusObservationTests.swift`:真实 Git 经 Core 验证批量恢复暂存区和磁盘、未选文件不变、 + 新增及意向新增文件保留/删除、复制来源不变、重命名/删除/保留副本以及 linked Worktree 隔离; + 同一个已挂载 Commit 面板通过真实监听接收外部新增、修改、删除,后台只读未结束时原生回滚按钮可点击, + 打开确认后取消不改变真实索引。 +- `macos/Tests/LitheTests/GitRollbackDialogTests.swift`:中英文与深浅主题原生布局、关闭/确认回调、独立重新勾选和空集合禁止确认。 + 普通 SwiftPM 宿主不携带产品图标和语言包;截图验收用带当前资源的临时签名宿主运行相同用例, + 检查真实中文按钮及复选框资源,不向已安装程序包写入测试资源。 +- `macos/Tests/LitheTests/GitWorktreeAppearanceTests.swift`:没有高亮行时点击工具栏实际打开带标题的审核窗口, + 通过关闭按钮的 Escape 快捷键及原生关闭控件取消,均不派发命令且允许重开;另检查宽窄布局控件可达。 + 独立窗口测试通过无障碍接口直接按关闭按钮;隔离宿主的真实子面板不稳定提供 SwiftUI 无障碍节点, + 因而集成用例验证原生响应链,不把它视为完整产品的辅助功能验收。 +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` 与该 Skill 的 macOS 逐项计时入口。 +- `./scripts/macos/test/test-macos.sh`、`./scripts/architecture/verify-service-boundaries.sh`、 + `./scripts/macos/verify/verify-runtime-bundle-immutability.sh`、`./scripts/agent-notes/verify-agent-notes.sh`、 + `./scripts/platform-feature-matrix/verify-platform-feature-matrix.sh`,以及共享 i18n 检查。 +- 原生组件和真实 Git 测试不能替代完整工作台人工验收;平台矩阵保留 pending。 + +## 适用范围 + +- `macos/Sources/LitheGitModule/Models/GitRollbackSelection.swift` +- `macos/Sources/LitheGitModule/Application/GitFeatureModel.swift` +- `macos/Sources/Lithe/Views/Git/GitRollbackDialog.swift` +- `macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift` +- `macos/Sources/Lithe/Views/Git/GitWorktreeChangesView.swift` diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 0b3063e1fb..228d43c86c 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -242,6 +242,7 @@ "Add to %@" = "Add to %@"; "Add to Another Conversation" = "Add to Another Conversation"; "Add to Current Conversation · %@" = "Add to Current Conversation · %@"; +"Add to VCS" = "Add to VCS"; "Add to Watches" = "Add to Watches"; "Add to favorites" = "Add to favorites"; "Add tool windows and commands to the workbench" = "Add tool windows and commands to the workbench"; @@ -250,6 +251,7 @@ "Added" = "Added"; "Added by plugins" = "Added by plugins"; "Added memory usage visibility to the status bar." = "Added memory usage visibility to the status bar."; +"Adding to VCS…" = "Adding to VCS…"; "Additional Search Paths" = "Additional Search Paths"; "Additional class patterns" = "Additional class patterns"; "Additions" = "Additions"; @@ -788,6 +790,7 @@ "Comment posted" = "Comment posted"; "Comments" = "Comments"; "Commit" = "Commit"; +"Commit Files…" = "Commit Files…"; "Commit History" = "Commit History"; "Commit History (%lld)" = "Commit History (%lld)"; "Commit Message" = "Commit Message"; @@ -992,6 +995,7 @@ "Could not read the project folder. Check that it still exists and that Lithe has permission to access it." = "Could not read the project folder. Check that it still exists and that Lithe has permission to access it."; "Could not read the selected built-in background." = "Could not read the selected built-in background."; "Could not read this file" = "Could not read this file"; +"Could not refresh Git status. Refresh to verify the changes." = "Could not refresh Git status. Refresh to verify the changes."; "Could not register Lithe MCP in %@: %@" = "Could not register Lithe MCP in %@: %@"; "Could not remove Lithe MCP from %@: %@" = "Could not remove Lithe MCP from %@: %@"; "Could not restore local history" = "Could not restore local history"; @@ -1116,6 +1120,7 @@ "Delete ChangeList" = "Delete ChangeList"; "Delete ChangeList?" = "Delete ChangeList?"; "Delete Chat" = "Delete Chat"; +"Delete Files" = "Delete Files"; "Delete Hook?" = "Delete Hook?"; "Delete Selected Branch" = "Delete Selected Branch"; "Delete Tag…" = "Delete Tag…"; @@ -1123,6 +1128,7 @@ "Delete branch?" = "Delete branch?"; "Delete chat" = "Delete chat"; "Delete collected application logs?" = "Delete collected application logs?"; +"Delete local copies of added files" = "Delete local copies of added files"; "Delete only Lithe-managed logs in the active directory across all modules and sessions. Filters do not limit this action. New events will continue to be recorded." = "Delete only Lithe-managed logs in the active directory across all modules and sessions. Filters do not limit this action. New events will continue to be recorded."; "Delete provider" = "Delete provider"; "Delete tag '%@'?" = "Delete tag '%@'?"; @@ -1132,6 +1138,8 @@ "Deleted branch '%@'" = "Deleted branch '%@'"; "Deleted references: %@" = "Deleted references: %@"; "Deleted tag '%@'" = "Deleted tag '%@'"; +"Delete…" = "Delete…"; +"Deleting…" = "Deleting…"; "Deletions" = "Deletions"; "Deny" = "Deny"; "Dependencies" = "Dependencies"; @@ -1752,6 +1760,7 @@ "Include" = "Include"; "Include a short body when useful" = "Include a short body when useful"; "Include in commit" = "Include in commit"; +"Include in rollback" = "Include in rollback"; "Include non-project items" = "Include non-project items"; "Include the URL path when Git looks up HTTP credentials." = "Include the URL path when Git looks up HTTP credentials."; "Increase Font Size" = "Increase Font Size"; @@ -3181,8 +3190,11 @@ "Roll back Agent changes?" = "Roll back Agent changes?"; "Roll back all" = "Roll back all"; "Roll back the selected changes?" = "Roll back the selected changes?"; +"Rollback" = "Rollback"; +"Rollback Changes" = "Rollback Changes"; "Rollback could not restore the changed file. It remains at %@. Restore it manually before retrying." = "Rollback could not restore the changed file. It remains at %@. Restore it manually before retrying."; "Rollback could not verify the moved file. Check the Trash before retrying." = "Rollback could not verify the moved file. Check the Trash before retrying."; +"Rolled back %@" = "Rolled back %@"; "Rolling back…" = "Rolling back…"; "Routes API-key and Codex subscription requests through a local proxy on this Mac to show model calls, time to first token, output speed and context composition. Requests and credentials are forwarded unchanged and never stored. Applies after reconnecting." = "Routes API-key and Codex subscription requests through a local proxy on this Mac to show model calls, time to first token, output speed and context composition. Requests and credentials are forwarded unchanged and never stored. Applies after reconnecting."; "Ruby Support" = "Ruby Support"; @@ -3441,6 +3453,7 @@ "Show Conflict Files" = "Show Conflict Files"; "Show Dependencies" = "Show Dependencies"; "Show Diff" = "Show Diff"; +"Show Diff in New Tab" = "Show Diff in New Tab"; "Show Diff with Working Tree" = "Show Diff with Working Tree"; "Show Disassembly" = "Show Disassembly"; "Show Editor Preview" = "Show Editor Preview"; @@ -3560,6 +3573,7 @@ "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying."; "Some dependencies are not shown." = "Some dependencies are not shown."; "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." = "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations."; +"Some selected files belong to another version control root. Refresh and select them again." = "Some selected files belong to another version control root. Refresh and select them again."; "Some special files could not be copied" = "Some special files could not be copied"; "Sort by Last Updated" = "Sort by Last Updated"; "Sort by Name" = "Sort by Name"; @@ -3595,6 +3609,7 @@ "Stage this change block" = "Stage this change block"; "Stage your files in Changes, enter a commit message, and create the first commit." = "Stage your files in Changes, enter a commit message, and create the first commit."; "Staged" = "Staged"; +"Staged and unstaged changes in the checked files will be rolled back. This cannot be undone by Lithe." = "Staged and unstaged changes in the checked files will be rolled back. This cannot be undone by Lithe."; "Staged changes" = "Staged changes"; "Staged files changed before generation finished" = "Staged files changed before generation finished"; "Stale record" = "Stale record"; @@ -4004,6 +4019,7 @@ "The selected branch diff is sent to the active AI provider when you generate." = "The selected branch diff is sent to the active AI provider when you generate."; "The selected branches have no textual changes to summarize." = "The selected branches have no textual changes to summarize."; "The selected built-in background is unavailable in this build." = "The selected built-in background is unavailable in this build."; +"The selected changes changed. Reopen the rollback dialog and review them again." = "The selected changes changed. Reopen the rollback dialog and review them again."; "The selected file changed before generation finished" = "The selected file changed before generation finished"; "The selected file has no textual diff to summarize." = "The selected file has no textual diff to summarize."; "The selected file must be inside the current workspace." = "The selected file must be inside the current workspace."; @@ -4026,6 +4042,7 @@ "The stable release is missing a valid publisher signature. Nothing was installed. Try a newer release or install manually from a trusted source." = "The stable release is missing a valid publisher signature. Nothing was installed. Try a newer release or install manually from a trusted source."; "The staged changes have no textual diff to summarize." = "The staged changes have no textual diff to summarize."; "The staged changes will be committed and the current branch will be pushed to its configured remote." = "The staged changes will be committed and the current branch will be pushed to its configured remote."; +"The staged files changed. Review the checked files before committing." = "The staged files changed. Review the checked files before committing."; "The stopped source file is no longer available: %@" = "The stopped source file is no longer available: %@"; "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file."; "The terminal directory is outside the remote workspace." = "The terminal directory is outside the remote workspace."; @@ -4047,6 +4064,7 @@ "The version control client could not be run. Check that it is installed." = "The version control client could not be run. Check that it is installed."; "The version control provider failed to start." = "The version control provider failed to start."; "The version control provider stopped before it was ready." = "The version control provider stopped before it was ready."; +"The version control provider will delete these files and record their removal. They may not be moved to Trash." = "The version control provider will delete these files and record their removal. They may not be moved to Trash."; "The working copy is locked by an interrupted action. Run Cleanup, then try again." = "The working copy is locked by an interrupted action. Run Cleanup, then try again."; "The working copy is out of date. Update it, then commit again." = "The working copy is out of date. Update it, then commit again."; "The working copy was cleaned up." = "The working copy was cleaned up."; @@ -4064,6 +4082,7 @@ "These are the options Lithe will use for the next ordinary Fetch." = "These are the options Lithe will use for the next ordinary Fetch."; "These choices apply to this Fetch only. Git configuration and credentials remain unchanged." = "These choices apply to this Fetch only. Git configuration and credentials remain unchanged."; "These defaults apply only to this project on this computer. Run configurations inherit them unless overridden. Use the Project save scope in a run configuration to share settings with your team." = "These defaults apply only to this project on this computer. Run configurations inherit them unless overridden. Use the Project save scope in a run configuration to share settings with your team."; +"These files will be moved to the macOS Trash." = "These files will be moved to the macOS Trash."; "These overrides affect only this run configuration. Clear a path to inherit the project environment." = "These overrides affect only this run configuration. Clear a path to inherit the project environment."; "These processes left their terminal or tool to keep running in the background, like daemons and dev servers. Checked items end when Lithe quits; unchecked items keep running." = "These processes left their terminal or tool to keep running in the background, like daemons and dev servers. Checked items end when Lithe quits; unchecked items keep running."; "These programs were started in Lithe before it closed unexpectedly. Checked items are ended now; unchecked items keep running and are not tracked anymore." = "These programs were started in Lithe before it closed unexpectedly. Checked items are ended now; unchecked items keep running and are not tracked anymore."; @@ -4649,6 +4668,14 @@ "git.diff.repositoryPreviewEmptyTitle" = "Repository Diff"; "git.diff.repositoryPreviewTitle" = "Repository Diff: %1$@"; "git.log.olderCommitsFailed" = "Could not load older commits. Refresh history"; +"git.rollback.confirm" = "Rollback"; +"git.rollback.deleteAdded" = "Delete local copies of added files"; +"git.rollback.include" = "Include in rollback"; +"git.rollback.stale" = "The selected changes changed. Reopen the rollback dialog and review them again."; +"git.rollback.success" = "Rolled back %1$@"; +"git.rollback.title" = "Rollback Changes"; +"git.rollback.warning" = "Staged and unstaged changes in the checked files will be rolled back. This cannot be undone by Lithe."; +"git.status.refreshFailed" = "Could not refresh Git status. Refresh to verify the changes."; "git.unifiedView" = "Unified view"; "https://github.com/owner/repository" = "https://github.com/owner/repository"; "just now" = "just now"; @@ -9234,6 +9261,17 @@ "ui.retry" = "Retry"; "ui.save" = "Save"; "upstream commit(s)" = "upstream commit(s)"; +"vcs.commitFiles.addToVcs" = "Add to VCS"; +"vcs.commitFiles.adding" = "Adding to VCS…"; +"vcs.commitFiles.commitFiles" = "Commit Files…"; +"vcs.commitFiles.deleteAction" = "Delete…"; +"vcs.commitFiles.deleteTitle" = "Delete Files"; +"vcs.commitFiles.deleting" = "Deleting…"; +"vcs.commitFiles.diffNewTab" = "Show Diff in New Tab"; +"vcs.commitFiles.ownershipChanged" = "Some selected files belong to another version control root. Refresh and select them again."; +"vcs.commitFiles.stagingChanged" = "The staged files changed. Review the checked files before committing."; +"vcs.commitFiles.trashWarning" = "These files will be moved to the macOS Trash."; +"vcs.commitFiles.vcsDeleteWarning" = "The version control provider will delete these files and record their removal. They may not be moved to Trash."; "version managed from %@" = "version managed from %@"; "— Click the Run icon in the editor gutter" = "— Click the Run icon in the editor gutter"; "— Launch a" = "— Launch a"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index f961a115e6..973f38f8b5 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -242,6 +242,7 @@ "Add to %@" = "添加到 %@"; "Add to Another Conversation" = "添加到其他会话"; "Add to Current Conversation · %@" = "添加到当前会话 · %@"; +"Add to VCS" = "添加到 VCS"; "Add to Watches" = "添加到监视"; "Add to favorites" = "加入收藏"; "Add tool windows and commands to the workbench" = "向工作台添加工具窗口和命令"; @@ -250,6 +251,7 @@ "Added" = "新增"; "Added by plugins" = "由插件添加"; "Added memory usage visibility to the status bar." = "已在状态栏中加入内存占用显示。"; +"Adding to VCS…" = "正在添加到 VCS…"; "Additional Search Paths" = "额外搜索路径"; "Additional class patterns" = "其他类模式"; "Additions" = "新增"; @@ -788,6 +790,7 @@ "Comment posted" = "评论已发表"; "Comments" = "评论"; "Commit" = "提交"; +"Commit Files…" = "提交文件…"; "Commit History" = "提交历史"; "Commit History (%lld)" = "提交历史(%lld 条)"; "Commit Message" = "提交信息"; @@ -992,6 +995,7 @@ "Could not read the project folder. Check that it still exists and that Lithe has permission to access it." = "无法读取项目文件夹,请确认文件夹仍然存在并允许 Lithe 访问。"; "Could not read the selected built-in background." = "无法读取所选的内置背景。"; "Could not read this file" = "无法读取此文件"; +"Could not refresh Git status. Refresh to verify the changes." = "无法刷新 Git 状态。请刷新以确认更改结果。"; "Could not register Lithe MCP in %@: %@" = "无法在 %@ 中注册 Lithe MCP:%@"; "Could not remove Lithe MCP from %@: %@" = "无法从 %@ 中移除 Lithe MCP:%@"; "Could not restore local history" = "无法恢复本地历史"; @@ -1116,6 +1120,7 @@ "Delete ChangeList" = "删除更改列表"; "Delete ChangeList?" = "删除更改列表?"; "Delete Chat" = "删除对话"; +"Delete Files" = "删除文件"; "Delete Hook?" = "删除 Hook?"; "Delete Selected Branch" = "删除所选分支"; "Delete Tag…" = "删除标签…"; @@ -1123,6 +1128,7 @@ "Delete branch?" = "删除分支?"; "Delete chat" = "删除会话"; "Delete collected application logs?" = "删除已收集的应用日志?"; +"Delete local copies of added files" = "删除新增文件的本地副本"; "Delete only Lithe-managed logs in the active directory across all modules and sessions. Filters do not limit this action. New events will continue to be recorded." = "仅删除生效目录中由 Lithe 管理的所有模块和会话日志。筛选条件不限制清理范围,后续事件仍会继续记录。"; "Delete provider" = "删除供应商"; "Delete tag '%@'?" = "删除标签“%@”?"; @@ -1132,6 +1138,8 @@ "Deleted branch '%@'" = "已删除分支“%@”"; "Deleted references: %@" = "已删除引用:%@"; "Deleted tag '%@'" = "已删除标签“%@”"; +"Delete…" = "删除…"; +"Deleting…" = "正在删除…"; "Deletions" = "删除"; "Deny" = "拒绝"; "Dependencies" = "依赖"; @@ -1752,6 +1760,7 @@ "Include" = "包含范围"; "Include a short body when useful" = "有必要时包含简短正文"; "Include in commit" = "包含在提交中"; +"Include in rollback" = "纳入回滚"; "Include non-project items" = "包含非项目文件"; "Include the URL path when Git looks up HTTP credentials." = "Git 查找 HTTP 凭据时是否把 URL 路径也纳入匹配。"; "Increase Font Size" = "增大字号"; @@ -3181,8 +3190,11 @@ "Roll back Agent changes?" = "回撤 Agent 的修改?"; "Roll back all" = "回撤全部"; "Roll back the selected changes?" = "回滚选中的更改?"; +"Rollback" = "回滚"; +"Rollback Changes" = "回滚更改"; "Rollback could not restore the changed file. It remains at %@. Restore it manually before retrying." = "回撤时无法恢复发生冲突的文件,文件仍保留在 %@。请先手动恢复再重试。"; "Rollback could not verify the moved file. Check the Trash before retrying." = "回撤时无法核对已移动的文件,请先检查废纸篓再重试。"; +"Rolled back %@" = "已回滚 %@"; "Rolling back…" = "正在回滚…"; "Routes API-key and Codex subscription requests through a local proxy on this Mac to show model calls, time to first token, output speed and context composition. Requests and credentials are forwarded unchanged and never stored. Applies after reconnecting." = "通过本机本地代理转发 API Key 和 Codex 订阅请求,用于显示模型调用次数、首 token 延迟、输出速度和上下文构成。请求与凭据原样转发,不会保存。重新连接后生效。"; "Ruby Support" = "Ruby 支持"; @@ -3441,6 +3453,7 @@ "Show Conflict Files" = "显示冲突文件"; "Show Dependencies" = "显示依赖"; "Show Diff" = "显示 Diff"; +"Show Diff in New Tab" = "在新标签页中显示差异"; "Show Diff with Working Tree" = "显示与工作区的 Diff"; "Show Disassembly" = "显示反汇编"; "Show Editor Preview" = "显示编辑器预览"; @@ -3560,6 +3573,7 @@ "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "部分 MCP 配置文件未能恢复:%@。请刷新并检查这些文件后重试。"; "Some dependencies are not shown." = "部分依赖未显示。"; "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." = "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。"; +"Some selected files belong to another version control root. Refresh and select them again." = "部分所选文件属于其他版本控制根目录。请刷新后重新选择。"; "Some special files could not be copied" = "部分特殊文件未能复制"; "Sort by Last Updated" = "按最近更新排序"; "Sort by Name" = "按名称排序"; @@ -3595,6 +3609,7 @@ "Stage this change block" = "暂存此更改代码块"; "Stage your files in Changes, enter a commit message, and create the first commit." = "在“更改”面板暂存文件、填写提交消息,然后创建首次提交。"; "Staged" = "已暂存"; +"Staged and unstaged changes in the checked files will be rolled back. This cannot be undone by Lithe." = "将回滚勾选文件中已暂存和未暂存的更改。Lithe 无法撤销此操作。"; "Staged changes" = "已暂存更改"; "Staged files changed before generation finished" = "生成完成前,已暂存的文件发生了变化"; "Stale record" = "陈旧记录"; @@ -4004,6 +4019,7 @@ "The selected branch diff is sent to the active AI provider when you generate." = "生成时,所选分支的差异内容会发送给当前 AI 服务商。"; "The selected branches have no textual changes to summarize." = "所选分支之间没有可供总结的文本更改。"; "The selected built-in background is unavailable in this build." = "此版本不提供所选的内置背景。"; +"The selected changes changed. Reopen the rollback dialog and review them again." = "所选更改已发生变化。请重新打开回滚弹窗并再次检查。"; "The selected file changed before generation finished" = "生成完成前,选中的文件已发生变化"; "The selected file has no textual diff to summarize." = "所选文件没有可用于总结的文本 Diff。"; "The selected file must be inside the current workspace." = "所选文件必须位于当前工作区内。"; @@ -4026,6 +4042,7 @@ "The stable release is missing a valid publisher signature. Nothing was installed. Try a newer release or install manually from a trusted source." = "正式版缺少有效的发布者签名,未执行安装。请等待新版本,或从可信来源手动安装。"; "The staged changes have no textual diff to summarize." = "已暂存的更改没有可用于总结的文本 Diff。"; "The staged changes will be committed and the current branch will be pushed to its configured remote." = "暂存的更改将被提交,并推送当前分支到已配置的远程仓库。"; +"The staged files changed. Review the checked files before committing." = "已暂存的文件发生了变化。请在提交前检查勾选的文件。"; "The stopped source file is no longer available: %@" = "程序停住的源文件已不存在:%@"; "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "开关控制是否使用 Lithe 共享区块,不会停用 Agent 或它的自有指令。请先保存共享修改再操作开关。点击行可编辑完整原生文件。"; "The terminal directory is outside the remote workspace." = "终端目录不在远程工作区内。"; @@ -4047,6 +4064,7 @@ "The version control client could not be run. Check that it is installed." = "无法运行版本控制客户端。请检查它是否已安装。"; "The version control provider failed to start." = "版本控制 Provider 启动失败。"; "The version control provider stopped before it was ready." = "版本控制 Provider 在就绪前已停止。"; +"The version control provider will delete these files and record their removal. They may not be moved to Trash." = "版本控制插件将删除这些文件并记录删除操作,文件可能不会移入废纸篓。"; "The working copy is locked by an interrupted action. Run Cleanup, then try again." = "工作副本被中断的操作锁定。请先执行清理,然后重试。"; "The working copy is out of date. Update it, then commit again." = "工作副本已过期。请先更新,再重新提交。"; "The working copy was cleaned up." = "工作副本已清理。"; @@ -4064,6 +4082,7 @@ "These are the options Lithe will use for the next ordinary Fetch." = "这是 Lithe 下次普通 Fetch 使用的选项。"; "These choices apply to this Fetch only. Git configuration and credentials remain unchanged." = "这些选项仅用于本次获取。Git 配置和凭据保持不变。"; "These defaults apply only to this project on this computer. Run configurations inherit them unless overridden. Use the Project save scope in a run configuration to share settings with your team." = "这些默认设置仅作用于这台电脑上的此项目。运行配置默认继承,单独设置时可覆盖。需要与团队共享的设置,请在运行配置中选择“项目”保存范围。"; +"These files will be moved to the macOS Trash." = "这些文件将移到 macOS 废纸篓。"; "These overrides affect only this run configuration. Clear a path to inherit the project environment." = "以下覆盖值仅影响当前运行配置。清空路径可恢复继承项目环境。"; "These processes left their terminal or tool to keep running in the background, like daemons and dev servers. Checked items end when Lithe quits; unchecked items keep running." = "这些进程已脱离所在的终端或工具,在后台独立运行,例如守护进程和开发服务器。勾选的项目会在 Lithe 退出时结束;未勾选的项目继续运行。"; "These programs were started in Lithe before it closed unexpectedly. Checked items are ended now; unchecked items keep running and are not tracked anymore." = "这些程序是 Lithe 意外关闭前在 Lithe 中启动的。勾选的项目会立即结束;未勾选的项目继续运行,Lithe 之后不再管理它们。"; @@ -4649,6 +4668,14 @@ "git.diff.repositoryPreviewEmptyTitle" = "仓库差异"; "git.diff.repositoryPreviewTitle" = "仓库差异: %1$@"; "git.log.olderCommitsFailed" = "无法加载更早的提交,点击刷新历史"; +"git.rollback.confirm" = "回滚"; +"git.rollback.deleteAdded" = "删除新增文件的本地副本"; +"git.rollback.include" = "纳入回滚"; +"git.rollback.stale" = "所选更改已发生变化。请重新打开回滚弹窗并再次检查。"; +"git.rollback.success" = "已回滚 %1$@"; +"git.rollback.title" = "回滚更改"; +"git.rollback.warning" = "将回滚勾选文件中已暂存和未暂存的更改。Lithe 无法撤销此操作。"; +"git.status.refreshFailed" = "无法刷新 Git 状态。请刷新以确认更改结果。"; "git.unifiedView" = "统一视图"; "https://github.com/owner/repository" = "https://github.com/owner/repository"; "just now" = "刚刚"; @@ -9234,6 +9261,17 @@ "ui.retry" = "重试"; "ui.save" = "保存"; "upstream commit(s)" = "个上游提交"; +"vcs.commitFiles.addToVcs" = "添加到 VCS"; +"vcs.commitFiles.adding" = "正在添加到 VCS…"; +"vcs.commitFiles.commitFiles" = "提交文件…"; +"vcs.commitFiles.deleteAction" = "删除…"; +"vcs.commitFiles.deleteTitle" = "删除文件"; +"vcs.commitFiles.deleting" = "正在删除…"; +"vcs.commitFiles.diffNewTab" = "在新标签页中显示差异"; +"vcs.commitFiles.ownershipChanged" = "部分所选文件属于其他版本控制根目录。请刷新后重新选择。"; +"vcs.commitFiles.stagingChanged" = "已暂存的文件发生了变化。请在提交前检查勾选的文件。"; +"vcs.commitFiles.trashWarning" = "这些文件将移到 macOS 废纸篓。"; +"vcs.commitFiles.vcsDeleteWarning" = "版本控制插件将删除这些文件并记录删除操作,文件可能不会移入废纸篓。"; "version managed from %@" = "依赖管理前版本为 %@"; "— Click the Run icon in the editor gutter" = "— 点击编辑器行号栏中的运行图标"; "— Launch a" = "— 启动"; diff --git a/macos/Sources/Lithe/Application/Features/CommitDraftFeatureModel.swift b/macos/Sources/Lithe/Application/Features/CommitDraftFeatureModel.swift index 62db2e9ff6..38b1085b0e 100644 --- a/macos/Sources/Lithe/Application/Features/CommitDraftFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/CommitDraftFeatureModel.swift @@ -16,6 +16,10 @@ final class CommitDraftFeatureModel: ObservableObject { @Published private(set) var pendingGeneratedMessage: String? @Published private(set) var commitEditorRequestVersion = 0 + func requestCommitEditor() { + commitEditorRequestVersion &+= 1 + } + /// Undo returns to normal committing without overwriting a draft the user is already writing. func prepareForUndoneCommit(message originalMessage: String) { if message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift b/macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift new file mode 100644 index 0000000000..6d5cd8efd9 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift @@ -0,0 +1,123 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Confirmation and editor safety shared by Git and plugin Commit menus. +/// Providers own tracked deletion; native Trash owns ordinary disk deletion. +@MainActor +final class CommitFileDeletionFeatureModel: ObservableObject { + struct Request: Identifiable { + let id = UUID() + let urls: [URL] + let root: URL + let usesVcs: Bool + let isCurrent: @MainActor () -> Bool + let isEligible: @MainActor () -> Bool + let delete: @MainActor ([URL]) async -> Bool + let refresh: @MainActor () async -> Void + } + + @Published private(set) var pending: Request? + @Published private(set) var isDeleting = false + private var deletionTask: Task? + private let files: any WorkspaceFileOperations + private let hasUnsavedEdits: @MainActor (URL) -> Bool + private let closeCleanDocuments: @MainActor (URL) -> Void + private let notify: @MainActor (String) -> Void + private let synchronizeEditors: @MainActor ([URL]) async throws -> Void + + init(files: any WorkspaceFileOperations, + hasUnsavedEdits: @escaping @MainActor (URL) -> Bool, + closeCleanDocuments: @escaping @MainActor (URL) -> Void, + synchronizeEditors: @escaping @MainActor ([URL]) async throws -> Void = { _ in }, + notify: @escaping @MainActor (String) -> Void) { + self.files = files + self.hasUnsavedEdits = hasUnsavedEdits + self.closeCleanDocuments = closeCleanDocuments + self.notify = notify + self.synchronizeEditors = synchronizeEditors + } + + func request(_ urls: [URL], root: URL, usesVcs: Bool = false, + isCurrent: @escaping @MainActor () -> Bool, + isEligible: @escaping @MainActor () -> Bool = { true }, + delete: (@MainActor ([URL]) async -> Bool)? = nil, + refresh: @escaping @MainActor () async -> Void) { + guard !isDeleting, isCurrent(), isEligible() else { return } + let root = root.standardizedFileURL + let urls = Array(Set(urls.map(\.standardizedFileURL))).sorted { $0.path < $1.path } + guard !urls.isEmpty, urls.allSatisfy({ contains($0, in: root) && files.fileExists(at: $0) }) else { return } + let targets = urls.filter { url in !urls.contains { $0 != url && url.path.hasPrefix($0.path + "/") } } + guard !targets.contains(where: hasUnsavedEdits) else { reportUnsaved(); return } + pending = Request(urls: targets, root: root, usesVcs: usesVcs, isCurrent: isCurrent, isEligible: isEligible, + delete: delete ?? { [weak self] urls in + await self?.trash(urls, root: root, isCurrent: isCurrent) ?? false + }, refresh: refresh) + } + + func cancel() { pending = nil } + + func reset() { + pending = nil + deletionTask?.cancel() + } + + /// Consume consent synchronously before the native presenter can dismiss. + /// An explicitly cancelled or replaced request can never be replayed. + @discardableResult + func beginConfirmation(_ request: Request) -> Task? { + guard pending?.id == request.id, !isDeleting, request.isCurrent(), request.isEligible() else { return nil } + pending = nil + isDeleting = true + let task = Task { await perform(request) } + deletionTask = task + return task + } + + func confirm(_ request: Request) async { + await beginConfirmation(request)?.value + } + + private func perform(_ request: Request) async { + defer { isDeleting = false; deletionTask = nil } + do { try await synchronizeEditors(request.urls) } + catch { if request.isCurrent() { notify(error.localizedDescription) }; return } + guard request.isCurrent(), request.isEligible(), !Task.isCancelled, + request.urls.allSatisfy({ contains($0, in: request.root) && files.fileExists(at: $0) }) else { return } + guard !request.urls.contains(where: hasUnsavedEdits) else { reportUnsaved(); return } + if await request.delete(request.urls), request.isCurrent(), request.usesVcs { + request.urls.filter { !hasUnsavedEdits($0) }.forEach(closeCleanDocuments) + } + if request.isCurrent() { await request.refresh() } + } + + func trash(_ urls: [URL], root: URL, isCurrent: @MainActor () -> Bool) async -> Bool { + let files = files + do { + for url in urls { + guard isCurrent(), !Task.isCancelled, contains(url, in: root), files.fileExists(at: url) else { return false } + guard !hasUnsavedEdits(url) else { reportUnsaved(); return false } + try await Task.detached(priority: .userInitiated) { try files.trashItem(at: url) }.value + if isCurrent(), !hasUnsavedEdits(url) { closeCleanDocuments(url) } + } + return true + } catch { + notify(error.localizedDescription) + return false + } + } + + private func contains(_ url: URL, in root: URL) -> Bool { + let prefix = root.standardizedFileURL.path + "/" + let resolvedPrefix = root.resolvingSymlinksInPath().standardizedFileURL.path + "/" + // Trash moves the leaf itself, not its symlink target. Its parent must + // still resolve inside the confirmed root, never through an escape. + let parent = url.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL.path + "/" + return url.path.hasPrefix(prefix) && parent.hasPrefix(resolvedPrefix) + } + + private func reportUnsaved() { + notify(L10n.string("Save or discard unsaved files before deleting this item")) + } +} diff --git a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift index 512774b33a..3b903ffc3d 100644 --- a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift @@ -18,6 +18,11 @@ final class EditorTabOrderFeatureModel: ObservableObject { if agentSessionByTab.keys.contains(where: { !open.contains($0) }) { agentSessionByTab = agentSessionByTab.filter { open.contains($0.key) } } + let diffs = Set(repositoryDiffIDs) + providerDiffs = providerDiffs.filter { diffs.contains($0.key) } + loadingProviderDiffs.formIntersection(diffs) + for (id, task) in providerDiffTasks where !diffs.contains(id) { task.cancel() } + providerDiffTasks = providerDiffTasks.filter { diffs.contains($0.key) } } } @Published private(set) var groupLayout: EditorGroupLayout = .group(UUID()) @@ -142,6 +147,11 @@ final class EditorTabOrderFeatureModel: ObservableObject { @Published var repositoryDiffSelected = false var repositoryDiffReturnTabs: [UUID: EditorTabItem] = [:] var repositoryDiffKeys: [UUID: RepositoryDiffTabKey] = [:] + /// Loaded plugin comparisons share the existing diff tab/group lifecycle, + /// without activating Git or storing them in Git's comparison sessions. + @Published var providerDiffs: [UUID: VcsFileDiff] = [:] + var loadingProviderDiffs: Set = [] + var providerDiffTasks: [UUID: Task] = [:] var repositoryDiffIDs: [UUID] { items.compactMap { diff --git a/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift index 19426fd2a3..7c4cae99c3 100644 --- a/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift @@ -612,6 +612,138 @@ final class VcsProvidersFeatureModel: ObservableObject { title: L10n.string("Cleaning up…"), as: VcsEmptyResult.self) != nil } + func canAdd(_ changes: [VcsChange], in rootID: String) -> Bool { + canChangeFiles(changes, in: rootID) + && changes.allSatisfy { $0.status == .unversioned } + } + + func canDelete(_ changes: [VcsChange], in rootID: String) -> Bool { + canChangeFiles(changes, in: rootID) + && changes.allSatisfy { ![.unversioned, .ignored, .deleted, .missing].contains($0.status) } + } + + func canTrash(_ changes: [VcsChange], in rootID: String) -> Bool { + canChangeFiles(changes, in: rootID, requiresProvider: false) + && changes.allSatisfy { $0.status == .unversioned } + } + + private func canChangeFiles(_ changes: [VcsChange], in rootID: String, requiresProvider: Bool = true) -> Bool { + guard runningOperation == nil, !isDetecting, let root = root(rootID), !root.isRefreshing, + !isTruncated, (!requiresProvider || root.supports(.fileOperations)), + let workspaceURL, !changes.isEmpty else { return false } + return changes.allSatisfy { + root.snapshot?.changes.contains($0) == true + && Self.workspacePath($0.path, rootID: rootID, workspaceURL: workspaceURL) != nil + && !containsOtherRoot($0, in: root) + } + } + + /// A recursive add/delete must not cross a nested working copy, even when + /// the selected directory itself belongs to the enclosing provider. + private func containsOtherRoot(_ change: VcsChange, in root: VcsRootState) -> Bool { + guard let workspaceURL else { return true } + let others = (allRootIDs + ambiguous.map(\.id)).filter { $0 != root.id } + return Self.hasRoot(others.map { Self.rootURL(id: $0, workspaceURL: workspaceURL) }, + atOrBelow: root.url.appendingPathComponent(change.path)) + } + + /// Every root and ambiguous directory of the last detection, Git + /// included, so a recursive Git deletion keeps out of them too. + var knownRootURLs: [URL] { + guard let workspaceURL else { return [] } + return (allRootIDs + ambiguous.map(\.id)).map { Self.rootURL(id: $0, workspaceURL: workspaceURL) } + } + + /// True when one of `roots` is `target` or lies inside it. + nonisolated static func hasRoot(_ roots: [URL], atOrBelow target: URL) -> Bool { + let target = target.standardizedFileURL.path + return roots.contains { + let path = $0.standardizedFileURL.path + return path == target || path.hasPrefix(target + "/") + } + } + + /// Detects the roots again off the main thread, within Core's directory + /// budget, and tells whether the last detection still holds. A recursive + /// deletion asks first: the cached roots miss a working copy created + /// while its confirmation was open. False when detection fails, runs out + /// of budget, finds other roots, or the workspace changes; a stale answer + /// starts a detection so the next selection sees the new roots. True + /// without a workspace or a provider plugin, where nothing is detected. + func confirmRootsAreCurrent() async -> Bool { + guard let workspaceURL else { return true } + let sources = sources() + guard !sources.isEmpty else { return true } + let mappings = mappingStore.mappings(workspaceURL: workspaceURL) + let planner = planner, generation = generation + let detection = await Task.detached(priority: .userInitiated) { + try? planner.detectRoots(workspaceURL: workspaceURL, manifests: sources.map(\.manifest), mappings: mappings) + }.value + guard self.generation == generation, !Task.isCancelled else { return false } + guard let detection, !detection.truncated, !isTruncated, detection.roots.map(\.id) == allRootIDs, + detection.ambiguous.map(\.id) == ambiguous.map(\.id) else { + detect() + return false + } + return true + } + + /// Native Trash removes a directory with everything inside, so the roots + /// are detected again before it runs, not only read from the cache. + func trash(_ changes: [VcsChange], in rootID: String, + action: @MainActor () async -> Bool) async -> Bool { + let generation = generation + guard canTrash(changes, in: rootID), await owns(changes, in: rootID) else { return false } + guard await confirmRootsAreCurrent() else { + if self.generation == generation { + report(L10n.string("Some selected files belong to another version control root. Refresh and select them again."), rootID: rootID) + } + return false + } + guard self.generation == generation, canTrash(changes, in: rootID) else { return false } + runningOperation = RunningOperation(rootID: rootID, title: L10n.string("Deleting…")) + defer { + if self.generation == generation { runningOperation = nil; refresh(rootID) } + } + return await action() + } + + func add(_ changes: [VcsChange], in rootID: String) async -> Bool { + guard canAdd(changes, in: rootID), await owns(changes, in: rootID), canAdd(changes, in: rootID) else { return false } + return await perform(.add(paths: Array(Set(changes.map(\.path))).sorted()), in: rootID, + title: L10n.string("Adding to VCS…"), as: VcsEmptyResult.self) != nil + } + + /// No filesystem fallback: a provider refusal must not turn into an + /// untracked disk deletion (notably for working copies such as SVN). + func delete(_ changes: [VcsChange], in rootID: String) async -> Bool { + guard canDelete(changes, in: rootID), await owns(changes, in: rootID), canDelete(changes, in: rootID) else { return false } + return await perform(.delete(paths: Array(Set(changes.map(\.path))).sorted()), in: rootID, + title: L10n.string("Deleting…"), as: VcsEmptyResult.self) != nil + } + + private func owns(_ changes: [VcsChange], in rootID: String) async -> Bool { + guard let workspaceURL else { return false } + let paths = changes.compactMap { Self.workspacePath($0.path, rootID: rootID, workspaceURL: workspaceURL) } + guard paths.count == changes.count else { return false } + let planner = planner, roots = allRootIDs, ambiguous = ambiguous.map(\.id), generation = generation + do { + let owners = try await Task.detached(priority: .userInitiated) { + try planner.owners(roots: roots, ambiguous: ambiguous, paths: paths) + }.value + guard self.generation == generation, !Task.isCancelled, + allRootIDs == roots, self.ambiguous.map(\.id) == ambiguous else { return false } + guard owners.count == paths.count, owners.allSatisfy({ $0 == rootID }) else { + report(L10n.string("Some selected files belong to another version control root. Refresh and select them again."), rootID: rootID) + return false + } + return true + } catch { + if self.generation == generation { report(Self.message(for: error), rootID: rootID) } + return false + } + } + func baseContent(_ path: String, in rootID: String) async throws -> VcsBaseContent { try await request(.baseContent(path: path), rootID: rootID, timeout: Self.statusTimeout, as: VcsBaseContent.self) } @@ -634,6 +766,7 @@ final class VcsProvidersFeatureModel: ObservableObject { /// 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 generation = generation let fileURL = root.url.appendingPathComponent(change.path).standardizedFileURL var current: String? = "" var isBinary = false @@ -651,6 +784,7 @@ final class VcsProvidersFeatureModel: ObservableObject { isTooLarge = (content.content?.utf8.count ?? 0) > Self.diffByteLimit base = isBinary || isTooLarge ? nil : content.content ?? "" } + guard self.generation == generation, !Task.isCancelled else { throw CancellationError() } return VcsFileDiff( rootID: rootID, path: change.path, fileURL: fileURL, base: base, current: isBinary || isTooLarge ? nil : current, @@ -775,11 +909,13 @@ final class VcsProvidersFeatureModel: ObservableObject { roots[index].negotiation = reply.negotiation } let planner = planner - return try await Task.detached(priority: .userInitiated) { + let value = try await Task.detached(priority: .userInitiated) { try planner.decodeReply( operation, capabilities: reply.negotiation.capabilities, reply: reply.value, as: Value.self ) }.value + guard self.generation == generation, !Task.isCancelled else { throw CancellationError() } + return value } private func target(for state: VcsRootState) -> VcsProviderTarget? { diff --git a/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift index af0a388411..dfdd8021c3 100644 --- a/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift @@ -407,7 +407,7 @@ final class WorkbenchFeatureModel: ObservableObject { layoutSessionEnded = false let priorLeft = activeLeftToolWindow let layout = layoutStore?.load(for: workspaceURL) - ?? WorkbenchLayout(sidebarWidth: 320, topPaneHeight: nil) + ?? WorkbenchLayout(sidebarWidth: WorkbenchLayout.defaultSidebarWidth, topPaneHeight: nil) layoutWorkspaceURL = nil contributionPreferences = layout.contributionPreferences ?? [:] var states = Self().toolWindowStates diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 668796a7e8..d84a8a64e8 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -109,7 +109,9 @@ extension AppModel { editorTabOrderFeature.repositoryDiffReturnTabs[id] = previous } editorTabOrderFeature.focus(.repositoryDiff(id)) - gitFeatureIfActive?.activateRepositoryDiffSession(id) + if editorTabOrderFeature.providerDiffs[id] == nil && !editorTabOrderFeature.loadingProviderDiffs.contains(id) { + gitFeatureIfActive?.activateRepositoryDiffSession(id) + } terminalPlacementFeature.activateDocument() mediaFeature.deactivate() activeDocumentID = nil diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift index 013c7c67c2..897bad1157 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift @@ -28,7 +28,7 @@ extension AppModel { self?.workspaceFeature.beginGitOperationFreeze() }, operationEnded: { [weak self] in - await self?.workspaceFeature.endGitOperationFreeze() + await self?.workspaceFeature.endGitOperationFreeze(waitForRefresh: false) }, commitUndone: { [weak self] message in guard let self else { return } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift index 0d8522bd74..ee1fae58b4 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift @@ -1,7 +1,37 @@ import Foundation import LitheGitModule +import LitheModuleAPI extension AppModel { + func makeCommitFileDeletion() -> CommitFileDeletionFeatureModel { + CommitFileDeletionFeatureModel( + files: services.fileOperations, + hasUnsavedEdits: { [weak self] url in + self?.openDocuments.contains { document in + document.isDirty && (document.url.standardizedFileURL == url.standardizedFileURL + || document.url.standardizedFileURL.path.hasPrefix(url.standardizedFileURL.path + "/")) + } == true + }, + closeCleanDocuments: { [weak self] url in + guard let self else { return } + guard !self.openDocuments.contains(where: { $0.isDirty && ($0.url.standardizedFileURL == url.standardizedFileURL + || $0.url.standardizedFileURL.path.hasPrefix(url.standardizedFileURL.path + "/")) }) else { return } + self.documentFeature.closeDocuments(containedIn: url) + }, synchronizeEditors: { [weak self] urls in + guard let self else { throw CancellationError() } + let documents = self.openDocuments.filter { document in urls.contains { url in + document.url.standardizedFileURL == url.standardizedFileURL + || document.url.standardizedFileURL.path.hasPrefix(url.standardizedFileURL.path + "/") + } } + for document in documents { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + document.withSynchronizedEditor { continuation.resume(with: $0) } + } + try Task.checkCancellation() + } + }, notify: { [weak self] in self?.showNotification($0) }) + } + func showGitDirectoryDiff(for directoryURL: URL) async { let requestID = beginRepositoryDiffRequest(key: RepositoryDiffTabKey(repositoryRoot: gitRepositoryRoot?.standardizedFileURL, source: .directory(directoryURL.standardizedFileURL))) @@ -70,15 +100,69 @@ extension AppModel { gitFeatureIfActive?.clearGitConflictFilter() } - func selectChange(_ change: GitChange) { + func selectChange(_ change: GitChange, inNewTab: Bool = false) { guard !change.isUntrackedDirectory else { return } - let requestID = beginRepositoryDiffRequest(key: RepositoryDiffTabKey(repositoryRoot: change.repositoryRoot.standardizedFileURL, + let requestID = beginRepositoryDiffRequest(key: inNewTab ? nil : RepositoryDiffTabKey(repositoryRoot: change.repositoryRoot.standardizedFileURL, source: .workingFile(change.url.standardizedFileURL))) Task { [weak self] in await self?.performRepositoryDiffRequest(requestID) { await $0.selectChange(change, session: $1) } } } + func prepareCommitFiles(_ changes: [GitChange], feature: GitFeatureModel, draft: CommitDraftFeatureModel) { + let generation = workspaceFeature.workspaceGeneration + Task { [weak self] in + guard await feature.prepareCommitFiles(changes), let self, + self.workspaceFeature.workspaceGeneration == generation else { return } + draft.requestCommitEditor() + } + } + + func requestDeleteCommitFiles(_ changes: [GitChange], feature: GitFeatureModel) { + guard let first = changes.first, feature.canRollbackChanges, + changes.allSatisfy({ feature.gitChanges.contains($0) && $0.repositoryRoot == first.repositoryRoot }) else { return } + let generation = workspaceFeature.workspaceGeneration + let root = first.repositoryRoot.standardizedFileURL + let deletion = commitFileDeletion + let isCurrent: @MainActor () -> Bool = { [weak self, weak feature] in + guard let self, let feature else { return false } + return self.workspaceFeature.workspaceGeneration == generation + && feature.availableRepositoryRoots.contains(root) + } + // Provider roots include ambiguous directories: a nested working copy + // whose VCS is not chosen yet still must not go to the Trash. + let isEligible: @MainActor () -> Bool = { [weak self, weak feature] in + guard let self, let feature, feature.canRollbackChanges, + self.vcsProvidersFeature?.isTruncated != true else { return false } + let nestedRoots = feature.availableRepositoryRoots + (self.vcsProvidersFeature?.knownRootURLs ?? []) + return changes.allSatisfy { change in + feature.gitChanges.contains(change) + && !VcsProvidersFeatureModel.hasRoot(nestedRoots, atOrBelow: change.url) + } + } + commitFileDeletion.request(changes.map(\.url), root: root, + isCurrent: isCurrent, + isEligible: isEligible, + delete: { [weak self, weak feature, weak deletion] urls in + guard let self, let feature, let deletion else { return false } + // Git finds its own nested repositories again below; plugin + // roots created during the confirmation need a fresh detection. + if let providers = self.vcsProvidersFeature { + guard await providers.confirmRootsAreCurrent(), isCurrent(), isEligible() else { + if isCurrent() { + self.showNotification(L10n.string("Some selected files belong to another version control root. Refresh and select them again.")) + } + return false + } + } + return await feature.deleteCommitFiles(changes) { + await deletion.trash(urls, root: root, isCurrent: isCurrent) + } + }, refresh: { [weak self] in + await self?.workspaceFeature.refreshCurrent() + }) + } + private func workingDiffKey(_ url: URL?) -> RepositoryDiffTabKey? { guard let url else { return nil } let root = gitChanges.first { $0.url.standardizedFileURL == url.standardizedFileURL }?.repositoryRoot ?? gitRepositoryRoot diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift index 0b8d12a7ec..20ac44d340 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift @@ -19,4 +19,56 @@ extension AppModel { func handleVcsProviderFileChanges(_ changes: [WorkspaceFileChange]) { vcsProvidersFeature?.workspaceFilesChanged(changes) } + + func showVcsDiffInNewTab(_ change: VcsChange, rootID: String) { + guard let feature = vcsProvidersFeature else { return } + let generation = workspaceFeature.workspaceGeneration + let id = beginRepositoryDiffRequest(isProvider: true) + let tabs = editorTabOrderFeature + tabs.providerDiffTasks[id] = Task { [weak self, weak tabs] in + defer { tabs?.providerDiffTasks[id] = nil } + do { + let diff = try await feature.diff(for: change, in: rootID) + guard let self, self.workspaceFeature.workspaceGeneration == generation, !Task.isCancelled, + self.editorTabOrderFeature.contains(.repositoryDiff(id)) else { return } + self.editorTabOrderFeature.providerDiffs[id] = diff + self.editorTabOrderFeature.loadingProviderDiffs.remove(id) + } catch is CancellationError { + if let self, self.workspaceFeature.workspaceGeneration == generation { self.closeRepositoryDiffTab(id) } + } catch { + guard let self, self.workspaceFeature.workspaceGeneration == generation, !Task.isCancelled, + self.editorTabOrderFeature.contains(.repositoryDiff(id)) else { return } + self.closeRepositoryDiffTab(id) + feature.report(VcsProvidersFeatureModel.message(for: error), rootID: rootID) + } + } + } + + func requestDeleteVcsFiles(_ changes: [VcsChange], rootID: String) { + guard let feature = vcsProvidersFeature, let root = feature.root(rootID) else { return } + let unversioned = changes.allSatisfy { $0.status == .unversioned } + guard feature.canTrash(changes, in: rootID) || feature.canDelete(changes, in: rootID) else { return } + let generation = workspaceFeature.workspaceGeneration + let deletion = commitFileDeletion + let isCurrent: @MainActor () -> Bool = { [weak self, weak feature] in + self?.workspaceFeature.workspaceGeneration == generation && feature?.root(rootID)?.root == root.root + } + let delete: @MainActor ([URL]) async -> Bool + if unversioned { + delete = { [weak feature, weak deletion] urls in + guard let feature, let deletion else { return false } + return await feature.trash(changes, in: rootID) { + await deletion.trash(urls, root: root.url, isCurrent: isCurrent) + } + } + } else { + delete = { [weak feature] _ in await feature?.delete(changes, in: rootID) ?? false } + } + commitFileDeletion.request(changes.map { root.url.appendingPathComponent($0.path) }, root: root.url, + usesVcs: !unversioned, isCurrent: isCurrent, + isEligible: { [weak feature] in + (unversioned ? feature?.canTrash(changes, in: rootID) : feature?.canDelete(changes, in: rootID)) == true + }, delete: delete, + refresh: { [weak self] in await self?.workspaceFeature.refreshCurrent() }) + } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 49d064aca5..fd3c085a89 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -65,6 +65,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { set { searchSessionFeature.selectedReplacementPaths = newValue } } let gitLogPresentationCache = GitLogPresentationCache() + lazy var commitFileDeletion = makeCommitFileDeletion() let editorChrome = EditorChromeModel() let editorDiagnosticsStore = EditorDiagnosticsStore() /// 编辑器当前选中的单行文本,供 Find/Replace in Files 预填查询词。 @@ -708,6 +709,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { func shutdownProjectSession() async { isProjectSessionClosed = true + commitFileDeletion.reset() // Native window close can retain this model through Settings without // closing its workspace. Revoke page requests before teardown can yield. services.pluginWebPages.closeAll() @@ -938,6 +940,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { } private func prepareForWorkspaceOpen(at normalizedURL: URL) { + commitFileDeletion.reset() documentLanguageCoordinator?.stop() beginModuleRuntimeShutdown() // A workspace root is a hard language-server ownership boundary. Stop @@ -996,6 +999,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { } private func finishWorkspaceClose(for workspaceURL: URL) { + commitFileDeletion.reset() releaseRemoteWorkspace(at: workspaceURL) ideCapabilities.disable() documentLanguageCoordinator?.stop() @@ -1635,7 +1639,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { if session.savedDiffSnapshot == nil { feature.showSavedChangesDiff(snapshot, version: version, file: file, session: session) } } - func beginRepositoryDiffRequest(key: RepositoryDiffTabKey? = nil) -> UUID { + func beginRepositoryDiffRequest(key: RepositoryDiffTabKey? = nil, isProvider: Bool = false) -> UUID { if let key, let existing = editorTabOrderFeature.repositoryDiffKeys.first(where: { $0.value == key })?.key, editorTabOrderFeature.contains(.repositoryDiff(existing)) { selectRepositoryDiffTab(existing) @@ -1648,6 +1652,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { ?? activeDocument.map { .document($0.id) } editorTabOrderFeature.repositoryDiffKeys[id] = key editorTabOrderFeature.moveToEnd(.repositoryDiff(id)) + if isProvider { editorTabOrderFeature.loadingProviderDiffs.insert(id) } selectRepositoryDiffTab(id) return id } @@ -1670,6 +1675,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { let wasSelected = selectedRepositoryDiffID == id let previous = editorTabOrderFeature.repositoryDiffReturnTabs.removeValue(forKey: id) editorTabOrderFeature.repositoryDiffKeys[id] = nil + editorTabOrderFeature.providerDiffs[id] = nil let group = editorTabOrderFeature.groupLayout.groups.first { editorTabOrderFeature.items(in: $0).contains(item) } if wasSelected { editorTabOrderFeature.repositoryDiffSelected = false } editorTabOrderFeature.remove(item) diff --git a/macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift b/macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift index a678875fa7..a9b40f139d 100644 --- a/macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift +++ b/macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift @@ -19,6 +19,8 @@ struct WorkbenchLayout: Codable, Sendable { // IDEA's ide.mainSplitter.min.size applies to each outer pane and the editor. static let minimumPaneSize: Double = 30 static let minimumSidebarWidth = minimumPaneSize + static let defaultSidebarWidth: Double = 360 + static let defaultTopPaneFraction: Double = 0.72 static let defaultMavenPaneWidth: Double = 360 var toolWindows: [ToolWindowState]? var groupFractions: [String: Double]? @@ -43,7 +45,7 @@ struct WorkbenchLayout: Codable, Sendable { struct WorkbenchLayoutStore { private static let keyPrefix = "lithe.workbench-layout." - private static let defaultLayout = WorkbenchLayout(sidebarWidth: 320, topPaneHeight: nil) + private static let defaultLayout = WorkbenchLayout(sidebarWidth: WorkbenchLayout.defaultSidebarWidth, topPaneHeight: nil) private let store: any KeyValueStore init(store: any KeyValueStore) { diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index 0d1d51d0a6..fa11981dea 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -360,6 +360,7 @@ private struct ActiveSessionChrome: View { feature: session.vcsProvidersFeature, fontFamily: session.settings.editorFontFamily, openFile: { [weak session] in session?.openFile($0, allowsOutsideWorkspace: true) } )) + .modifier(CommitFileDeletionPresentation(feature: session.commitFileDeletion)) } diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index aafd729b9c..3d0541beec 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -1175,6 +1175,9 @@ struct EditorAreaView: View { } private func repositoryDiffTitle(_ id: UUID) -> String { + if let diff = model.editorTabOrderFeature.providerDiffs[id] { + return L10n.string("Repository Diff: \(diff.fileURL.lastPathComponent)") + } guard let session = model.gitFeatureIfActive?.repositoryDiffSessions[id] else { return L10n.string("Repository Diff") } if let comparison = session.branchComparison { let target = comparison.targetReference?.shortName ?? L10n.string("Working Tree") @@ -1189,7 +1192,9 @@ struct EditorAreaView: View { @ViewBuilder private func repositoryDiffContent(_ id: UUID) -> some View { - if let feature = model.gitFeatureIfActive, let session = feature.repositoryDiffSessions[id] { + if let diff = model.editorTabOrderFeature.providerDiffs[id] { + VcsFileDiffContent(diff: diff, fontFamily: settings.editorFontFamily) + } else if let feature = model.gitFeatureIfActive, let session = feature.repositoryDiffSessions[id] { RepositoryDiffSessionView(feature: feature, session: session, fontFamily: settings.editorFontFamily) } else { ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index 5d02798914..04515f682d 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -20,6 +20,9 @@ struct ChangesSidebarView: View { let revealInFinder: (URL) -> Void let copyPath: (URL, Bool) -> Void let showSettings: (SettingsCategory) -> Void + var prepareCommitFiles: (([GitChange]) -> Void)? = nil + var showDiffInNewTab: ((GitChange) -> Void)? = nil + var deleteFiles: (([GitChange]) -> Void)? = nil @Environment(\.controlActiveState) private var controlActiveState @State private var selectedTab = CommitTab.commit @State private var commitToolActive = false @@ -27,7 +30,6 @@ struct ChangesSidebarView: View { @State private var repositoryExpanded: [String: Bool] = [:] @State private var pendingDropStash: GitStash? @State private var pendingDropShelf: GitShelfEntry? - @State private var pendingDiscardSelection: [GitChange] = [] @State private var selection = GitChangeSelection() @State private var sectionsCache = GitChangeSectionsCache() @@ -84,22 +86,7 @@ struct ChangesSidebarView: View { .onChange(of: feature.requestedStashReference) { _ in selectRequestedStashIfNeeded() } - .confirmationDialog( - "Discard changes to selected files?", - isPresented: Binding(get: { !pendingDiscardSelection.isEmpty }, - set: { if !$0 { pendingDiscardSelection = [] } }), - titleVisibility: .visible - ) { - Button("Discard Changes", role: .destructive) { - let changes = pendingDiscardSelection - pendingDiscardSelection = [] - Task { await feature.discardChanges(changes) } - } - Button("Cancel", role: .cancel) { pendingDiscardSelection = [] } - } message: { - Text(pendingDiscardSelection.map(\.path).joined(separator: "\n") - + "\nThis action cannot be undone by Lithe. Untracked files will be deleted.") - } + .modifier(GitRollbackPresentation(feature: feature)) .confirmationDialog( "Drop \(pendingDropStash?.reference ?? "stash")?", isPresented: Binding( @@ -237,7 +224,9 @@ struct ChangesSidebarView: View { .help("Refresh changes") Button { - pendingDiscardSelection = selectedChanges + let changes = displayedChanges.filter { feature.changelists.listID(for: $0) == feature.changelists.activeID } + feature.requestRollbackChanges(changes, included: GitRollbackSelection.initialTargets( + changes: changes, selectedIDs: selection.ids)) } label: { LitheIDEAIcon( resourcePath: "expui/vcs/revert.svg", @@ -246,8 +235,10 @@ struct ChangesSidebarView: View { preservesOriginalColors: true ) } - .litheToolbarIconButton(isEnabled: !selectedChanges.isEmpty) - .help("Discard selected change") + .litheToolbarIconButton(isEnabled: !feature.activeChangelistChanges.isEmpty && feature.canRollbackChanges) + .help("Rollback Changes") + .accessibilityLabel("Rollback Changes") + .accessibilityIdentifier("git-rollback") Button { Task { await feature.stageAllChanges() } @@ -504,10 +495,6 @@ struct ChangesSidebarView: View { return name.isEmpty ? root.path : name } - private var selectedChanges: [GitChange] { - selection.actionTargets(in: displayedChanges) - } - private var visibleChangeIDs: [String] { changeRows.compactMap { row in if case .change(let change, _) = row.content { return change.id } @@ -520,6 +507,14 @@ struct ChangesSidebarView: View { let shouldStage = !targets.allSatisfy(isEffectivelyStaged) var items: [LitheContextMenuItem] = [] let isFile = !change.isUntrackedDirectory + items.append(.action("Commit Files…", isEnabled: prepareCommitFiles != nil && feature.canPrepareCommitFiles(targets)) { + prepareCommitFiles?(targets) + }) + items.append(.action("Rollback Changes", role: .destructive, + isEnabled: feature.canRollbackChanges && !targets.isEmpty) { + feature.requestRollbackChanges(displayedChanges, included: targets) + }) + items.append(.separator) if change.kind != .deleted && isFile { items.append(.action("Open", systemImage: "doc.text", action: { openFile(change.url, change.path) @@ -529,6 +524,9 @@ struct ChangesSidebarView: View { items.append(.action("Show Diff", systemImage: "doc.text.magnifyingglass", action: { selectChange(change) })) + items.append(.action("Show Diff in New Tab", isEnabled: showDiffInNewTab != nil) { + showDiffInNewTab?(change) + }) } items.append(.submenu("Move to ChangeList", items: feature.changelists.lists.map { list in .action(list.displayName, isEnabled: !feature.changelistEditingDisabled, @@ -541,14 +539,14 @@ struct ChangesSidebarView: View { isEnabled: !feature.isCommitting && !feature.changelistStorageFailed, action: { setStaging(targets, shouldStage) } )) - if targets.contains(where: \.hasWorkingTreeChange) { - items.append(.action( - "Discard Changes", - systemImage: "trash", - role: .destructive, - action: { pendingDiscardSelection = targets } - )) - } + items.append(.action("Delete…", role: .destructive, + isEnabled: deleteFiles != nil && feature.canRollbackChanges + && !targets.isEmpty && targets.allSatisfy { $0.kind != .deleted && $0.repositoryRoot == change.repositoryRoot }) { + deleteFiles?(targets) + }) + items.append(.action("Add to VCS", isEnabled: feature.canRollbackChanges && targets.contains(where: \.isUntracked)) { + setStaging(targets.filter(\.isUntracked), true) + }) items += [ .separator, .action( diff --git a/macos/Sources/Lithe/Views/Git/CommitAreaView.swift b/macos/Sources/Lithe/Views/Git/CommitAreaView.swift index 97a6bcbbf8..6c36e9347f 100644 --- a/macos/Sources/Lithe/Views/Git/CommitAreaView.swift +++ b/macos/Sources/Lithe/Views/Git/CommitAreaView.swift @@ -17,6 +17,9 @@ struct CommitAreaView: View { } .toggleStyle(.checkbox) .font(LitheTheme.uiFont(size: LitheTheme.Commit.amendFontSize)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .accessibilityIdentifier("git-commit-amend") LitheIDEAIcon(resourcePath: "expui/general/history.svg", size: LitheTheme.Commit.actionIconSize, fallbackSystemImage: "clock", preservesOriginalColors: true) Spacer() @@ -46,6 +49,7 @@ struct CommitAreaView: View { Text("\(stagedChanges.count) staged") .font(LitheTheme.uiFont(size: LitheTheme.Commit.metadataFontSize)) .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) } .padding(.horizontal, LitheTheme.Commit.contentInset) .padding(.top, LitheTheme.Commit.messageVerticalGap) @@ -119,6 +123,7 @@ struct CommitAreaView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(hasBackgroundImage ? Color.clear : LitheTheme.toolHeader) + .onChange(of: draft.commitEditorRequestVersion) { _ in commitMessageFocused = true } .confirmationDialog( "Replace current commit message?", isPresented: Binding( diff --git a/macos/Sources/Lithe/Views/Git/CommitFileDeletionPresentation.swift b/macos/Sources/Lithe/Views/Git/CommitFileDeletionPresentation.swift new file mode 100644 index 0000000000..8b0720270f --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/CommitFileDeletionPresentation.swift @@ -0,0 +1,51 @@ +import LitheModuleAPI +import SwiftUI + +struct CommitFileDeletionPresentation: ViewModifier { + @ObservedObject var feature: CommitFileDeletionFeatureModel + + func body(content: Content) -> some View { + content.background(LitheCenteredPopup(isPresented: Binding( + get: { feature.pending != nil }, set: { if !$0 { feature.cancel() } }), + dialogTitle: L10n.string("Delete Files")) { + if let request = feature.pending { + CommitFileDeletionDialog(request: request, onCancel: { feature.cancel() }, + onDelete: { feature.beginConfirmation(request) }) + } + }.frame(width: 0, height: 0).allowsHitTesting(false)) + } +} + +struct CommitFileDeletionDialog: View { + let request: CommitFileDeletionFeatureModel.Request + let onCancel: () -> Void + let onDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Group { + if request.usesVcs { + Text("The version control provider will delete these files and record their removal. They may not be moved to Trash.") + } else { + Text("These files will be moved to the macOS Trash.") + } + } + .font(LitheTheme.uiFont(size: 13)) + .accessibilityIdentifier("commit-files-delete-warning") + ScrollView { + Text(verbatim: request.urls.map(\.path).joined(separator: "\n")) + .font(LitheTheme.uiFont(size: 12)).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + }.litheScrollViewChrome().frame(maxHeight: 200) + HStack(spacing: LitheTheme.ActionButton.spacing) { + Spacer() + Button("Cancel", action: onCancel) + .buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) + .accessibilityIdentifier("commit-files-delete-cancel") + Button("Delete", action: onDelete) + .buttonStyle(LitheActionButtonStyle(isPrimary: true)).keyboardShortcut(.defaultAction) + .accessibilityIdentifier("commit-files-delete-confirm") + } + }.padding(20).frame(width: 520).background(LitheCommitDialogStyle.background) + } +} diff --git a/macos/Sources/Lithe/Views/Git/GitRollbackDialog.swift b/macos/Sources/Lithe/Views/Git/GitRollbackDialog.swift new file mode 100644 index 0000000000..ac18e9831d --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitRollbackDialog.swift @@ -0,0 +1,128 @@ +import AppKit +import LitheModuleAPI +import SwiftUI +import LitheGitModule + +/// Inclusion here is only a rollback draft: toggling never stages a file. +struct GitRollbackDialog: View { + @State var selection: GitRollbackSelection + let onClose: () -> Void + let onRollback: (GitRollbackSelection) -> Void + @State private var highlightedIDs: Set = [] + @State private var collapsedFolders: Set = [] + + private struct Row: Identifiable { + let id: String + let root: URL + let name: String + let depth: Int + let changes: [GitChange] + let change: GitChange? + } + + private var rows: [Row] { + var roots: [URL] = [] + var seen: Set = [] + let groups = Dictionary(grouping: selection.changes) { $0.repositoryRoot.standardizedFileURL } + for change in selection.changes { + let root = change.repositoryRoot.standardizedFileURL + if seen.insert(root).inserted { roots.append(root) } + } + return roots.flatMap { root in + let changes = groups[root] ?? [] + let tree = GitCommitFileTreeNode.build(from: changes.map { + GitCommitFile(status: $0.displayStatus, path: $0.path) + }, rootName: root.lastPathComponent) + let byPath = Dictionary(grouping: changes, by: \.path) + func append(_ node: GitCommitFileTreeNode, depth: Int) -> [Row] { + let id = "folder:" + root.path + ":" + node.id + let descendants = changes.filter { node.path.isEmpty || $0.path.hasPrefix(node.path + "/") } + var result = [Row(id: id, root: root, name: node.name, depth: depth, changes: descendants, change: nil)] + guard !collapsedFolders.contains(id) else { return result } + for directory in node.directories { result += append(directory, depth: depth + 1) } + for file in node.files { + for change in byPath[file.path] ?? [] { + result.append(Row(id: change.id, root: root, name: change.url.lastPathComponent, + depth: depth + 1, changes: [change], change: change)) + } + } + return result + } + return append(tree, depth: 0) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + LitheTreeList(rows, id: \.id, selection: $highlightedIDs, hierarchy: { row in + .init(depth: row.depth, isBranch: row.change == nil, + isExpanded: !collapsedFolders.contains(row.id)) + }, onExpansion: { row, expanded in + if expanded { collapsedFolders.remove(row.id) } else { collapsedFolders.insert(row.id) } + }) { row in + HStack(spacing: LitheTheme.Tree.iconTextGap) { + LitheTreeCheckbox(state: checkboxState(row.changes), accessibilityTitle: "Include in rollback") { + selection.toggle(row.changes) + } + .accessibilityIdentifier("git-rollback-include-" + row.id) + if let change = row.change { + LitheIcon(kind: LitheIcons.kind(for: change.url, isDirectory: change.isUntrackedDirectory), + size: LitheTheme.Tree.iconSize) + Text(verbatim: row.name).foregroundStyle(change.fileNameColor) + .lineLimit(1).help(change.url.path) + } else { + LitheIcon(kind: .folder, size: LitheTheme.Tree.iconSize) + Text(verbatim: row.name).help(row.root.path) + Text("\(row.changes.reduce(0) { $0 + $1.fileCount }) files").foregroundStyle(LitheTheme.secondaryText) + } + Spacer(minLength: 0) + }.font(LitheTheme.uiFont(size: 13)) + } + .frame(minHeight: 220, idealHeight: 320, maxHeight: .infinity) + .background(LitheTheme.inputBackground) + + Text("\(selection.includedChanges.reduce(0) { $0 + $1.fileCount }) files selected") + .font(LitheTheme.uiFont(size: 12)).foregroundStyle(LitheTheme.secondaryText) + Toggle("Delete local copies of added files", isOn: $selection.deleteAddedFiles) + .toggleStyle(.checkbox).disabled(!selection.hasIncludedAdditions) + Text("Staged and unstaged changes in the checked files will be rolled back. This cannot be undone by Lithe.") + .font(LitheTheme.uiFont(size: 12)).foregroundStyle(LitheTheme.warning) + HStack(spacing: LitheTheme.ActionButton.spacing) { + Spacer() + Button("Close", action: onClose) + .buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) + .accessibilityIdentifier("git-rollback-close") + Button("Rollback") { onRollback(selection) } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)).keyboardShortcut(.defaultAction) + .disabled(selection.includedChanges.isEmpty) + .accessibilityIdentifier("git-rollback-confirm") + } + } + .padding(20).frame(width: 540, height: 520) + .background(LitheCommitDialogStyle.background) + } + + private func checkboxState(_ changes: [GitChange]) -> NSControl.StateValue { + let included = changes.filter { selection.includedIDs.contains($0.id) }.count + return included == changes.count ? .on : included == 0 ? .off : .mixed + } +} + +struct GitRollbackPresentation: ViewModifier { + @ObservedObject var feature: GitFeatureModel + @Environment(\.locale) private var locale + + func body(content: Content) -> some View { + content.background(LitheCenteredPopup(isPresented: Binding( + get: { feature.pendingRollbackSelection != nil }, + set: { if !$0 { feature.cancelRollbackChanges() } }), + dialogTitle: L10n.string("Rollback Changes", locale: locale)) { + if let selection = feature.pendingRollbackSelection { + GitRollbackDialog(selection: selection, onClose: { feature.cancelRollbackChanges() }, onRollback: { captured in + feature.cancelRollbackChanges() + Task { await feature.confirmRollbackChanges(captured) } + }).id(selection.id) + } + }.frame(width: 0, height: 0).allowsHitTesting(false)) + } +} diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreeActions.swift b/macos/Sources/Lithe/Views/Git/GitWorktreeActions.swift index 76dd42c188..72c4058d2b 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreeActions.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreeActions.swift @@ -13,4 +13,7 @@ struct GitWorktreeActions { var openFile: ((URL, String) -> Void)? = nil var commitSession: ((GitWorktree) -> WorktreeCommitSession)? = nil var showCommitSettings: () -> Void = {} + var showDiffInNewTab: ((GitChange) -> Void)? = nil + var prepareCommitFiles: (([GitChange], WorktreeCommitSession) -> Void)? = nil + var deleteFiles: (([GitChange], GitFeatureModel) -> Void)? = nil } diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreeChangesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreeChangesView.swift index bfbeec42e0..dbf5259710 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreeChangesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreeChangesView.swift @@ -14,7 +14,6 @@ struct GitWorktreeChangesView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.locale) private var locale @State private var selection = GitChangeSelection() - @State private var pendingDiscard: [GitChange] = [] @State private var collapsedLists: Set = [] @State private var sectionsCache = GitChangeSectionsCache() @@ -33,6 +32,7 @@ struct GitWorktreeChangesView: View { /// does not flash them grey. Only a refresh or operation the user started greys them. private var checkboxesLookEnabled: Bool { !feature.isPerformingWorktreeOperation && !feature.isCommitting + && !rollbackFeature.isRollingBackChanges && commitSession?.feature.isCommitting != true && commitSession?.feature.changelistStorageFailed != true && !(feature.isRefreshingWorktreeChanges && !feature.isMutatingWorktreeChanges) } @@ -67,19 +67,27 @@ struct GitWorktreeChangesView: View { } private var fileList: some View { + fileListContent.modifier(GitRollbackPresentation(feature: rollbackFeature)) + } + + private var rollbackFeature: GitFeatureModel { commitSession?.feature ?? feature.commitFeature(for: worktree) } + + private var fileListContent: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 2) { toolbarButton("Refresh changes", icon: "expui/general/refresh.svg", enabled: enabled) { Task { await feature.refreshWorktreeChanges(worktree) } } - // Same set as the Commit toolbar. Only discarding needs a selection, because it cannot - // be undone; the rest work on every file when nothing is selected. + // Rollback reviews its own inclusion draft before any file is changed. toolbarButton("Stage all files", icon: "expui/general/download.svg", enabled: enabled && activeChanges.contains { GitFileContextMenuPlan(change: $0).showsAdd }) { setStaging(activeChanges, true) } - toolbarButton("Discard selected change", icon: "expui/vcs/revert.svg", - enabled: enabled && targets.contains(where: \.hasWorkingTreeChange)) { pendingDiscard = targets } + toolbarButton("Rollback Changes", icon: "expui/vcs/revert.svg", + enabled: enabled && rollbackFeature.canRollbackChanges && !activeChanges.isEmpty) { + rollbackFeature.requestRollbackChanges(activeChanges, included: GitRollbackSelection.initialTargets( + changes: activeChanges, selectedIDs: selection.ids)) + } toolbarButton("Show Diff", icon: "expui/general/show.svg", enabled: diffTarget != nil && actions.showChange != nil) { if let diffTarget { actions.showChange?(diffTarget) } @@ -135,19 +143,6 @@ struct GitWorktreeChangesView: View { } } .onChange(of: changes) { selection.retain($0.map(\.id)) } - .confirmationDialog("Discard changes to selected files?", - isPresented: Binding(get: { !pendingDiscard.isEmpty }, set: { if !$0 { pendingDiscard = [] } }), - titleVisibility: .visible) { - Button("Discard Changes", role: .destructive) { - let captured = pendingDiscard - pendingDiscard = [] - Task { await feature.discardWorktreeChanges(captured, in: worktree) } - } - Button("Cancel", role: .cancel) { pendingDiscard = [] } - } message: { - Text(worktree.path + "\n" + pendingDiscard.map(\.path).joined(separator: "\n")) - Text("This action cannot be undone by Lithe. Untracked files will be deleted.") - } } private var targets: [GitChange] { selection.actionTargets(in: changes) } @@ -198,17 +193,34 @@ struct GitWorktreeChangesView: View { func menu(for change: GitChange) -> [LitheContextMenuItem] { let targets = selection.actionTargets(in: changes, clicked: change) return [ + .action("Commit Files…", isEnabled: enabled && actions.prepareCommitFiles != nil + && commitSession != nil && rollbackFeature.canPrepareCommitFiles(targets)) { + if let commitSession { actions.prepareCommitFiles?(targets, commitSession) } + }, + .action("Rollback Changes", role: .destructive, isEnabled: enabled && rollbackFeature.canRollbackChanges && !targets.isEmpty) { + rollbackFeature.requestRollbackChanges(changes, included: targets) + }, + .separator, .action("Open", isEnabled: !change.isUntrackedDirectory && change.kind != .deleted && actions.openFile != nil) { actions.openFile?(change.url, change.path) }, .action("Show Diff", isEnabled: !change.isUntrackedDirectory && actions.showChange != nil) { actions.showChange?(change) }, + .action("Show Diff in New Tab", isEnabled: !change.isUntrackedDirectory && actions.showDiffInNewTab != nil) { + actions.showDiffInNewTab?(change) + }, .submenu("Move to ChangeList", items: commitSession?.feature.changelists.lists.map { list in .action(list.displayName, isEnabled: enabled) { commitSession?.feature.moveChanges(targets, toChangelist: list.id) } } ?? []), .separator, .action("Stage Files", isEnabled: enabled && targets.contains { GitFileContextMenuPlan(change: $0).showsAdd }) { setStaging(targets, true) }, .action("Unstage Files", isEnabled: enabled && targets.contains(where: \.isStaged)) { setStaging(targets, false) }, - .action("Discard Changes", role: .destructive, isEnabled: enabled && targets.contains(where: \.hasWorkingTreeChange)) { pendingDiscard = targets }, + .action("Delete…", role: .destructive, isEnabled: enabled && actions.deleteFiles != nil + && !targets.isEmpty && targets.allSatisfy { $0.kind != .deleted }) { + actions.deleteFiles?(targets, rollbackFeature) + }, + .action("Add to VCS", isEnabled: enabled && targets.contains(where: \.isUntracked)) { + setStaging(targets.filter(\.isUntracked), true) + }, .separator, .action("Show in Finder") { actions.reveal(change.kind == .deleted ? change.url.deletingLastPathComponent() : change.url) }, .action("Copy Path") { actions.copyPath(change.url) } diff --git a/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift b/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift index bca43a4987..3fd9db0cca 100644 --- a/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift +++ b/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift @@ -192,8 +192,6 @@ struct VcsFileDiffSheet: View { 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) { @@ -209,12 +207,21 @@ struct VcsFileDiffSheet: View { } .padding(12) Rectangle().fill(LitheTheme.divider).frame(height: 1) - content + VcsFileDiffContent(diff: diff, fontFamily: fontFamily) } .frame(minWidth: 640, idealWidth: 960, minHeight: 400, idealHeight: 640) .background(LitheTheme.editor) - .task(id: diff.id) { await buildRows() } } +} + +/// The same native comparison renderer for a dialog and a persistent editor tab. +struct VcsFileDiffContent: View { + let diff: VcsFileDiff + let fontFamily: String + @State private var rows: [DiffRow]? + @State private var isOverLimit = false + + var body: some View { content.task(id: diff.id) { await buildRows() } } @ViewBuilder private var content: some View { @@ -262,7 +269,7 @@ struct VcsFileDiffSheet: View { guard !diff.isBinary, !diff.isTooLarge else { return } let base = diff.base ?? "" let current = diff.current ?? "" - let limit = Self.lineLimit + let limit = VcsFileDiffSheet.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 } diff --git a/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift index 5532493ea7..03936fdc65 100644 --- a/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift +++ b/macos/Sources/Lithe/Views/Vcs/VcsProviderToolWindowView.swift @@ -10,6 +10,8 @@ struct VcsProviderToolWindowActions { let revealInFinder: (URL) -> Void let copyPath: (URL) -> Void let hide: () -> Void + var showDiffInNewTab: ((VcsChange, String) -> Void)? = nil + var deleteFiles: (([VcsChange], String) -> Void)? = nil } /// The tool window of one plugin VCS, laid out like IDEA's Commit window: @@ -73,7 +75,7 @@ struct VcsProviderToolWindowView: View { .workbenchHoverTooltipScope() .onAppear(perform: loadDraft) .onChange(of: root?.id) { _ in - selection = []; inclusion = [:]; notice = nil; historyPath = nil + selection = []; inclusion = [:]; notice = nil; historyPath = nil; pendingRollback = [] loadDraft() } .onChange(of: message) { text in @@ -544,16 +546,26 @@ struct VcsProviderToolWindowView: View { } } - private func contextMenu(for change: VcsChange, root: VcsRootState) -> [LitheContextMenuItem] { - let selected = selectedChanges(root) + func contextMenu(for change: VcsChange, root: VcsRootState) -> [LitheContextMenuItem] { + let selected = Self.actionTargets(root.snapshot?.changes ?? [], selection: selection, clicked: change) let url = root.url.appendingPathComponent(change.path) let exists = ![.deleted, .missing].contains(change.status) let busy = feature.runningOperation != nil var items: [LitheContextMenuItem] = [] + items.append(.action("Commit Files…", isEnabled: root.supports(.checkin) && !busy && !selected.isEmpty + && !selected.contains(where: { Self.group(of: $0) == .conflicts })) { + let paths = Set(selected.map(\.path)) + inclusion = Dictionary(uniqueKeysWithValues: (root.snapshot?.changes ?? []).map { ($0.path, paths.contains($0.path)) }) + tab = .changes + messageFocused = true + }) if root.supports(.baseContent) { items.append(.action(L10n.string("Show Diff"), localizesTitle: false, isEnabled: selected.count == 1) { showDiff(change, in: root) }) + items.append(.action("Show Diff in New Tab", isEnabled: selected.count == 1 && actions.showDiffInNewTab != nil) { + actions.showDiffInNewTab?(change, root.id) + }) } items.append(.action(L10n.string("Open"), localizesTitle: false, isEnabled: exists) { actions.openFile(url) }) if root.supports(.fileHistory) && change.status != .unversioned { @@ -563,6 +575,15 @@ struct VcsProviderToolWindowView: View { }) } items.append(.separator) + items.append(.action("Delete…", role: .destructive, + isEnabled: !busy && actions.deleteFiles != nil && !selected.isEmpty + && (feature.canTrash(selected, in: root.id) || feature.canDelete(selected, in: root.id))) { + actions.deleteFiles?(selected, root.id) + }) + items.append(.action("Add to VCS", isEnabled: feature.canAdd(selected, in: root.id)) { + Task { _ = await feature.add(selected, in: root.id) } + }) + items.append(.separator) if root.supports(.conflicts) && !conflicted(selected).isEmpty { items.append(.action(L10n.string("Mark as Resolved"), localizesTitle: false, isEnabled: !busy) { resolve(conflicted(selected), in: root) @@ -581,6 +602,10 @@ struct VcsProviderToolWindowView: View { return items } + static func actionTargets(_ changes: [VcsChange], selection: Set, clicked: VcsChange) -> [VcsChange] { + changes.filter { selection.contains(clicked.path) ? selection.contains($0.path) : $0.path == clicked.path } + } + private func selectedChanges(_ root: VcsRootState) -> [VcsChange] { (root.snapshot?.changes ?? []).filter { selection.contains($0.path) } } diff --git a/macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift b/macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift index 559abcc0f4..b4eba8b65e 100644 --- a/macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift +++ b/macos/Sources/Lithe/Views/Workbench/VcsProviderToolWindowRegistration.swift @@ -67,7 +67,9 @@ extension WorkbenchModuleUIRegistry { 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) } + hide: { [weak model] in model?.workbenchFeature.setVisibility(tool, isVisible: false) }, + showDiffInNewTab: { [weak model] in model?.showVcsDiffInNewTab($0, rootID: $1) }, + deleteFiles: { [weak model] in model?.requestDeleteVcsFiles($0, rootID: $1) } ) )) }, diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index 9a8791904f..3903502ec2 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -213,7 +213,10 @@ enum WorkbenchModuleUIComposition { showLocalHistory: { model.showLocalHistory(for: $0) }, revealInFinder: { model.revealProjectItemInFinder($0) }, copyPath: { model.copyProjectItemPath($0, relative: $1) }, - showSettings: { model.showSettings(category: $0) } + showSettings: { model.showSettings(category: $0) }, + prepareCommitFiles: { model.prepareCommitFiles($0, feature: feature, draft: model.commitDraftFeature) }, + showDiffInNewTab: { model.selectChange($0, inNewTab: true) }, + deleteFiles: { model.requestDeleteCommitFiles($0, feature: feature) } )) }, contentIdentity: { AnyHashable([WorkbenchModuleUIRegistry.Renderer.featureIdentity($0.gitFeatureIfActive), @@ -259,7 +262,12 @@ enum WorkbenchModuleUIComposition { showChange: { [weak model] in model?.selectChange($0) }, openFile: { [weak model] in model?.openFile($0, displayPath: $1) }, commitSession: { [unowned model] in model.worktreeCommitSession(feature: feature, worktree: $0) }, - showCommitSettings: { [weak model] in model?.showSettings(category: .ai) } + showCommitSettings: { [weak model] in model?.showSettings(category: .ai) }, + showDiffInNewTab: { [weak model] in model?.selectChange($0, inNewTab: true) }, + prepareCommitFiles: { [weak model] changes, session in + model?.prepareCommitFiles(changes, feature: session.feature, draft: session.draft) + }, + deleteFiles: { [weak model] changes, scoped in model?.requestDeleteCommitFiles(changes, feature: scoped) } ), presentationCache: model.gitLogPresentationCache )) diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 9c96371ff4..25f81763cd 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -282,7 +282,7 @@ struct WorkbenchView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @StateObject private var toolWindowDrag = ToolWindowDragState() @StateObject private var toolContentStore = WorkbenchToolContentStore() - @State private var sidebarWidth: CGFloat = 320 + @State private var sidebarWidth = CGFloat(WorkbenchLayout.defaultSidebarWidth) @State private var rightSidebarWidth: CGFloat = 380 @State private var mavenPaneWidth = CGFloat(WorkbenchLayout.defaultMavenPaneWidth) @State private var branchPopupHeight: CGFloat? @@ -1648,7 +1648,7 @@ struct WorkbenchView: View { GeometryReader { geometry in let leading = hasVisibleTool(on: .left) ? min(sidebarWidth, geometry.size.width) : 0 let trailing = hasVisibleTool(on: .right) ? min(mavenPaneWidth, geometry.size.width - leading) : 0 - let height = isBottomToolVisible ? min(topPaneHeight ?? geometry.size.height * 2 / 3, geometry.size.height) + let height = isBottomToolVisible ? min(topPaneHeight ?? geometry.size.height * WorkbenchLayout.defaultTopPaneFraction, geometry.size.height) : geometry.size.height Rectangle().fill(LitheTheme.toolWindowDropArea) .overlay { @@ -2093,7 +2093,7 @@ private struct WorkbenchWorkspaceSplitView = [] private var completedRefreshRequestID: UInt64 = 0 private var refreshCompletionWaiters: [UUID: CheckedContinuation] = [:] + private var changeRefreshRevision: UInt64 = 0 + private var repositoryChangeRefreshes: [URL: UUID] = [:] private var isLoadingInitialGitConsoleEntry = false private var hasLoadedInitialGitConsoleEntry = false private var gitConsoleRepositoryGeneration: UInt64 = 0 @@ -584,10 +594,8 @@ package final class GitFeatureModel: ObservableObject { }) scoped.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { [weak self] in self?.notify?($0) }, onStateRefreshed: { [weak self] in - guard let self else { return } - if self.gitWorktreeInspection?.worktreeID == worktree.id { - await self.refreshWorktreeChanges(worktree) - } + guard let self, let scoped = self.worktreeCommitFeatures[root] else { return } + self.updateWorktreeInspection(scoped.gitChanges.filter { !scoped.isListedUntrackedContent($0) }, at: root) }) scoped.worktreeOperationHost = self if changelistWorkspace == root { @@ -625,6 +633,9 @@ package final class GitFeatureModel: ObservableObject { || allDiffSessions.contains { $0.isLoadingDiff || $0.isLoadingBranchComparison } || isRefreshingGit || isCommitting + || isRollingBackChanges + || isPreparingCommitFiles + || isDeletingCommitFiles || isLoadingGitHistory || isLoadingMoreGitHistory || isLoadingBranchComparison @@ -658,6 +669,8 @@ package final class GitFeatureModel: ObservableObject { gitChanges = [] expandedUntrackedDirectoryIDs = [] pendingStagingStates = [:] + changeRefreshRevision &+= 1 + repositoryChangeRefreshes = [:] gitStashes = [] gitShelves = [] gitWorktrees = [] @@ -697,8 +710,14 @@ package final class GitFeatureModel: ObservableObject { diffHunks = [] gitDiffWhitespaceMode = .doNotIgnore isLoadingDiff = false - isRefreshingGit = false + finishCurrentGitRefresh() pendingDiscardChange = nil + pendingRollbackSelection = nil + isRollingBackChanges = false + isPreparingCommitFiles = false + isDeletingCommitFiles = false + activeRollbackID = nil + rollbackContextID = UUID() pendingDiscardHunk = nil isCommitting = false pendingSubmoduleCommitPlan = nil @@ -762,9 +781,10 @@ package final class GitFeatureModel: ObservableObject { _ requestID: UInt64, workspaceURLProvider: @MainActor () -> URL? ) async { + let generation = workspaceCommitGeneration if isRefreshingGit { await waitForCurrentGitRefresh() - guard !Task.isCancelled else { return } + guard !Task.isCancelled, workspaceCommitGeneration == generation else { return } if completedRefreshRequestID < requestID { await fulfillGitRefreshRequest(requestID, workspaceURLProvider: workspaceURLProvider) } @@ -777,15 +797,20 @@ package final class GitFeatureModel: ObservableObject { } isRefreshingGit = true - defer { finishCurrentGitRefresh() } - repeat { - guard !Task.isCancelled else { return } + defer { if workspaceCommitGeneration == generation { finishCurrentGitRefresh() } } + while true { + guard !Task.isCancelled, workspaceCommitGeneration == generation else { return } let processingRequestID = activeRefreshRequestIDs.max() ?? requestID + let revision = changeRefreshRevision await refreshGitState(at: workspaceURL) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, workspaceCommitGeneration == generation else { return } + guard workspaceURLProvider() == workspaceURL else { return } + // A mutation may publish newer status while this workspace-wide read + // waits on another repository. Never overwrite it with that old read. + if revision != changeRefreshRevision { continue } completedRefreshRequestID = max(completedRefreshRequestID, processingRequestID) - } while activeRefreshRequestIDs.contains(where: { $0 > completedRefreshRequestID }) - && workspaceURLProvider() == workspaceURL + if !activeRefreshRequestIDs.contains(where: { $0 > completedRefreshRequestID }) { break } + } } private func waitForCurrentGitRefresh() async { @@ -820,10 +845,12 @@ package final class GitFeatureModel: ObservableObject { private func refreshGitState(at workspaceURL: URL) async { guard !Task.isCancelled else { return } + let revision = changeRefreshRevision var didChange = false var historyContextChanged = false let repositoryRoots = await repositoryRootsProvider(workspaceURL) - guard !Task.isCancelled, workspaceURLProvider?()?.standardizedFileURL == workspaceURL.standardizedFileURL else { return } + guard !Task.isCancelled, revision == changeRefreshRevision, + workspaceURLProvider?()?.standardizedFileURL == workspaceURL.standardizedFileURL else { return } if availableRepositoryRoots != repositoryRoots { availableRepositoryRoots = repositoryRoots didChange = true @@ -834,14 +861,19 @@ package final class GitFeatureModel: ObservableObject { requestedRepositoryReference = nil } let observation = await workspaceSnapshot(workspaceURL: workspaceURL, repositoryRoots: repositoryRoots) + guard !Task.isCancelled, revision == changeRefreshRevision, + workspaceURLProvider?()?.standardizedFileURL == workspaceURL.standardizedFileURL else { return } if let observation { let snapshot = observation.snapshot guard !Task.isCancelled, workspaceURLProvider?()?.standardizedFileURL == workspaceURL.standardizedFileURL else { return } - let changes = await listingExpandedUntrackedDirectories(in: snapshot.changes) - guard !Task.isCancelled else { return } + let changes = await listingExpandedUntrackedDirectories(in: snapshot.changes, + isCurrent: { revision == self.changeRefreshRevision }) + guard !Task.isCancelled, revision == changeRefreshRevision else { return } loadChangelistsIfNeeded() let changesChanged = gitChanges != changes if gitRepositoryRoot != snapshot.repositoryRoot { + pendingRollbackSelection = nil + rollbackContextID = UUID() resetHistoryForRepositoryChange() gitRepositoryRoot = snapshot.repositoryRoot publishGitJournal() @@ -896,25 +928,25 @@ package final class GitFeatureModel: ObservableObject { didChange = didChange || previousFilter != gitConflictFilterPaths } let stashes = await stashesProvider(snapshot.repositoryRoot) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, revision == changeRefreshRevision else { return } if gitStashes != stashes { gitStashes = stashes didChange = true } let shelves = await shelveService?.entries(for: snapshot.repositoryRoot) ?? [] - guard !Task.isCancelled else { return } + guard !Task.isCancelled, revision == changeRefreshRevision else { return } if gitShelves != shelves { gitShelves = shelves didChange = true } let operationState = await operationStateProvider(snapshot.repositoryRoot) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, revision == changeRefreshRevision else { return } if gitOperationState != operationState { gitOperationState = operationState didChange = true } await interactiveRebase.refreshSession(at: snapshot.repositoryRoot) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, revision == changeRefreshRevision else { return } if let gitOperationState, deferredSavedChanges == nil, let stash = gitStashes.first(where: { $0.message.contains("Lithe auto-stash before") }) { deferredSavedChanges = GitDeferredSavedChanges( @@ -924,10 +956,11 @@ package final class GitFeatureModel: ObservableObject { } for session in allDiffSessions where session.selectedChange != nil { + guard !Task.isCancelled, revision == changeRefreshRevision else { return } await refreshWorkingDiff( session, changes: changes, confirmedRoots: observation.successfullyReadRepositoryRoots ) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, revision == changeRefreshRevision else { return } } } else { if gitRepositoryRoot != nil { @@ -1014,11 +1047,17 @@ package final class GitFeatureModel: ObservableObject { /// so the result is rebuilt from the current expansions until none changed /// during a pass. Listings already read are reused, so a pass without new /// expansions does not suspend and the loop ends. - private func listingExpandedUntrackedDirectories(in changes: [GitChange]) async -> [GitChange] { + private func listingExpandedUntrackedDirectories( + in changes: [GitChange], confirmedRoots: Set? = nil, isCurrent: () -> Bool = { true } + ) async -> [GitChange] { var listings: [GitChange.ID: [GitChange]] = [:] // Only expansions this read was asked for can be confirmed gone; one // made meanwhile may come from a newer status than `changes`. - let checked = expandedUntrackedDirectoryIDs + let checked = confirmedRoots.map { roots in + expandedUntrackedDirectoryIDs.intersection(gitChanges.filter { + roots.contains($0.repositoryRoot.standardizedFileURL) + }.map(\.id)) + } ?? expandedUntrackedDirectoryIDs while true { let requested = expandedUntrackedDirectoryIDs guard !requested.isEmpty else { return changes } @@ -1038,6 +1077,7 @@ package final class GitFeatureModel: ObservableObject { result.insert(contentsOf: children, at: index) } } + guard !Task.isCancelled, isCurrent() else { return changes } guard expandedUntrackedDirectoryIDs == requested else { continue } // Forget expansions whose summary is gone. let present = Set(result.map(\.id)) @@ -1087,6 +1127,8 @@ package final class GitFeatureModel: ObservableObject { if let reference { await selectGitReference(reference) } return } + pendingRollbackSelection = nil + rollbackContextID = UUID() requestedRepositoryRoot = root requestedRepositoryReference = reference await refreshGit() @@ -1122,6 +1164,7 @@ package final class GitFeatureModel: ObservableObject { _ session: GitRepositoryDiffSession, changes: [GitChange], confirmedRoots: Set ) async { guard let previous = session.selectedChange, !session.isClosed else { return } + let revision = changeRefreshRevision if let directory = session.directoryURL { await showDirectoryDiff(at: directory, session: session, refreshingConfirmedRoots: confirmedRoots) return @@ -1136,7 +1179,8 @@ package final class GitFeatureModel: ObservableObject { let generation = UUID() session.repositoryDiffGeneration = generation let document = await diffDocumentProvider(updated, session.gitDiffWhitespaceMode) - guard session.repositoryDiffGeneration == generation, !session.isClosed, !Task.isCancelled else { return } + guard session.repositoryDiffGeneration == generation, revision == changeRefreshRevision, + !session.isClosed, !Task.isCancelled else { return } if session.selectedChange != updated { session.selectedChange = updated } session.updateDocument(patch: document.patch, rows: document.rows, hunks: document.hunks) if session.isLoadingDiff { session.isLoadingDiff = false } @@ -1178,6 +1222,7 @@ package final class GitFeatureModel: ObservableObject { refreshingConfirmedRoots: Set? = nil ) async { let session = session ?? activeRepositoryDiffSession + let revision = changeRefreshRevision let directoryPath = directoryURL.standardizedFileURL.path guard let repositoryRoot = (availableRepositoryRoots.isEmpty ? [gitRepositoryRoot].compactMap { $0 } : availableRepositoryRoots) @@ -1239,6 +1284,7 @@ package final class GitFeatureModel: ObservableObject { // A refresh has nothing to show for a cancelled read; a first load // still resolves its loading state below. if refreshing, Task.isCancelled { return } + guard revision == changeRefreshRevision else { return } } guard session.repositoryDiffGeneration == generation, !session.isClosed else { return } if session.selectedChange != summary { session.selectedChange = summary } @@ -1320,12 +1366,18 @@ package final class GitFeatureModel: ObservableObject { func withGitOperation( title: String? = nil, plannedArguments: [String]? = nil, + afterOperation: (@MainActor () async -> Void)? = nil, _ operation: () async -> T ) async -> T { if let worktreeOperationHost { - return await worktreeOperationHost.withGitOperation(title: title, plannedArguments: plannedArguments, operation) + return await worktreeOperationHost.withGitOperation(title: title, plannedArguments: plannedArguments, + afterOperation: afterOperation, operation) + } + if GitExecutionContext.current != nil { + let result = await operation() + await afterOperation?() + return result } - if GitExecutionContext.current != nil { return await operation() } let lease = acquireModuleLease?("Git operation in progress") defer { lease?.release() } let root = gitRepositoryRoot @@ -1402,10 +1454,49 @@ package final class GitFeatureModel: ObservableObject { } trimGitConsole() } + await afterOperation?() await onGitOperationEnded?() return result } + /// Confirm this operation's status before releasing queued filesystem events. + /// The read is outside the user command context and does not rediscover or + /// wait for unrelated repositories, stashes, history or project services. + private func withGitChangeOperation( + _ changes: [GitChange], onWorktreeSettled: @escaping @MainActor () -> Void = {}, + _ operation: () async -> T + ) async -> T { + let generation = workspaceCommitGeneration + let workspace = workspaceURLProvider?()?.standardizedFileURL + let hostGeneration = worktreeOperationHost?.workspaceCommitGeneration + let requests = Dictionary(uniqueKeysWithValues: Set(changes.map { $0.repositoryRoot.standardizedFileURL }).map { ($0, UUID()) }) + invalidateChangeReads(requests) + worktreeOperationHost?.invalidateChangeReads(requests) + return await withGitOperation(afterOperation: { + guard self.workspaceCommitGeneration == generation, + self.workspaceURLProvider?()?.standardizedFileURL == workspace, + self.worktreeOperationHost?.workspaceCommitGeneration == hostGeneration else { return } + // Completion order, not dispatch order, owns the next status read. + // A slower earlier command must still publish its final disk state. + let completedRequests = requests.mapValues { _ in UUID() } + self.invalidateChangeReads(completedRequests) + self.worktreeOperationHost?.invalidateChangeReads(completedRequests) + await self.refreshAfterChangeMutation(requests: completedRequests, onWorktreeSettled: onWorktreeSettled) + }, operation) + } + + private func invalidateChangeReads(_ requests: [URL: UUID]) { + changeRefreshRevision &+= 1 + for (root, request) in requests { + repositoryChangeRefreshes[root] = request + worktreeCommitFeatures[root]?.invalidateChangeReads([root: request]) + } + if let inspection = gitWorktreeInspection, requests.keys.contains(where: { $0.path == inspection.worktreeID }) { + worktreeChangesRequestGeneration &+= 1 + isRefreshingWorktreeChanges = false + } + } + private func recordingGitCommand( _ operation: () async -> GitService.CommandResult ) async -> GitService.CommandResult { @@ -1586,9 +1677,8 @@ package final class GitFeatureModel: ObservableObject { package func stageChange(_ selectedChange: GitChange) async { guard !isCommitting, !isWorktreeCommitBlocked, selectedChange.canToggleStaging else { return } - let result = await withGitOperation { await service.stage(selectedChange) } + let result = await withGitChangeOperation([selectedChange]) { await service.stage(selectedChange) } showResult(result, success: "Staged \(selectedChange.path)") - await refreshAfterChangeMutation([selectedChange]) } package func unstageSelectedChange() async { @@ -1598,23 +1688,20 @@ package final class GitFeatureModel: ObservableObject { package func unstageChange(_ selectedChange: GitChange) async { guard !isCommitting, !isWorktreeCommitBlocked else { return } - let result = await withGitOperation { await service.unstage(selectedChange) } + let result = await withGitChangeOperation([selectedChange]) { await service.unstage(selectedChange) } showResult(result, success: "Unstaged \(selectedChange.path)") - await refreshAfterChangeMutation([selectedChange]) } package func stageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { guard !isCommitting, !isWorktreeCommitBlocked else { return } - let result = await withGitOperation { await service.stage(hunk: hunk, of: change) } + let result = await withGitChangeOperation([change]) { await service.stage(hunk: hunk, of: change) } showResult(result, success: "Staged a change block in \(change.path)") - await refreshAfterChangeMutation([change]) } package func unstageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { guard !isCommitting, !isWorktreeCommitBlocked else { return } - let result = await withGitOperation { await service.unstage(hunk: hunk, of: change) } + let result = await withGitChangeOperation([change]) { await service.unstage(hunk: hunk, of: change) } showResult(result, success: "Unstaged a change block in \(change.path)") - await refreshAfterChangeMutation([change]) } package func requestDiscardHunk(_ hunk: DiffHunk, in change: GitChange) { @@ -1624,11 +1711,10 @@ package final class GitFeatureModel: ObservableObject { // Receive the confirmed value before SwiftUI dismisses and clears the dialog binding. package func confirmDiscardHunk(_ request: DiffHunkRequest) async { pendingDiscardHunk = nil - let result = await withGitOperation { + let result = await withGitChangeOperation([request.change]) { await service.discard(hunk: request.hunk, of: request.change) } showResult(result, success: "Discarded a change block in \(request.change.path)") - await refreshAfterChangeMutation([request.change]) } package func cancelDiscardHunk() { @@ -1657,7 +1743,7 @@ package final class GitFeatureModel: ObservableObject { package func discardChanges(_ changes: [GitChange]) async { let changes = coveringUntrackedDirectoryContents(changes) guard !changes.isEmpty else { return } - _ = await withGitOperation { + _ = await withGitChangeOperation(changes) { var results: [GitService.CommandResult] = [] for change in changes { guard !Task.isCancelled else { break } @@ -1669,13 +1755,90 @@ package final class GitFeatureModel: ObservableObject { } return results } - await refreshAfterChangeMutation(changes) } package func cancelDiscardChange() { pendingDiscardChange = nil } + package var canRollbackChanges: Bool { + // Background reads do not own the index/worktree. Mutation revisions + // reject their stale results; only pending writes must block rollback. + !isCommitting && !isStagingChanges && !isWorktreeCommitBlocked && !changelistEditingDisabled + && !isRollingBackChanges + && !isPerformingStashOperation && !isPerformingShelfOperation && !isPerformingBranchOperation + && !isPerformingWorktreeOperation && !isMutatingWorktreeChanges && !isRefreshingWorktreeChanges + && !isResolvingGitOperation && !historyEditing.isBusy && !patchExchange.isBusy && !interactiveRebase.isBusy + } + + package func requestRollbackChanges(_ changes: [GitChange], included: [GitChange]) { + let current = Set(gitChanges) + guard canRollbackChanges, !changes.isEmpty, changes.allSatisfy({ current.contains($0) }) else { return } + rollbackContextID = UUID() + rollbackWorkspaceURL = workspaceURLProvider?()?.standardizedFileURL + pendingRollbackSelection = GitRollbackSelection(contextID: rollbackContextID, changes: changes, included: included) + } + + package func cancelRollbackChanges() { pendingRollbackSelection = nil } + + /// Confirmation owns a snapshot, never a later row selection or index checkbox. + package func confirmRollbackChanges(_ selection: GitRollbackSelection) async { + guard selection.contextID == rollbackContextID, rollbackWorkspaceURL == workspaceURLProvider?()?.standardizedFileURL, + canRollbackChanges, + !selection.includedChanges.isEmpty else { return } + pendingRollbackSelection = nil + rollbackContextID = UUID() + let current = Set(gitChanges) + guard selection.includedChanges.allSatisfy({ current.contains($0) }) else { + notify?(L10n.string("The selected changes changed. Reopen the rollback dialog and review them again.")) + return + } + let contextID = rollbackContextID + let workspace = rollbackWorkspaceURL + let changes = coveringUntrackedDirectoryContents(selection.includedChanges) + activeRollbackID = contextID + isRollingBackChanges = true + defer { + if activeRollbackID == contextID { activeRollbackID = nil; isRollingBackChanges = false } + } + func isCurrent() -> Bool { + rollbackContextID == contextID && workspace == workspaceURLProvider?()?.standardizedFileURL && !Task.isCancelled + && GitExecutionContext.current?.isCancellationRequested != true + } + _ = await withGitChangeOperation(changes, onWorktreeSettled: { + if self.activeRollbackID == contextID { self.activeRollbackID = nil; self.isRollingBackChanges = false } + }) { + var results: [GitService.CommandResult] = [] + for change in changes { + guard isCurrent() else { break } + let target = GitRollbackSelection.operationTarget(change) + let result: GitService.CommandResult + // Tracked paths are restored in one Core operation. Only new + // paths need unstaging first (including intent-to-add entries). + if GitRollbackSelection.isAddition(change), + change.isStaged || (change.workTreeStatus == "A" && !change.isConflicted) { + let unstaged = await recordingGitCommand { await service.unstage(target) } + results.append(unstaged) + guard isCurrent() else { break } + if !unstaged.succeeded { showResult(unstaged, success: ""); break } + } + if GitRollbackSelection.isAddition(change) { + // Like IDEA, added files become unversioned unless the user + // explicitly asks to delete their local copies. + if !selection.deleteAddedFiles { continue } + result = await recordingGitCommand { await service.discard(target) } + } else { + result = await recordingGitCommand { await service.discardAll(target) } + } + results.append(result) + guard isCurrent() else { break } + showResult(result, success: L10n.string("Rolled back \(change.path)")) + if !result.succeeded { break } + } + return results + } + } + package func requestConflictRollback(path: String, resume: GitConflictResume) { guard gitChanges.contains(where: { $0.path == path && $0.repositoryRoot == gitRepositoryRoot }) else { notify?("The conflict file is no longer in the working tree") @@ -1750,17 +1913,18 @@ package final class GitFeatureModel: ObservableObject { } package var isStagingChanges: Bool { - !pendingStagingStates.isEmpty || worktreeOperationHost?.pendingStagingStates.values.contains { + isRollingBackChanges || isPreparingCommitFiles || isDeletingCommitFiles || !pendingStagingStates.isEmpty || worktreeOperationHost?.pendingStagingStates.values.contains { $0.repositoryRoot.standardizedFileURL == gitRepositoryRoot?.standardizedFileURL } == true } package var isWorktreeCommitBlocked: Bool { - guard let host = worktreeOperationHost else { return worktreeCommitFeatures.values.contains { $0.isCommitting } } + if isRollingBackChanges || isPreparingCommitFiles || isDeletingCommitFiles { return true } + guard let host = worktreeOperationHost else { return worktreeCommitFeatures.values.contains { $0.isCommitting || $0.isRollingBackChanges || $0.isPreparingCommitFiles || $0.isDeletingCommitFiles } } guard host.gitWorktreeLoadState == .ready, host.gitWorktrees.contains(where: { $0.url.standardizedFileURL == gitRepositoryRoot && !$0.isPrunable && !$0.isBare }) else { return true } - return host.isCommitting || host.isMutatingWorktreeChanges || host.isRefreshingWorktreeChanges - || host.isPerformingWorktreeOperation || host.worktreeCommitFeatures.values.contains { $0 !== self && $0.isCommitting } + return host.isCommitting || host.isRollingBackChanges || host.isPreparingCommitFiles || host.isDeletingCommitFiles || host.isMutatingWorktreeChanges || host.isRefreshingWorktreeChanges + || host.isPerformingWorktreeOperation || host.worktreeCommitFeatures.values.contains { $0 !== self && ($0.isCommitting || $0.isRollingBackChanges || $0.isPreparingCommitFiles || $0.isDeletingCommitFiles) } } /// Staged state the user just asked for, by change ID, while Git is still applying it. @@ -1790,7 +1954,7 @@ package final class GitFeatureModel: ObservableObject { } package func finishToggleStaging(_ change: GitChange, staged: Bool) async { - let result = await withGitOperation { + let result = await withGitChangeOperation([change]) { staged ? await service.stage(change) : await service.unstage(change) @@ -1800,7 +1964,6 @@ package final class GitFeatureModel: ObservableObject { if !result.succeeded { pendingStagingStates.removeValue(forKey: change.id) } - await refreshAfterChangeMutation([change]) } package func finishSetStaging(_ pendingChanges: [GitChange], staged: Bool) async { @@ -1809,7 +1972,7 @@ package final class GitFeatureModel: ObservableObject { var completedChangeIDs = Set() var failedChange: GitChange? var failedResult: GitService.CommandResult? - await withGitOperation { + await withGitChangeOperation(pendingChanges) { for change in pendingChanges { let result = await recordingGitCommand { staged @@ -1834,7 +1997,6 @@ package final class GitFeatureModel: ObservableObject { for change in pendingChanges where !completedChangeIDs.contains(change.id) { pendingStagingStates.removeValue(forKey: change.id) } - await refreshAfterChangeMutation(pendingChanges) } package func stageAllChanges() async { @@ -1843,6 +2005,117 @@ package final class GitFeatureModel: ObservableObject { await finishSetStaging(pendingChanges, staged: true) } + package func canPrepareCommitFiles(_ changes: [GitChange]) -> Bool { + canRollbackChanges && !changes.isEmpty + && changes.allSatisfy { gitChanges.contains($0) && $0.canToggleStaging && !$0.isConflicted } + && Set(changes.map { changelists.listID(for: $0) }).count == 1 + } + + package func deleteCommitFiles(_ changes: [GitChange], action: @MainActor () async -> Bool) async -> Bool { + guard canRollbackChanges, !changes.isEmpty, changes.allSatisfy({ gitChanges.contains($0) }) else { return false } + let generation = workspaceCommitGeneration + isDeletingCommitFiles = true + defer { + if workspaceCommitGeneration == generation { isDeletingCommitFiles = false } + } + let workspace = workspaceURLProvider?()?.standardizedFileURL + for root in Set(changes.map(\.repositoryRoot)) { + let roots = await service.repositories(in: root) + guard generation == workspaceCommitGeneration, workspace == workspaceURLProvider?()?.standardizedFileURL, + !Task.isCancelled, changes.allSatisfy({ gitChanges.contains($0) }) else { return false } + guard roots.contains(root.standardizedFileURL), !changes.contains(where: { change in + let target = change.url.standardizedFileURL.path + return roots.contains { $0.path == target || $0.path.hasPrefix(target + "/") } + }) else { + notify?(L10n.string("Some selected files belong to another version control root. Refresh and select them again.")) + return false + } + } + func isCurrent() -> Bool { + generation == workspaceCommitGeneration && workspace == workspaceURLProvider?()?.standardizedFileURL + && !Task.isCancelled && GitExecutionContext.current?.isCancellationRequested != true + } + return await withGitChangeOperation(changes) { + let deleted = await action() + guard isCurrent() else { return false } + // Trash does not change the index. Clear only selected additions + // that Git confirms are now missing, even after a partial failure. + // Tracked deletions and unrelated/retained index content must remain. + for root in Set(changes.filter(GitRollbackSelection.isAddition).map(\.repositoryRoot)).sorted(by: { $0.path < $1.path }) { + let snapshot = await snapshotProvider(root, availableRepositoryRoots) + guard isCurrent() else { return false } + guard let snapshot, snapshot.repositoryRoot.standardizedFileURL == root.standardizedFileURL else { + notify?(L10n.string("Could not refresh Git status. Refresh to verify the changes.")) + return false + } + for change in changes where change.repositoryRoot == root && GitRollbackSelection.isAddition(change) { + guard let missing = snapshot.changes.first(where: { + $0.path == change.path && $0.workTreeStatus == "D" && GitRollbackSelection.isAddition($0) + }) else { continue } + guard isCurrent() else { return false } + let result = await recordingGitCommand { await service.unstage(GitRollbackSelection.operationTarget(missing)) } + guard isCurrent() else { return false } + if !result.succeeded { notify?(trimmedMessage(result)); return false } + } + } + return deleted + } + } + + /// Prepare the real index, not a second UI selection. Already staged hunks + /// in selected files remain intact; unrelated staged files are excluded. + package func prepareCommitFiles(_ changes: [GitChange]) async -> Bool { + guard canPrepareCommitFiles(changes), let first = changes.first else { return false } + activateChangelist(changelists.listID(for: first)) + guard canPrepareCommitFiles(changes) else { return false } + let generation = workspaceCommitGeneration + let workspace = workspaceURLProvider?()?.standardizedFileURL + let selectedIDs = Set(changes.map(\.id)) + let alreadyStaged = Set(changes.filter(\.isStaged).map(\.id)) + let excluded = gitChanges.filter { $0.isStaged && !selectedIDs.contains($0.id) } + let included = coveringUntrackedDirectoryContents(changes.filter { !alreadyStaged.contains($0.id) }) + isPreparingCommitFiles = true + defer { if workspaceCommitGeneration == generation { isPreparingCommitFiles = false } } + func isCurrent() -> Bool { + workspaceCommitGeneration == generation && workspaceURLProvider?()?.standardizedFileURL == workspace + && !Task.isCancelled && GitExecutionContext.current?.isCancellationRequested != true + } + let succeeded = await withGitChangeOperation(excluded + changes) { + var seen: Set = [] + for (targets, staged) in [(excluded, false), (included, true)] { + seen = [] + for change in targets where seen.insert(change.id).inserted { + guard isCurrent() else { return false } + let target = GitRollbackSelection.operationTarget(change) + let result = await recordingGitCommand { + staged ? await service.stage(target) : await service.unstage(target) + } + guard isCurrent() else { return false } + guard result.succeeded else { + notify?(trimmedMessage(result)) + return false + } + } + } + return isCurrent() + } + guard isCurrent() else { return false } + guard isCurrent(), succeeded else { return false } + let staged = gitChanges.filter(\.isStaged) + func matches(_ current: GitChange, _ selected: GitChange) -> Bool { + current.repositoryRoot.standardizedFileURL == selected.repositoryRoot.standardizedFileURL + && (current.path == selected.path || selected.containsUntrackedDescendant(current)) + } + // A directory summary becomes individual index entries; Git may also + // discover a rename/copy only after staging, changing the row's ID. + guard changes.allSatisfy({ selected in staged.contains { matches($0, selected) } }), + staged.allSatisfy({ current in changes.contains { matches(current, $0) } }) else { + notify?(L10n.string("The staged files changed. Review the checked files before committing.")) + return false + } + return true + } + package func stashWorkingTree(message: String, includeUntracked: Bool) async { guard let gitRepositoryRoot else { return } isPerformingStashOperation = true @@ -2839,7 +3112,7 @@ package final class GitFeatureModel: ObservableObject { private func mutateWorktreeChanges(_ changes: [GitChange], in worktree: GitWorktree, staged: Bool?) async { guard worktreeCommitFeatures[worktree.url.standardizedFileURL]?.changelistStorageFailed != true, - !worktreeCommitFeatures.values.contains(where: { $0.isCommitting }) else { return } + !worktreeCommitFeatures.values.contains(where: { $0.isCommitting || $0.isRollingBackChanges || $0.isPreparingCommitFiles || $0.isDeletingCommitFiles }) else { return } guard let repositoryRoot = gitRepositoryRoot, !Task.isCancelled, !isMutatingWorktreeChanges, !isRefreshingWorktreeChanges, !isPerformingWorktreeOperation, !isCommitting, !isStagingChanges, @@ -2876,7 +3149,7 @@ package final class GitFeatureModel: ObservableObject { } } defer { settle() } - await withGitOperation { + await withGitChangeOperation(targets, onWorktreeSettled: settle) { for change in targets { guard !Task.isCancelled, gitRepositoryRoot == repositoryRoot, worktreeChangesMutationID == mutationID else { break } @@ -2895,23 +3168,110 @@ package final class GitFeatureModel: ObservableObject { } } } - guard gitRepositoryRoot == repositoryRoot, worktreeChangesMutationID == mutationID, - !Task.isCancelled else { return } - // The rows are settled once this worktree's status is read. The project-wide refresh - // that follows must not keep the checkboxes busy. - await refreshAfterChangeMutation(targets, onWorktreeSettled: settle) } - private func refreshAfterChangeMutation(_ changes: [GitChange], onWorktreeSettled: () -> Void = {}) async { - let root = gitRepositoryRoot - if let inspection = gitWorktreeInspection, - let worktree = gitWorktrees.first(where: { $0.id == inspection.worktreeID }), - changes.contains(where: { $0.repositoryRoot.standardizedFileURL == worktree.url.standardizedFileURL }) { - await refreshWorktreeChanges(worktree, clearingError: false) + private func refreshAfterChangeMutation(requests: [URL: UUID], onWorktreeSettled: () -> Void = {}) async { + let generation = workspaceCommitGeneration + let workspace = workspaceURLProvider?()?.standardizedFileURL + let roots = requests.keys.sorted { $0.path < $1.path } + var confirmed: Set = [] + var confirmedChanges: [GitChange] = [] + for root in roots { + guard !Task.isCancelled, workspaceCommitGeneration == generation, + let request = requests[root], repositoryChangeRefreshes[root] == request else { continue } + let snapshot = availableRepositoryRoots.contains(root) || gitRepositoryRoot == root + ? await snapshotProvider(root, availableRepositoryRoots) + : await worktreeSnapshot(for: root) + guard !Task.isCancelled, workspaceCommitGeneration == generation, + workspaceURLProvider?()?.standardizedFileURL == workspace else { return } + // A newer read for this root does not confirm the other roots in + // the batch. They still need their own post-command status. + guard repositoryChangeRefreshes[root] == request else { continue } + guard let snapshot, snapshot.repositoryRoot.standardizedFileURL == root else { + let message = L10n.string("Could not refresh Git status. Refresh to verify the changes.") + if gitWorktreeInspection?.worktreeID == root.path { worktreeChangesError = message } + notify?(message) + continue + } + guard await publishMutationSnapshot(snapshot, request: request) else { continue } + confirmed.insert(root) + confirmedChanges.append(contentsOf: snapshot.changes) + if let host = worktreeOperationHost { _ = await host.publishMutationSnapshot(snapshot, request: request) } } onWorktreeSettled() - guard gitRepositoryRoot == root, !Task.isCancelled else { return } - await refreshGit() + guard !Task.isCancelled, workspaceCommitGeneration == generation else { return } + let currentRoots = confirmed.filter { repositoryChangeRefreshes[$0] == requests[$0] } + await refreshMutationDiffs(confirmedChanges, confirmedRoots: currentRoots) + guard !Task.isCancelled, workspaceCommitGeneration == generation else { return } + if let host = worktreeOperationHost { + let hostRoots = confirmed.filter { host.repositoryChangeRefreshes[$0] == requests[$0] } + await host.refreshMutationDiffs(confirmedChanges, confirmedRoots: hostRoots) + } + // Watcher events still update the project tree and metadata after the + // freeze ends, but the Commit rows already reflect this exact operation. + if worktreeOperationHost == nil { await onStateRefreshed?() } + } + + @discardableResult + private func publishMutationSnapshot(_ snapshot: GitSnapshot, request: UUID? = nil) async -> Bool { + let root = snapshot.repositoryRoot.standardizedFileURL + let generation = workspaceCommitGeneration + if let scoped = worktreeCommitFeatures[root], + scoped.gitChanges.filter({ !scoped.isListedUntrackedContent($0) }) != snapshot.changes { + guard await scoped.publishMutationSnapshot(snapshot, request: request), + workspaceCommitGeneration == generation, + request == nil || repositoryChangeRefreshes[root] == request else { return false } + } + // Finish the scoped model first: the host's expansion state can change + // while that model awaits its own directory listing. + let changes = await listingExpandedUntrackedDirectories(in: snapshot.changes, confirmedRoots: [root], isCurrent: { + self.workspaceCommitGeneration == generation && (request == nil || self.repositoryChangeRefreshes[root] == request) + }) + guard !Task.isCancelled, workspaceCommitGeneration == generation, + request == nil || repositoryChangeRefreshes[root] == request else { return false } + changeRefreshRevision &+= 1 + if availableRepositoryRoots.contains(root) || gitRepositoryRoot == root { + // Replace only the successfully read repository, keeping failed or + // unrelated roots and their pending checkbox state unchanged. + var merged: [GitChange] = [] + var inserted = false + for change in gitChanges { + if change.repositoryRoot.standardizedFileURL == root { + if !inserted { merged.append(contentsOf: changes); inserted = true } + } else { merged.append(change) } + } + if !inserted { merged.append(contentsOf: changes) } + if gitChanges != merged { + gitChanges = merged + rememberChangelistRenames() + gitLineChangeMarkers = [:] + lineChangeHunks = [:] + } + if gitRepositoryRoot == root, currentBranch != snapshot.branch { currentBranch = snapshot.branch } + if !gitConflictFilterPaths.isEmpty { gitConflictFilterPaths.formIntersection(Set(gitChanges.map(\.path))) } + } + updateWorktreeInspection(snapshot.changes, at: root, clearingError: false) + reconcilePendingStagingStates(with: changes, successfullyReadRepositoryRoots: [root]) + return true + } + + private func refreshMutationDiffs(_ changes: [GitChange], confirmedRoots: Set) async { + let revision = changeRefreshRevision + for session in allDiffSessions where session.selectedChange.map({ confirmedRoots.contains($0.repositoryRoot.standardizedFileURL) }) == true { + guard revision == changeRefreshRevision, !Task.isCancelled else { return } + await refreshWorkingDiff(session, changes: changes, confirmedRoots: confirmedRoots) + } + } + + private func updateWorktreeInspection(_ changes: [GitChange], at root: URL, clearingError: Bool = true) { + guard let inspection = gitWorktreeInspection, inspection.worktreeID == root.path else { return } + worktreeChangesRequestGeneration &+= 1 + isRefreshingWorktreeChanges = false + if !inspection.hasLoadedChanges || inspection.changes != changes { + gitWorktreeInspection = GitWorktreeInspection(worktreeID: inspection.worktreeID, changes: changes, + commits: inspection.commits, hasMoreCommits: inspection.hasMoreCommits) + } + if clearingError { worktreeChangesError = nil } } private func worktreeSnapshot(for root: URL) async -> GitSnapshot? { diff --git a/macos/Sources/LitheGitModule/Models/GitRollbackSelection.swift b/macos/Sources/LitheGitModule/Models/GitRollbackSelection.swift new file mode 100644 index 0000000000..92ee737c48 --- /dev/null +++ b/macos/Sources/LitheGitModule/Models/GitRollbackSelection.swift @@ -0,0 +1,59 @@ +import Foundation + +/// A reviewed file set, independent of both row highlighting and the real index. +package struct GitRollbackSelection: Identifiable, Sendable { + package let id = UUID() + package let contextID: UUID + package let changes: [GitChange] + package private(set) var includedIDs: Set + package var deleteAddedFiles = false + + package init(contextID: UUID, changes: [GitChange], included: [GitChange]) { + self.contextID = contextID + // Git can report a staged deletion and an untracked copy with one ID. + // Keep the tracked row so rolling it back restores HEAD, rather than + // treating the retained disk copy as a newly added file. + var seen: Set = [] + let trackedIDs = Set(changes.filter { !$0.isUntracked }.map(\.id)) + self.changes = changes.filter { change in + (!change.isUntracked || !trackedIDs.contains(change.id)) + && !(change.isUntrackedDirectory && changes.contains(where: { change.containsUntrackedDescendant($0) })) + && seen.insert(change.id).inserted + } + let includedIDs = Set(included.map(\.id)) + let includedDirectories = included.filter(\.isUntrackedDirectory) + self.includedIDs = Set(self.changes.filter { change in + includedIDs.contains(change.id) || includedDirectories.contains(where: { $0.containsUntrackedDescendant(change) }) + }.map(\.id)) + } + + package var includedChanges: [GitChange] { changes.filter { includedIDs.contains($0.id) } } + package var hasIncludedAdditions: Bool { includedChanges.contains(where: Self.isAddition) } + + package static func isAddition(_ change: GitChange) -> Bool { + change.kind == .added || (change.indexStatus == "C" && !change.isConflicted) + } + + /// A copied file owns only its destination; its source can have unrelated edits. + package static func operationTarget(_ change: GitChange) -> GitChange { + guard change.indexStatus == "C" || change.workTreeStatus == "C" else { return change } + return GitChange(repositoryRoot: change.repositoryRoot, path: change.path, originalPath: nil, + indexStatus: change.indexStatus, workTreeStatus: change.workTreeStatus, + submodule: change.submodule, canToggleStaging: change.canToggleStaging, + untrackedFileCount: change.untrackedFileCount) + } + + package mutating func toggle(_ changes: [GitChange]) { + let ids = Set(changes.map(\.id)) + if ids.isSubset(of: includedIDs) { includedIDs.subtract(ids) } + else { includedIDs.formUnion(ids) } + } + + /// Commit inclusion wins over the current diff's single highlighted row. + package static func initialTargets(changes: [GitChange], selectedIDs: Set) -> [GitChange] { + let checked = changes.filter(\.isStaged) + if !checked.isEmpty { return checked } + let selected = changes.filter { selectedIDs.contains($0.id) } + return selected.isEmpty ? changes : selected + } +} diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index c6b5e4cb66..f1f30eb481 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -45,7 +45,7 @@ package final class WorkspaceFeatureModel: ObservableObject { private var pendingGitRefresh = false private var pendingFullRescan = false private var pendingWatchRootsChanged = false - private var isGitRefreshRunning = false + private var activeGitRefreshID: UUID? private var externalRefreshGeneration = 0 private var gitRefreshGeneration = 0 private var workspaceSessionPersistenceTask: Task? @@ -178,7 +178,7 @@ package final class WorkspaceFeatureModel: ObservableObject { pendingGitRefresh = false pendingFullRescan = false pendingWatchRootsChanged = false - isGitRefreshRunning = false + activeGitRefreshID = nil externalRefreshGeneration += 1 gitRefreshGeneration += 1 gitOperationFreezeDepth = 0 @@ -203,7 +203,7 @@ package final class WorkspaceFeatureModel: ObservableObject { pendingGitRefresh = false pendingFullRescan = false pendingWatchRootsChanged = false - isGitRefreshRunning = false + activeGitRefreshID = nil externalRefreshGeneration += 1 gitRefreshGeneration += 1 gitOperationFreezeDepth = 0 @@ -233,6 +233,13 @@ package final class WorkspaceFeatureModel: ObservableObject { package func beginWorkspace(at url: URL, visibilityRules: FileVisibilityRules) { invalidateDirectoryListings() + refreshTask?.cancel() + refreshTask = nil + gitRefreshTask?.cancel() + gitRefreshTask = nil + recoveryTask?.cancel() + recoveryTask = nil + activeGitRefreshID = nil isPerformingProjectItemOperation = false workspaceGeneration &+= 1 directoryMarkRevision &+= 1 @@ -273,15 +280,31 @@ package final class WorkspaceFeatureModel: ObservableObject { } /// Flushes accumulated workspace and Git events after the outermost Git operation. - package func endGitOperationFreeze() async { + package func endGitOperationFreeze(waitForRefresh: Bool = true) async { guard gitOperationFreezeDepth > 0 else { return } gitOperationFreezeDepth -= 1 guard gitOperationFreezeDepth == 0, let workspaceURL else { return } + // Local Git mutations already publish their confirmed status. Project + // services and unrelated repository scans must not hold the action open. + if !waitForRefresh { + if pendingWatchRootsChanged || pendingFullRescan { + scheduleRecovery() + return + } + if !pendingExternalPaths.isEmpty { schedulePendingExternalRefresh() } + if pendingGitRefresh { scheduleGitRefresh() } + return + } + if pendingWatchRootsChanged || pendingFullRescan { await applyPendingRecovery(at: workspaceURL) return } + let generation = workspaceGeneration + if pendingGitRefresh { await drainGitRefreshes() } + guard !Task.isCancelled, self.workspaceURL == workspaceURL, + workspaceGeneration == generation else { return } if !pendingExternalPaths.isEmpty { let changedPaths = Array(pendingExternalPaths) pendingExternalPaths.removeAll() @@ -289,9 +312,6 @@ package final class WorkspaceFeatureModel: ObservableObject { await applyExternalRefresh(changedPaths, at: workspaceURL) return } - if pendingGitRefresh { - await drainGitRefreshes() - } } package func rebuild( @@ -366,9 +386,12 @@ package final class WorkspaceFeatureModel: ObservableObject { guard isCurrent() else { return .stale } await updateWatchConfiguration() guard isCurrent() else { return .stale } + // Git status must reach an open Commit panel before project services + // start potentially slow imports or language-server preparation. + await requestGitRefreshNow() + guard isCurrent() else { return .stale } await onSnapshotLoaded?(snapshot, isInitialLoad) guard isCurrent() else { return .stale } - await requestGitRefreshNow() if pendingFullRescan || pendingWatchRootsChanged { scheduleRecovery() } @@ -1113,9 +1136,11 @@ package final class WorkspaceFeatureModel: ObservableObject { } if !batch.workspacePaths.isEmpty { - if batch.gitStateMayHaveChanged { pendingGitRefresh = true } schedulePendingExternalRefresh() - } else if batch.gitStateMayHaveChanged { + } + // These are independent owned debounce lanes: tree/index/project work + // must not delay Git or cancel the last chance to publish a file batch. + if batch.gitStateMayHaveChanged { scheduleGitRefresh() } } @@ -1143,6 +1168,7 @@ package final class WorkspaceFeatureModel: ObservableObject { return } + let generation = workspaceGeneration let rootsChanged = pendingWatchRootsChanged let fullRescan = pendingFullRescan pendingWatchRootsChanged = false @@ -1150,16 +1176,27 @@ package final class WorkspaceFeatureModel: ObservableObject { if rootsChanged { await updateWatchConfiguration(forceRebuild: true) } + guard !Task.isCancelled, self.workspaceURL == workspaceURL, + workspaceGeneration == generation else { return } if fullRescan { await refreshCurrent() - } else if !pendingExternalPaths.isEmpty { - let changedPaths = Array(pendingExternalPaths) - pendingExternalPaths.removeAll() - externalRefreshGeneration += 1 - refreshTask?.cancel() - refreshTask = nil - await applyExternalRefresh(changedPaths, at: workspaceURL) + } else { + // Without a snapshot the external batch rebuilds it, including an + // early Git refresh. Avoid reading the same recovery batch twice. + if hasSnapshot, pendingGitRefresh { await drainGitRefreshes() } + guard !Task.isCancelled, self.workspaceURL == workspaceURL, + workspaceGeneration == generation else { return } + if !pendingExternalPaths.isEmpty { + let changedPaths = Array(pendingExternalPaths) + pendingExternalPaths.removeAll() + externalRefreshGeneration += 1 + refreshTask?.cancel() + refreshTask = nil + await applyExternalRefresh(changedPaths, at: workspaceURL) + } } + guard !Task.isCancelled, self.workspaceURL == workspaceURL, + workspaceGeneration == generation else { return } if pendingGitRefresh { await drainGitRefreshes() } @@ -1197,7 +1234,7 @@ package final class WorkspaceFeatureModel: ObservableObject { private func scheduleGitRefresh() { pendingGitRefresh = true gitRefreshGeneration += 1 - guard gitOperationFreezeDepth == 0, !isGitRefreshRunning else { return } + guard gitOperationFreezeDepth == 0, activeGitRefreshID == nil else { return } let generation = gitRefreshGeneration gitRefreshTask?.cancel() gitRefreshTask = Task { @MainActor [weak self, observationDelay] in @@ -1213,19 +1250,30 @@ package final class WorkspaceFeatureModel: ObservableObject { private func requestGitRefreshNow() async { pendingGitRefresh = true gitRefreshGeneration += 1 - gitRefreshTask?.cancel() - gitRefreshTask = nil + if activeGitRefreshID == nil { + gitRefreshTask?.cancel() + gitRefreshTask = nil + } await drainGitRefreshes() } private func drainGitRefreshes() async { - guard gitOperationFreezeDepth == 0, !isGitRefreshRunning else { return } - isGitRefreshRunning = true - while pendingGitRefresh, gitOperationFreezeDepth == 0 { + guard gitOperationFreezeDepth == 0, activeGitRefreshID == nil, !Task.isCancelled else { return } + let refreshID = UUID(), generation = workspaceGeneration + activeGitRefreshID = refreshID + defer { + if activeGitRefreshID == refreshID { + activeGitRefreshID = nil + if pendingGitRefresh, gitOperationFreezeDepth == 0, workspaceGeneration == generation { + scheduleGitRefresh() + } + } + } + while pendingGitRefresh, gitOperationFreezeDepth == 0, !Task.isCancelled, + workspaceGeneration == generation, activeGitRefreshID == refreshID { pendingGitRefresh = false await refreshGit?() } - isGitRefreshRunning = false } private func applyExternalRefresh(_ paths: [String], at workspaceURL: URL) async { @@ -1246,12 +1294,7 @@ package final class WorkspaceFeatureModel: ObservableObject { if !values.contains(url) { values.append(url) } } .sorted { $0.path < $1.path } - guard !changedURLs.isEmpty else { - // Events queued before the watcher learned a path is ignored still - // owe any Git refresh they requested. - if pendingGitRefresh { await drainGitRefreshes() } - return - } + guard !changedURLs.isEmpty else { return } let conflictDetected = processExternalChanges?(changedURLs) ?? false if conflictDetected { notify?("External edits conflict with unsaved changes") } @@ -1277,11 +1320,7 @@ package final class WorkspaceFeatureModel: ObservableObject { await applyIncrementalTree(root: root, files: files, reloadsTree: reloadsTree) reloadedProjectServices = true case .requiresSnapshot: - let generation = workspaceGeneration await refreshCurrentSnapshot() - // An unavailable snapshot must not swallow the batch's Git refresh. - guard self.workspaceURL == workspaceURL, workspaceGeneration == generation else { return } - if pendingGitRefresh { await drainGitRefreshes() } return } await updateSearchIndex( @@ -1305,7 +1344,6 @@ package final class WorkspaceFeatureModel: ObservableObject { return isLitheConfiguration || isBuildManagementFile } if requiresProjectServiceReload, !reloadedProjectServices { await reloadProjectServices?() } - await requestGitRefreshNow() } private func scheduleSearchIndexWarm(at workspaceURL: URL, rules: FileVisibilityRules) { diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 4579dded48..9969116655 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -617,23 +617,18 @@ struct GitModuleTests { let worktree = makeTestWorktree(path: "/linked", branch: "linked") let change = GitChange(repositoryRoot: worktree.url, path: "file.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") let probe = WorktreeChangesProbe(root: root, changes: [change]) - let staged = GitModuleTestGate(), projectRefreshEntered = GitModuleTestGate(), releaseProjectRefresh = GitModuleTestGate() + let projectRefreshEntered = GitModuleTestGate(), releaseProjectRefresh = GitModuleTestGate() let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( - snapshotHandler: { url in - // After the stage, hold the project-wide status read that refreshGit() performs last. - if url == root, staged.waitSynchronously(timeout: 0) { - projectRefreshEntered.open() - _ = releaseProjectRefresh.waitSynchronously() - } - return probe.snapshot(url) - }, - stageHandler: { change in - defer { staged.open() } - return probe.mutate(change, action: "stage") - })), worktreesProvider: { _ in [worktree] }) + snapshotHandler: { probe.snapshot($0) }, + stageHandler: { probe.mutate($0, action: "stage") })), worktreesProvider: { _ in [worktree] }) defer { releaseProjectRefresh.open(); feature.reset() } - feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}, + onGitOperationEnded: { + projectRefreshEntered.open() + _ = await releaseProjectRefresh.waitUntilOpen() + }) await feature.refreshGit(); await feature.refreshWorktrees(); await feature.inspectWorktree(worktree) + let projectReads = probe.snapshotRoots.filter { $0 == root }.count let task = Task { await feature.setWorktreeStaging([change], staged: true, in: worktree) } defer { task.cancel(); releaseProjectRefresh.open() } try #require(await projectRefreshEntered.waitUntilOpen()) @@ -641,6 +636,7 @@ struct GitModuleTests { #expect(feature.gitWorktreeInspection?.changes.first?.isStaged == true) #expect(!feature.isMutatingWorktreeChanges && !feature.isRefreshingWorktreeChanges) #expect(feature.pendingStagedStates.isEmpty && !feature.isStagingChanges) + #expect(probe.snapshotRoots.filter { $0 == root }.count == projectReads) releaseProjectRefresh.open() try #require(await waitForGitTaskCompletion(task)) } @@ -2858,6 +2854,511 @@ struct GitModuleTests { #expect(feature.gitChanges.isEmpty) } + @Test + func commitFilesPreparesExactlyTheRequestedIndexAndRejectsMixedChangelists() async throws { + let root = URL(fileURLWithPath: "/workspace") + let changes = [ + GitChange(repositoryRoot: root, path: "selected.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M"), + GitChange(repositoryRoot: root, path: "excluded.txt", originalPath: nil, indexStatus: "M", workTreeStatus: " ") + ] + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: changes) + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotHandler: { probe.snapshot($0) }, repositoryRoots: [root], + stageHandler: { probe.mutate($0, action: "stage") }, unstageHandler: { probe.mutate($0, action: "unstage") }))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + #expect(feature.saveChangelistName("Selected") == nil) + let list = try #require(feature.changelists.lists.last?.id) + feature.moveChanges([changes[0]], toChangelist: list) + #expect(!feature.canPrepareCommitFiles(changes)) + #expect(await feature.prepareCommitFiles([changes[0]])) + #expect(feature.changelists.activeID == list) + #expect(probe.calls.map(\.action) == ["unstage", "stage"]) + #expect(probe.calls.map { $0.change.path } == ["excluded.txt", "selected.txt"]) + #expect(feature.gitChanges.filter(\.isStaged).map(\.path) == ["selected.txt"]) + } + + @Test + func commitFilesStopsAtFirstFailureAndDoesNotPretendAStaleSnapshotSucceeded() async { + let root = URL(fileURLWithPath: "/workspace") + let selected = GitChange(repositoryRoot: root, path: "selected.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let excluded = GitChange(repositoryRoot: root, path: "excluded.txt", originalPath: nil, indexStatus: "M", workTreeStatus: " ") + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: [selected, excluded]) + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotHandler: { probe.snapshot($0) }, repositoryRoots: [root], + stageHandler: { probe.mutate($0, action: "stage") }, + unstageHandler: { probe.mutate($0, action: "unstage", succeeds: false) }))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + #expect(await !feature.prepareCommitFiles([selected])) + #expect(probe.calls.map(\.action) == ["unstage"] && !feature.isPreparingCommitFiles) + + let stale = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: [selected]), + stageResult: GitProcessResult(arguments: ["stage"], output: "", exitCode: 0)))) + stale.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { stale.reset() } + await stale.refreshGit() + #expect(await !stale.prepareCommitFiles([selected])) + #expect(!stale.isPreparingCommitFiles) + } + + @Test(arguments: [false, true]) + func commitFilesBlocksReentryAndStopsAfterCancellationOrReset(reset: Bool) async throws { + let root = URL(fileURLWithPath: "/workspace") + let changes = ["first.txt", "second.txt"].map { + GitChange(repositoryRoot: root, path: $0, originalPath: nil, indexStatus: " ", workTreeStatus: "M") + } + let gate = TestGitRunGate() + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: changes) + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotHandler: { probe.snapshot($0) }, repositoryRoots: [root], stageHandler: { + gate.blockFirstRun() + return probe.mutate($0, action: "stage") + }))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + await feature.refreshGit() + var result = true + let task = Task { result = await feature.prepareCommitFiles(changes) } + defer { gate.releaseFirstRun(); task.cancel(); feature.reset() } + try #require(await gate.waitUntilFirstRunStarts()) + #expect(feature.isPreparingCommitFiles && feature.changelistEditingDisabled && !feature.canRollbackChanges) + #expect(await !feature.prepareCommitFiles(changes)) + #expect(feature.beginSetStaging(changes, staged: false).isEmpty) + if reset { feature.reset() } else { task.cancel() } + gate.releaseFirstRun() + try #require(await waitForGitTaskCompletion(task)) + #expect(!result && !feature.isPreparingCommitFiles && !gate.didTimeOut) + #expect(probe.calls.count == 1) + } + + @Test(arguments: ["stage", "unstage", "discard", "rollback"]) + func localMutationsPublishBeforeWorkspaceDrainAndNeverScanSiblingRepositories(action: String) async throws { + let root = URL(fileURLWithPath: "/workspace"), sibling = root.appendingPathComponent("sibling") + let change = GitChange(repositoryRoot: root, path: "target.txt", originalPath: nil, + indexStatus: action == "unstage" || action == "rollback" ? "M" : " ", workTreeStatus: "M") + let directory = GitChange(repositoryRoot: sibling, path: "generated/", originalPath: nil, + indexStatus: "?", workTreeStatus: "?", untrackedFileCount: 101) + let child = GitChange(repositoryRoot: sibling, path: "generated/child.txt", originalPath: nil, + indexStatus: "?", workTreeStatus: "?") + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: [change, directory]) + let drainEntered = GitModuleTestGate(), releaseDrain = GitModuleTestGate() + let metadataReads = CleanedPaths() + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotHandler: { probe.snapshot($0) }, repositoryRoots: [root, sibling], + discardHandler: { probe.mutate($0, action: "discard") }, + discardAllHandler: { probe.mutate($0, action: "discard") }, + stageHandler: { probe.mutate($0, action: "stage") }, unstageHandler: { probe.mutate($0, action: "unstage") })), + stashesProvider: { url in metadataReads.append(url.path); return [] }, + untrackedDirectoryChildrenProvider: { _ in [child] }) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}, + onGitOperationEnded: { drainEntered.open(); _ = await releaseDrain.waitUntilOpen() }) + defer { releaseDrain.open(); feature.reset() } + await feature.refreshGit() + await feature.toggleUntrackedDirectory(directory) + await feature.selectChange(change) + let readCount = probe.snapshotRoots.count, metadataCount = metadataReads.paths.count + if action == "rollback" { feature.requestRollbackChanges([change], included: [change]) } + let selection = feature.pendingRollbackSelection + let task = Task { + switch action { + case "stage": await feature.stageChange(change) + case "unstage": await feature.unstageChange(change) + case "rollback": if let selection { await feature.confirmRollbackChanges(selection) } + default: await feature.discardChanges([change]) + } + } + defer { task.cancel(); releaseDrain.open() } + try #require(await drainEntered.waitUntilOpen()) + #expect(Array(probe.snapshotRoots.dropFirst(readCount)) == [root]) + #expect(metadataReads.paths.count == metadataCount) + #expect(feature.expandedUntrackedDirectoryIDs == [directory.id]) + #expect(feature.gitChanges.filter { $0.repositoryRoot == sibling } == [directory, child]) + #expect(!feature.isRollingBackChanges && !feature.isStagingChanges) + if action == "stage" || action == "unstage" { + #expect(feature.gitChanges.first?.isStaged == (action == "stage")) + #expect(feature.selectedChange == feature.gitChanges.first) + } else { + #expect(!feature.gitChanges.contains { $0.repositoryRoot == root }) + #expect(feature.selectedChange == nil) + } + releaseDrain.open() + try #require(await waitForGitTaskCompletion(task)) + } + + @Test + func oldWorkspaceReadCannotResurrectStatusAfterALocalMutation() async throws { + let root = URL(fileURLWithPath: "/workspace"), sibling = root.appendingPathComponent("sibling") + let change = GitChange(repositoryRoot: root, path: "target.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let other = GitChange(repositoryRoot: sibling, path: "other.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: [change, other]) + let oldReadEntered = GitModuleTestGate(), releaseOldRead = GitModuleTestGate() + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + repositoryRoots: [root, sibling], stageHandler: { probe.mutate($0, action: "stage") })), + snapshotProvider: { url in + let snapshot = probe.snapshot(url) + if url == sibling, probe.snapshotRoots.filter({ $0 == sibling }).count == 2 { + oldReadEntered.open() + _ = await releaseOldRead.waitUntilOpen() + } + return snapshot + }) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { releaseOldRead.open(); feature.reset() } + await feature.refreshGit() + let refresh = Task { await feature.refreshGit() } + defer { refresh.cancel(); releaseOldRead.open() } + try #require(await oldReadEntered.waitUntilOpen()) + await feature.stageChange(change) + #expect(feature.gitChanges.first?.isStaged == true) + var laterStates: [Bool] = [] + let subscription = feature.$gitChanges.sink { changes in + if let target = changes.first(where: { $0.path == "target.txt" }) { laterStates.append(target.isStaged) } + } + defer { subscription.cancel() } + releaseOldRead.open() + try #require(await waitForGitTaskCompletion(refresh)) + #expect(!laterStates.isEmpty && laterStates.allSatisfy { $0 }) + #expect(feature.gitChanges.contains(other)) + } + + @Test + func failedMutationReadRetainsRowsAndPendingCheckboxUntilAConfirmedStatus() async throws { + let root = URL(fileURLWithPath: "/workspace") + let change = GitChange(repositoryRoot: root, path: "target.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: [change]) + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotHandler: { probe.snapshot($0) }, repositoryRoots: [root], stageHandler: { probe.mutate($0, action: "stage") }))) + var messages: [String] = [] + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { messages.append($0) }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + probe.failSnapshots() + let staged = try #require(feature.beginToggleStaging(change) as Bool?) + #expect(staged) + await feature.finishToggleStaging(change, staged: staged) + #expect(feature.gitChanges == [change]) + #expect(feature.pendingStagedStates == [change.id: true]) + #expect(feature.isStagingChanges && !messages.isEmpty) + probe.restoreSnapshots() + await feature.refreshGit() + #expect(feature.gitChanges.first?.isStaged == true) + #expect(feature.pendingStagedStates.isEmpty && !feature.isStagingChanges) + } + + @Test(arguments: [false, true]) + func competingLocalMutationsPublishTheLastCompletedCommandAndRejectOldStatus(blockCommand: Bool) async throws { + let root = URL(fileURLWithPath: "/workspace") + let change = GitChange(repositoryRoot: root, path: "target.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: [change]) + let entered = GitModuleTestGate(), release = GitModuleTestGate() + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations(repositoryRoots: [root], + stageHandler: { target in + if blockCommand { entered.open(); guard release.waitSynchronously() else { return nil } } + return probe.mutate(target, action: "stage") + }, unstageHandler: { probe.mutate($0, action: "unstage") })), snapshotProvider: { url in + let snapshot = probe.snapshot(url) + if !blockCommand, probe.snapshotRoots.count == 2 { + entered.open(); _ = await release.waitUntilOpen() + } + return snapshot + }) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + await feature.refreshGit() + let first = Task { await feature.stageChange(change) } + defer { first.cancel(); release.open(); feature.reset() } + try #require(await entered.waitUntilOpen()) + await feature.unstageChange(change) + #expect(feature.gitChanges.first?.isStaged == false) + release.open() + try #require(await waitForGitTaskCompletion(first)) + // A blocked command writes last; a blocked read must not resurrect its old snapshot. + #expect(feature.gitChanges.first?.isStaged == blockCommand) + } + + @Test + func resetRefreshCannotFinishOrPublishIntoTheReopenedWorkspace() async throws { + let root = URL(fileURLWithPath: "/workspace") + let oldChange = GitChange(repositoryRoot: root, path: "old.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let newChange = GitChange(repositoryRoot: root, path: "new.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let oldEntered = GitModuleTestGate(), oldRelease = GitModuleTestGate() + let newEntered = GitModuleTestGate(), newRelease = GitModuleTestGate() + let reads = CleanedPaths() + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations(repositoryRoots: [root])), + snapshotProvider: { url in + reads.append(url.path) + let first = reads.paths.count == 1 + if first { oldEntered.open(); _ = await oldRelease.waitUntilOpen() } + else { newEntered.open(); _ = await newRelease.waitUntilOpen() } + return GitSnapshot(repositoryRoot: url, branch: "main", changes: [first ? oldChange : newChange]) + }) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + let old = Task { await feature.refreshGit() } + defer { old.cancel(); oldRelease.open(); newRelease.open(); feature.reset() } + try #require(await oldEntered.waitUntilOpen()) + feature.reset() + let reopened = Task { await feature.refreshGit() } + defer { reopened.cancel(); newRelease.open() } + try #require(await newEntered.waitUntilOpen()) + oldRelease.open() + try #require(await waitForGitTaskCompletion(old)) + #expect(feature.isRefreshingGit && feature.gitChanges.isEmpty) + newRelease.open() + try #require(await waitForGitTaskCompletion(reopened)) + #expect(!feature.isRefreshingGit && feature.gitChanges == [newChange]) + } + + @Test + func newerLocalStatusCannotBeOverwrittenByAnOlderExpandedDirectoryRead() async throws { + let root = URL(fileURLWithPath: "/workspace") + let change = GitChange(repositoryRoot: root, path: "target.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let directory = GitChange(repositoryRoot: root, path: "generated/", originalPath: nil, + indexStatus: "?", workTreeStatus: "?", untrackedFileCount: 101) + let child = GitChange(repositoryRoot: root, path: "generated/child.txt", originalPath: nil, indexStatus: "?", workTreeStatus: "?") + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: [change, directory]) + let entered = GitModuleTestGate(), release = GitModuleTestGate(), listings = CleanedPaths() + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotHandler: { probe.snapshot($0) }, repositoryRoots: [root], stageHandler: { probe.mutate($0, action: "stage") })), + untrackedDirectoryChildrenProvider: { _ in + listings.append("read") + if listings.paths.count == 2 { entered.open(); _ = await release.waitUntilOpen() } + return [child] + }) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + await feature.refreshGit(); await feature.toggleUntrackedDirectory(directory) + let first = Task { await feature.stageChange(change) } + defer { first.cancel(); release.open(); feature.reset() } + try #require(await entered.waitUntilOpen()) + probe.replaceChanges([]) + await feature.stageChange(change) + #expect(feature.gitChanges.isEmpty && feature.expandedUntrackedDirectoryIDs.isEmpty) + release.open() + try #require(await waitForGitTaskCompletion(first)) + #expect(feature.gitChanges.isEmpty && feature.expandedUntrackedDirectoryIDs.isEmpty) + } + + @Test + func supersededRootReadStillConfirmsOtherRootsInTheSameMutation() async throws { + let root = URL(fileURLWithPath: "/workspace"), sibling = root.appendingPathComponent("sibling") + let changes = [root, sibling].map { + GitChange(repositoryRoot: $0, path: "target.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + } + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: changes) + let entered = GitModuleTestGate(), release = GitModuleTestGate() + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + discardHandler: { probe.mutate($0, action: "discard") }, stageHandler: { probe.mutate($0, action: "stage") })), + snapshotProvider: { url in + let snapshot = probe.snapshot(url) + if url == root, probe.snapshotRoots.filter({ $0 == root }).count == 2 { + entered.open(); _ = await release.waitUntilOpen() + } + return snapshot + }, repositoryRootsProvider: { _ in [root, sibling] }) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + await feature.refreshGit() + let batch = Task { await feature.discardChanges(changes) } + defer { batch.cancel(); release.open(); feature.reset() } + try #require(await entered.waitUntilOpen()) + // A new command supersedes only the first root's blocked status read. + probe.replaceChanges([changes[0]]) + await feature.stageChange(changes[0]) + #expect(feature.gitChanges.first?.isStaged == true) + release.open() + try #require(await waitForGitTaskCompletion(batch)) + #expect(feature.gitChanges.count == 1 && feature.gitChanges.first?.repositoryRoot == root) + #expect(feature.gitChanges.first?.isStaged == true) + #expect(probe.snapshotRoots.filter { $0 == sibling }.count == 2) + } + + @Test + func hostCollapseDuringScopedPublicationCannotReinsertHiddenChildren() async throws { + let root = URL(fileURLWithPath: "/workspace"), worktree = makeTestWorktree(path: "/workspace", branch: "main") + let change = GitChange(repositoryRoot: root, path: "target.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let directory = GitChange(repositoryRoot: root, path: "generated/", originalPath: nil, + indexStatus: "?", workTreeStatus: "?", untrackedFileCount: 101) + let child = GitChange(repositoryRoot: root, path: "generated/child.txt", originalPath: nil, indexStatus: "?", workTreeStatus: "?") + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: [change, directory]) + let entered = GitModuleTestGate(), release = GitModuleTestGate(), listings = CleanedPaths() + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotHandler: { probe.snapshot($0) }, stageHandler: { probe.mutate($0, action: "stage") })), + worktreesProvider: { _ in [worktree] }, repositoryRootsProvider: { [$0] }, + untrackedDirectoryChildrenProvider: { _ in + listings.append("read") + if listings.paths.count == 4 { entered.open(); _ = await release.waitUntilOpen() } + return [child] + }) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + await feature.refreshGit(); await feature.refreshWorktrees(); await feature.inspectWorktree(worktree) + let scoped = feature.commitFeature(for: worktree) + await feature.toggleUntrackedDirectory(directory); await scoped.toggleUntrackedDirectory(directory) + let mutation = Task { await feature.stageChange(change) } + defer { mutation.cancel(); release.open(); feature.reset() } + try #require(await entered.waitUntilOpen()) + await feature.toggleUntrackedDirectory(directory) + #expect(feature.expandedUntrackedDirectoryIDs.isEmpty) + release.open() + try #require(await waitForGitTaskCompletion(mutation)) + #expect(feature.gitChanges.contains(directory) && !feature.gitChanges.contains(child)) + #expect(scoped.gitChanges.contains(child) && scoped.expandedUntrackedDirectoryIDs == [directory.id]) + #expect(feature.gitChanges.first?.isStaged == true && scoped.gitChanges.first?.isStaged == true) + } + + @Test(arguments: [false, true]) + func worktreeScopedMutationKeepsExpandedRowsAndHostHistoryWithoutSiblingReads(useScoped: Bool) async throws { + let root = URL(fileURLWithPath: "/workspace"), worktree = makeTestWorktree(path: "/linked", branch: "linked") + let change = GitChange(repositoryRoot: worktree.url, path: "target.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let directory = GitChange(repositoryRoot: worktree.url, path: "generated/", originalPath: nil, + indexStatus: "?", workTreeStatus: "?", untrackedFileCount: 101) + let child = GitChange(repositoryRoot: worktree.url, path: "generated/child.txt", originalPath: nil, indexStatus: "?", workTreeStatus: "?") + let probe = WorktreeChangesProbe(root: root, changes: [change, directory]) + let commits = (0..<80).map { makeTestCommit(hash: "worktree-\($0)", subject: "Commit \($0)") } + let pages = WorktreeHistoryPageProbe(pages: [GitHistoryPage(commits: commits, nextCursor: "older", hasMore: true)]) + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations(snapshotHandler: { probe.snapshot($0) }, + historyValue: GitHistorySnapshot(references: [], commits: Array(commits.prefix(30)), hasMore: true), + worktreeHistoryProbe: pages, stageHandler: { probe.mutate($0, action: "stage") })), + worktreesProvider: { _ in [worktree] }, repositoryRootsProvider: { [$0] }, + untrackedDirectoryChildrenProvider: { _ in [child] }) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset(); pages.releaseAll() } + await feature.refreshGit(); await feature.refreshWorktrees(); await feature.inspectWorktree(worktree) + await feature.loadMoreWorktreeHistory(for: worktree) + let scoped = feature.commitFeature(for: worktree) + await scoped.toggleUntrackedDirectory(directory) + let reads = probe.snapshotRoots.count + if useScoped { await scoped.stageChange(change) } + else { await feature.setWorktreeStaging([change], staged: true, in: worktree) } + #expect(Array(probe.snapshotRoots.dropFirst(reads)) == [worktree.url]) + #expect(scoped.gitChanges.first?.isStaged == true) + #expect(scoped.gitChanges.contains(child) && scoped.expandedUntrackedDirectoryIDs == [directory.id]) + #expect(feature.gitWorktreeInspection?.changes.first?.isStaged == true) + #expect(feature.gitWorktreeInspection?.commits == commits && feature.gitWorktreeInspection?.hasMoreCommits == true) + #expect(pages.requests.count == 1 && pages.closed.isEmpty) + #expect(feature.gitChanges.isEmpty && feature.gitRepositoryRoot == root) + } + + @Test + func backgroundReadDoesNotDisableRollbackOrResurrectItsOldRows() async throws { + let root = URL(fileURLWithPath: "/workspace"), sibling = root.appendingPathComponent("sibling") + let change = GitChange(repositoryRoot: root, path: "checked.txt", originalPath: nil, + indexStatus: "M", workTreeStatus: " ") + let probe = WorktreeChangesProbe(root: URL(fileURLWithPath: "/unused"), changes: [change]) + let entered = GitModuleTestGate(), release = GitModuleTestGate() + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + repositoryRoots: [root, sibling], discardAllHandler: { probe.mutate($0, action: "discard") })), + snapshotProvider: { url in + let snapshot = probe.snapshot(url) + if url == sibling, probe.snapshotRoots.filter({ $0 == sibling }).count == 2 { + entered.open() + _ = await release.waitUntilOpen() + } + return snapshot + }) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + await feature.refreshGit() + let refresh = Task { await feature.refreshGit() } + defer { refresh.cancel(); release.open(); feature.reset() } + try #require(await entered.waitUntilOpen()) + #expect(feature.isRefreshingGit && feature.canRollbackChanges) + feature.requestRollbackChanges([change], included: [change]) + let reviewed = try #require(feature.pendingRollbackSelection) + await feature.confirmRollbackChanges(reviewed) + #expect(feature.gitChanges.isEmpty && !feature.isRollingBackChanges && feature.canRollbackChanges) + release.open() + try #require(await waitForGitTaskCompletion(refresh)) + #expect(feature.gitChanges.isEmpty) + } + + @Test + func rollbackConfirmationSurvivesDismissalAndStopsAtFirstFailure() async throws { + let root = URL(fileURLWithPath: "/workspace") + let changes = ["checked.txt", "blocked.txt", "untouched.txt"].map { + GitChange(repositoryRoot: root, path: $0, originalPath: nil, indexStatus: "M", workTreeStatus: "M") + } + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: changes), + discardAllHandler: { change in + GitProcessResult(arguments: ["discardAll", change.path], output: "Access denied", + exitCode: change.path == "blocked.txt" ? 1 : 0) + }, unstageHandler: { change in + GitProcessResult(arguments: ["unstage", change.path], output: "Access denied", + exitCode: change.path == "blocked.txt" ? 1 : 0) + }))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + feature.clearGitConsole() + feature.requestRollbackChanges(changes, included: changes) + let captured = try #require(feature.pendingRollbackSelection) + feature.cancelRollbackChanges() + #expect(feature.gitConsoleEntries.isEmpty) + await feature.confirmRollbackChanges(captured) + #expect(feature.gitConsoleEntries.map(\.arguments) == [ + ["discardAll", "checked.txt"], ["discardAll", "blocked.txt"] + ]) + #expect(!feature.isRollingBackChanges && feature.canRollbackChanges) + } + + @Test(arguments: [false, true]) + func rollbackBlocksReentryAndRejectsLateWritesAfterReset(reset: Bool) async throws { + let root = URL(fileURLWithPath: "/workspace") + let changes = ["first.txt", "second.txt"].map { + GitChange(repositoryRoot: root, path: $0, originalPath: nil, indexStatus: "M", workTreeStatus: "M") + } + let gate = TestGitRunGate() + let calls = CleanedPaths() + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: changes), + discardAllHandler: { change in + calls.append(change.path) + gate.blockFirstRun() + return GitProcessResult(arguments: ["discardAll", change.path], output: "", exitCode: 0) + }, unstageHandler: { change in + return GitProcessResult(arguments: ["unstage", change.path], output: "", exitCode: 0) + }))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + await feature.refreshGit() + feature.requestRollbackChanges(changes, included: changes) + let captured = try #require(feature.pendingRollbackSelection) + let task = Task { await feature.confirmRollbackChanges(captured) } + defer { gate.releaseFirstRun(); task.cancel(); feature.reset() } + try #require(await gate.waitUntilFirstRunStarts()) + #expect(feature.isRollingBackChanges && !feature.canRollbackChanges && feature.changelistEditingDisabled) + #expect(feature.beginSetStaging(changes, staged: true).isEmpty) + await feature.confirmRollbackChanges(captured) + if reset { feature.reset() } else { task.cancel() } + gate.releaseFirstRun() + try #require(await waitForGitTaskCompletion(task)) + #expect(!feature.isRollingBackChanges && !gate.didTimeOut) + #expect(calls.paths == ["first.txt"]) + #expect(!feature.gitConsoleEntries.contains { $0.arguments.first == "unstage" }) + } + + @Test + func rollbackRejectsOldDialogAfterReplacementOrWorkspaceSwitch() async throws { + let root = URL(fileURLWithPath: "/workspace") + let change = GitChange(repositoryRoot: root, path: "target.txt", originalPath: nil, indexStatus: " ", workTreeStatus: "M") + let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: [change]), + discardAllHandler: { target in GitProcessResult(arguments: ["discardAll", target.path], output: "", exitCode: 0) }))) + var workspace = root + feature.configure(workspaceURLProvider: { workspace }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + feature.clearGitConsole() + feature.requestRollbackChanges([change], included: [change]) + let old = try #require(feature.pendingRollbackSelection) + feature.requestRollbackChanges([change], included: []) + await feature.confirmRollbackChanges(old) + #expect(feature.gitConsoleEntries.isEmpty) + feature.requestRollbackChanges([change], included: [change]) + let switched = try #require(feature.pendingRollbackSelection) + workspace = URL(fileURLWithPath: "/another-workspace") + await feature.confirmRollbackChanges(switched) + #expect(feature.gitConsoleEntries.isEmpty) + } + @Test func batchDiscardRetainsEachFallbackResultAndStopsAtTheFirstFailure() async { let root = URL(fileURLWithPath: "/workspace") @@ -5707,12 +6208,16 @@ private final class WorktreeChangesProbe: @unchecked Sendable { private var changes: [GitChange] private var recorded: [Call] = [] private var snapshotsFail = false + private var readRoots: [URL] = [] init(root: URL, changes: [GitChange]) { self.root = root; self.changes = changes } var calls: [Call] { lock.withLock { recorded } } + var snapshotRoots: [URL] { lock.withLock { readRoots } } func replaceChanges(_ changes: [GitChange]) { lock.withLock { self.changes = changes } } func failSnapshots() { lock.withLock { snapshotsFail = true } } + func restoreSnapshots() { lock.withLock { snapshotsFail = false } } func snapshot(_ target: URL) -> GitSnapshot? { lock.withLock { + readRoots.append(target) guard !snapshotsFail else { return nil } return GitSnapshot(repositoryRoot: target, branch: "main", changes: target == root ? [] : changes.filter { $0.repositoryRoot == target }) diff --git a/macos/Tests/LitheGitModuleTests/GitRollbackSelectionTests.swift b/macos/Tests/LitheGitModuleTests/GitRollbackSelectionTests.swift new file mode 100644 index 0000000000..875b8b8d27 --- /dev/null +++ b/macos/Tests/LitheGitModuleTests/GitRollbackSelectionTests.swift @@ -0,0 +1,62 @@ +import Foundation +@testable import LitheGitModule +import Testing + +struct GitRollbackSelectionTests { + private let root = URL(fileURLWithPath: "/workspace") + + private func change(_ path: String, index: Character = " ", worktree: Character = "M", count: Int? = nil) -> GitChange { + GitChange(repositoryRoot: root, path: path, originalPath: nil, indexStatus: index, + workTreeStatus: worktree, untrackedFileCount: count) + } + + @Test + func checkedFilesWinOverHighlightAndEmptyHighlightIncludesAll() { + let staged = change("checked.swift", index: "M", worktree: " ") + let highlighted = change("highlighted.swift") + #expect(GitRollbackSelection.initialTargets(changes: [staged, highlighted], selectedIDs: [highlighted.id]) == [staged]) + #expect(GitRollbackSelection.initialTargets(changes: [highlighted], selectedIDs: []) == [highlighted]) + } + + @Test + func inclusionDraftIsIndependentAndCanBeEmpty() { + let tracked = change("tracked.swift") + let added = change("added.swift", index: "A", worktree: " ") + var selection = GitRollbackSelection(contextID: UUID(), changes: [tracked, added], included: [tracked]) + #expect(!selection.deleteAddedFiles && !selection.hasIncludedAdditions) + selection.toggle([added]) + #expect(selection.hasIncludedAdditions && selection.includedChanges == [tracked, added]) + selection.toggle([tracked, added]) + #expect(selection.includedChanges.isEmpty && !selection.hasIncludedAdditions) + #expect(selection.changes == [tracked, added] && added.isStaged && !tracked.isStaged) + } + + @Test + func copiedDestinationAndIntentToAddKeepAdditionSafety() { + let copy = GitChange(repositoryRoot: root, path: "copy.txt", originalPath: "source.txt", + indexStatus: "C", workTreeStatus: " ") + #expect(GitRollbackSelection.isAddition(copy)) + #expect(GitRollbackSelection.operationTarget(copy).pathspecs == ["copy.txt"]) + #expect(GitRollbackSelection.isAddition(change("intent.txt", worktree: "A"))) + #expect(!GitRollbackSelection.isAddition(change("conflict.txt", index: "A", worktree: "A"))) + } + + @Test + func retainedDiskCopyNeverTurnsTrackedDeletionIntoAnAddition() { + let deleted = change("tracked.txt", index: "D", worktree: " ") + let retained = change("tracked.txt", index: "?", worktree: "?") + let selection = GitRollbackSelection(contextID: UUID(), changes: [retained, deleted, deleted], included: [retained]) + #expect(selection.changes == [deleted] && selection.includedChanges == [deleted]) + #expect(!selection.hasIncludedAdditions) + } + + @Test + func expandedUntrackedDirectoryUsesIndividualChildrenForExclusions() { + let directory = change("build/", index: "?", worktree: "?", count: 2) + let children = ["build/a.o", "build/b.o"].map { change($0, index: "?", worktree: "?") } + var selection = GitRollbackSelection(contextID: UUID(), changes: [directory] + children, included: [directory]) + #expect(selection.changes == children && selection.includedChanges == children) + selection.toggle([children[0]]) + #expect(selection.includedChanges == [children[1]]) + } +} diff --git a/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift b/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift index e3ee1418f8..efaf1b61aa 100644 --- a/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift +++ b/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift @@ -97,7 +97,7 @@ struct AppModelSettingsChangesTests { guard case .loaded(let snapshot) = result else { Issue.record("Snapshot was not applied"); return } identities.insert(snapshot.id) let input = model.languageToolingFeature.projectUsageInput(workspaceURL: model.workspaceURL, snapshot: snapshot) - let ready = await awaitChange(on: usage) { usage.summary(for: input.request) != nil } + let ready = await usagePageRendered(host) { usage.summary(for: input.request) != nil } try #require(ready, "Mounted LSP page did not reload snapshot \(snapshot.id)") let summary = try #require(usage.summary(for: input.request)) #expect(summary.pluginIDs == expectedPlugin.map { Set([$0]) } ?? []) @@ -167,6 +167,22 @@ struct AppModelSettingsChangesTests { } } +@MainActor +private func usagePageRendered(_ host: NSView, until condition: () -> Bool) async -> Bool { + // The CLI runner has no persistent AppKit event loop. Drive native render + // turns so SwiftUI can start its .task; model observation alone cannot do so. + func renderFrame() { CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0, true) } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + repeat { + host.layoutSubtreeIfNeeded() + renderFrame() + if condition() { return true } + await Task.yield() + } while clock.now < deadline + return condition() +} + @MainActor private func accessibilityLabels(in element: Any, depth: Int = 0) -> [String] { guard depth < 32 else { return [] } diff --git a/macos/Tests/LitheTests/CommitActionButtonStyleTests.swift b/macos/Tests/LitheTests/CommitActionButtonStyleTests.swift index 2dff5d5f60..b916f7e8dc 100644 --- a/macos/Tests/LitheTests/CommitActionButtonStyleTests.swift +++ b/macos/Tests/LitheTests/CommitActionButtonStyleTests.swift @@ -7,6 +7,51 @@ import Testing @MainActor @Suite("Commit action button chrome", .serialized) struct CommitActionButtonStyleTests { + @Test(arguments: [ColorScheme.dark, .light], ["en", "zh-Hans"]) + func defaultSidebarKeepsAmendOnOneLineBesideTheOtherActions(scheme: ColorScheme, language: String) async throws { + MacBundledFontRegistry.registerFonts() + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + defer { feature.reset() } + let draft = CommitDraftFeatureModel() + let workflow = CommitWorkflowCoordinator(draft: draft, activateGit: { nil }, workspaceGeneration: { 0 }, + generate: { _ in "" }, notify: { _ in }) + let host = NSHostingView(rootView: CommitAreaView(feature: feature, draft: draft, commitWorkflow: workflow, + hasBackgroundImage: false, showSettings: { _ in }) + .environment(\.colorScheme, scheme).environment(\.locale, Locale(identifier: language))) + host.sizingOptions = [] + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: WorkbenchLayout.defaultSidebarWidth, height: 240), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.contentView = host; window.orderBack(nil) + defer { window.contentView = nil; window.close() } + host.layoutSubtreeIfNeeded() + let amend = try await AgentNativeControlTestSupport.frame(of: "git-commit-amend", in: host) + let ai = try await AgentNativeControlTestSupport.frame(of: "git-commit-ai", in: host) + #expect(amend.height <= 22, "Amend must occupy a single line: \(amend)") + #expect(amend.minX >= 0 && amend.maxX < ai.minX && ai.maxX <= host.bounds.maxX) + #expect(abs(amend.midY - ai.midY) < 2) + if let folder = ProcessInfo.processInfo.environment["LITHE_COMMIT_LAYOUT_CAPTURE_DIR"] { + #expect(Bundle.main.path(forResource: language, ofType: "lproj") != nil) + #expect(LitheIcons.ideaImage(resourcePath: "expui/general/history.svg") != nil) + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = application.accessibilityAttributeValue(attribute) + application.accessibilitySetValue(true, forAttribute: attribute) + defer { application.accessibilitySetValue(previous, forAttribute: attribute) } + func elements(_ value: Any) -> [AnyObject] { + let object = value as AnyObject + let children = object.accessibilityChildren?() ?? [] + let descendants: [Any] = children.isEmpty ? ((value as? NSView)?.subviews ?? []) : children + return [object] + descendants.flatMap(elements) + } + let control = try #require(elements(host).first { $0.accessibilityIdentifier?() == "git-commit-amend" }) + #expect(control.accessibilityLabel?() == (language == "zh-Hans" ? "修订提交(上一次)" : "Amend last commit")) + try FileManager.default.createDirectory(atPath: folder, withIntermediateDirectories: true) + let bitmap = try nativeSRGBCapture(host) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: + URL(fileURLWithPath: folder).appendingPathComponent("commit-default-\(language)-\(scheme).png")) + } + } + @Test(arguments: [ColorScheme.dark, .light], [false, true]) func commitFormStaysInsideItsSplitPaneWhenTheFooterStacks(scheme: ColorScheme, hasResults: Bool) async throws { MacBundledFontRegistry.registerFonts() diff --git a/macos/Tests/LitheTests/CommitDraftFeatureModelTests.swift b/macos/Tests/LitheTests/CommitDraftFeatureModelTests.swift index f7c80bd407..8ab53f009a 100644 --- a/macos/Tests/LitheTests/CommitDraftFeatureModelTests.swift +++ b/macos/Tests/LitheTests/CommitDraftFeatureModelTests.swift @@ -4,6 +4,16 @@ import Testing @Suite("Commit draft") @MainActor struct CommitDraftFeatureModelTests { + @Test func commitFilesOnlyRequestsFocusAndPreservesTheDraft() { + let draft = CommitDraftFeatureModel() + draft.message = "Keep my existing message" + draft.amend = true + draft.requestCommitEditor() + draft.requestCommitEditor() + #expect(draft.commitEditorRequestVersion == 2) + #expect(draft.message == "Keep my existing message" && draft.amend) + } + @Test func undoRestoresTheFullMessageOnlyIntoAnEmptyDraft() { let draft = CommitDraftFeatureModel() diff --git a/macos/Tests/LitheTests/CommitFileDeletionTests.swift b/macos/Tests/LitheTests/CommitFileDeletionTests.swift new file mode 100644 index 0000000000..812b5b8b9f --- /dev/null +++ b/macos/Tests/LitheTests/CommitFileDeletionTests.swift @@ -0,0 +1,258 @@ +import AppKit +import Foundation +import LitheCoreContracts +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +@Suite("Commit file deletion", .serialized) +struct CommitFileDeletionTests { + @Test func cancellationReplacementAndRepeatedConfirmationNeverReplayConsent() async throws { + let files = DeletionTestFiles() + var deleted: [[URL]] = [] + let feature = makeFeature(files) + defer { feature.reset() } + func request(_ name: String) { + feature.request([root.appendingPathComponent(name)], root: root, isCurrent: { true }, + delete: { deleted.append($0); return true }, refresh: {}) + } + request("first.txt") + let cancelled = try #require(feature.pending) + feature.cancel() + await feature.confirm(cancelled) + request("second.txt") + let replaced = try #require(feature.pending) + request("third.txt") + await feature.confirm(replaced) + let confirmed = try #require(feature.pending) + let task = try #require(feature.beginConfirmation(confirmed)) + defer { task.cancel() } + #expect(feature.pending == nil && feature.isDeleting) + feature.cancel() // Native presenter dismisses after consuming consent. + await task.value + await feature.confirm(confirmed) + #expect(deleted == [[root.appendingPathComponent("third.txt")]]) + #expect(!feature.isDeleting && files.trashed.isEmpty) + } + + @Test(arguments: ["dirty", "stale", "ineligible", "cancelled", "flush-failed"]) + func editorSynchronizationRechecksEveryMutationPrecondition(outcome: String) async throws { + let files = DeletionTestFiles(), reached = TestGate(), release = TestGate() + var dirty = false, current = true, eligible = true, writes = 0 + let feature = CommitFileDeletionFeatureModel(files: files, hasUnsavedEdits: { _ in dirty }, + closeCleanDocuments: { _ in }, synchronizeEditors: { _ in + reached.open() + let released = await release.waitUntilOpen() + #expect(released || Task.isCancelled) + if outcome == "flush-failed" { throw CocoaError(.fileReadUnknown) } + }, notify: { _ in }) + feature.request([root.appendingPathComponent("target.txt")], root: root, + isCurrent: { current }, isEligible: { eligible }, delete: { _ in writes += 1; return true }, refresh: {}) + let request = try #require(feature.pending) + let task = try #require(feature.beginConfirmation(request)) + defer { release.open(); task.cancel(); feature.reset() } + try #require(await reached.waitUntilOpen()) + #expect(feature.isDeleting) + switch outcome { + case "dirty": dirty = true + case "stale": current = false + case "ineligible": eligible = false + case "cancelled": feature.reset() + default: break + } + release.open() + await task.value + #expect(writes == 0 && files.trashed.isEmpty && !feature.isDeleting) + } + + @Test func nativeTrashStopsAtTheFirstFailureAndOnlyClosesSuccessfullyDeletedCleanFiles() async throws { + let files = DeletionTestFiles() + files.failureName = "b.txt" + var closed: [URL] = [], refreshed = 0, errors: [String] = [] + let feature = CommitFileDeletionFeatureModel(files: files, hasUnsavedEdits: { _ in false }, + closeCleanDocuments: { closed.append($0) }, notify: { errors.append($0) }) + defer { feature.reset() } + feature.request(["a.txt", "b.txt", "c.txt"].map { root.appendingPathComponent($0) }, root: root, + isCurrent: { true }, refresh: { refreshed += 1 }) + await feature.confirm(try #require(feature.pending)) + #expect(files.trashed == ["a.txt", "b.txt"]) + #expect(closed == [root.appendingPathComponent("a.txt")]) + #expect(refreshed == 1 && errors.count == 1) + } + + @Test func providerDeletionUsesExactlyTheConfirmedBatchAndRefreshesAfterStatusChanges() async throws { + let files = DeletionTestFiles() + var current = true, eligible = true, closed: [URL] = [], refreshed = 0, batches: [[URL]] = [] + let feature = CommitFileDeletionFeatureModel(files: files, hasUnsavedEdits: { _ in false }, + closeCleanDocuments: { closed.append($0) }, notify: { _ in }) + defer { feature.reset(); current = false } + let target = root.appendingPathComponent("folder") + feature.request([target.appendingPathComponent("child.txt"), target, target], root: root, usesVcs: true, + isCurrent: { current }, isEligible: { eligible }, delete: { urls in + batches.append(urls) + eligible = false // Provider status no longer contains the removed paths. + return true + }, refresh: { refreshed += 1 }) + await feature.confirm(try #require(feature.pending)) + #expect(batches == [[target]] && closed == [target] && refreshed == 1) + #expect(files.trashed.isEmpty, "Tracked deletion never falls back to the native filesystem") + } + + @Test func refusesDirtyFilesAndPathsOutsideOrEqualToTheRoot() { + let files = DeletionTestFiles() + var dirty = false, warnings = 0 + let feature = CommitFileDeletionFeatureModel(files: files, hasUnsavedEdits: { _ in dirty }, + closeCleanDocuments: { _ in }, notify: { _ in warnings += 1 }) + defer { feature.reset() } + for url in [root, URL(fileURLWithPath: "/outside/file.txt"), root.appendingPathComponent("../escaped.txt")] { + feature.request([url], root: root, isCurrent: { true }, refresh: {}) + #expect(feature.pending == nil) + } + dirty = true + feature.request([root.appendingPathComponent("dirty.txt")], root: root, isCurrent: { true }, refresh: {}) + #expect(feature.pending == nil && warnings == 1) + } + + @Test(arguments: ["escape", "close"], [false, true]) + func nativeConfirmationDismissalNeverDeletesFiles(action: String, dark: Bool) async throws { + let files = DeletionTestFiles(), feature = makeFeature(files) + let host = NSHostingView(rootView: Color.clear.frame(width: 700, height: 450) + .modifier(CommitFileDeletionPresentation(feature: feature)) + .environment(\.colorScheme, dark ? .dark : .light) + .environment(\.locale, Locale(identifier: "zh-Hans"))) + let parent = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 700, height: 450), + styleMask: [.borderless], backing: .buffered, defer: false) + parent.isReleasedWhenClosed = false + parent.contentView = host; parent.center(); parent.orderFront(nil) + defer { feature.reset(); parent.contentView = nil; parent.close() } + feature.request([root.appendingPathComponent("target.txt")], root: root, isCurrent: { true }, refresh: {}) + let captured = try #require(feature.pending) + func settle(_ predicate: () -> Bool) async -> Bool { + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if predicate() { return true } + await Task.yield() + } while clock.now < deadline + return predicate() + } + try #require(await settle { + parent.childWindows?.contains { $0.isVisible && $0.contentView?.bounds.isEmpty == false } == true + }) + let visiblePanel = parent.childWindows?.first { $0.isVisible } + let panel = try #require(visiblePanel) + #expect(panel.styleMask.contains(.titled) && panel.styleMask.contains(.closable)) + _ = try nativeSRGBCapture(try #require(panel.contentView)) + if action == "close" { panel.performClose(nil) } + else { + panel.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: panel.windowNumber, context: nil, characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53))) + } + #expect(await settle { feature.pending == nil && !feature.isDeleting && panel.parent == nil }) + #expect(files.trashed.isEmpty) + await feature.confirm(captured) + #expect(files.trashed.isEmpty) + } + + @Test(arguments: [false, true], ["en", "zh-Hans"]) + func nativeDialogButtonsCancelOrConfirmExactlyOnce(dark: Bool, language: String) async throws { + MacBundledFontRegistry.registerFonts() + for usesVcs in [false, true] { + for confirm in [false, true] { + try await exerciseDialogButtons(confirm: confirm, dark: dark, language: language, usesVcs: usesVcs) + } + } + } + + private func exerciseDialogButtons(confirm: Bool, dark: Bool, language: String, usesVcs: Bool) async throws { + let files = DeletionTestFiles(), feature = makeFeature(files) + var providerDeleted: [URL] = [] + let delete: (@MainActor ([URL]) async -> Bool)? + if usesVcs { delete = { providerDeleted.append(contentsOf: $0); return true } } + else { delete = nil } + feature.request([root.appendingPathComponent("target.txt")], root: root, usesVcs: usesVcs, + isCurrent: { true }, delete: delete, refresh: {}) + let request = try #require(feature.pending) + var deletion: Task? + let host = NSHostingView(rootView: CommitFileDeletionDialog(request: request, + onCancel: { feature.cancel() }, onDelete: { deletion = feature.beginConfirmation(request) }) + .environment(\.colorScheme, dark ? .dark : .light) + .environment(\.locale, Locale(identifier: language))) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 520, height: 300), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.contentView = host; window.center(); window.orderFront(nil) + defer { deletion?.cancel(); feature.reset(); window.contentView = nil; window.close() } + let app = NSApplication.shared, attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = app.accessibilityAttributeValue(attribute) + app.accessibilitySetValue(true, forAttribute: attribute) + defer { app.accessibilitySetValue(previous, forAttribute: attribute) } + func elements(_ value: Any) -> [AnyObject] { + let object = value as AnyObject + let children = object.accessibilityChildren?() ?? [] + let descendants: [Any] = children.isEmpty ? ((value as? NSView)?.subviews ?? []) : children + return [object] + descendants.flatMap(elements) + } + for id in ["commit-files-delete-cancel", "commit-files-delete-confirm"] { + #expect(host.bounds.contains(try await AgentNativeControlTestSupport.frame(of: id, in: host))) + } + if let folder = ProcessInfo.processInfo.environment["LITHE_COMMIT_DELETE_CAPTURE_DIR"], !confirm { + #expect(Bundle.main.path(forResource: language, ofType: "lproj") != nil) + let control = try #require(elements(host).first { $0.accessibilityIdentifier?() == "commit-files-delete-confirm" }) + #expect(control.accessibilityLabel?() == (language == "zh-Hans" ? "删除" : "Delete")) + let warning = try #require(elements(host).first { $0.accessibilityIdentifier?() == "commit-files-delete-warning" }) + let expected: String + if usesVcs { + expected = language == "zh-Hans" ? "版本控制插件将删除这些文件并记录删除操作,文件可能不会移入废纸篓。" + : "The version control provider will delete these files and record their removal. They may not be moved to Trash." + } else { + expected = language == "zh-Hans" ? "这些文件将移到 macOS 废纸篓。" + : "These files will be moved to the macOS Trash." + } + let value: String? = warning.accessibilityValue?() + #expect((value ?? warning.accessibilityLabel?()) == expected) + try FileManager.default.createDirectory(atPath: folder, withIntermediateDirectories: true) + let bitmap = try nativeSRGBCapture(host) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: + URL(fileURLWithPath: folder).appendingPathComponent("delete-\(usesVcs ? "vcs" : "trash")-\(language)-\(dark ? "dark" : "light").png")) + } + let id = confirm ? "commit-files-delete-confirm" : "commit-files-delete-cancel" + let button = try #require(elements(host).first { $0.accessibilityIdentifier?() == id }) + #expect(button.accessibilityPerformPress?() == true) + await deletion?.value + #expect(feature.pending == nil && !feature.isDeleting) + #expect(files.trashed == (confirm && !usesVcs ? ["target.txt"] : [])) + #expect(providerDeleted == (confirm && usesVcs ? [root.appendingPathComponent("target.txt")] : [])) + await feature.confirm(request) + #expect(files.trashed == (confirm && !usesVcs ? ["target.txt"] : [])) + #expect(providerDeleted == (confirm && usesVcs ? [root.appendingPathComponent("target.txt")] : [])) + } + + private var root: URL { URL(fileURLWithPath: "/fixture/commit-deletion") } + + private func makeFeature(_ files: DeletionTestFiles) -> CommitFileDeletionFeatureModel { + CommitFileDeletionFeatureModel(files: files, hasUnsavedEdits: { _ in false }, + closeCleanDocuments: { _ in }, notify: { _ in }) + } +} + +private final class DeletionTestFiles: WorkspaceFileOperations, @unchecked Sendable { + private let lock = NSLock() + private var calls: [String] = [] + var failureName: String? + var trashed: [String] { lock.withLock { calls } } + func fileExists(at url: URL) -> Bool { true } + func isDirectory(at url: URL) -> Bool { false } + func trashItem(at url: URL) throws { + lock.withLock { calls.append(url.lastPathComponent) } + if url.lastPathComponent == failureName { throw CocoaError(.fileWriteNoPermission) } + } + func createFile(at url: URL) throws { throw CocoaError(.featureUnsupported) } + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws { throw CocoaError(.featureUnsupported) } + func copyItem(at sourceURL: URL, to destinationURL: URL) throws { throw CocoaError(.featureUnsupported) } + func moveItem(at sourceURL: URL, to destinationURL: URL) throws { throw CocoaError(.featureUnsupported) } + func removeItem(at url: URL) throws { throw CocoaError(.featureUnsupported) } + func writeText(_ text: String, to url: URL) throws { throw CocoaError(.featureUnsupported) } + func readText(from url: URL) throws -> String { "" } +} diff --git a/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift b/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift index d3e6c48587..9bdb34eb79 100644 --- a/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift +++ b/macos/Tests/LitheTests/EditorTabOrderFeatureModelTests.swift @@ -966,6 +966,35 @@ struct EditorTabOrderFeatureModelTests { await model.shutdownProjectSession() } + @Test func explicitNewDiffTabKeepsAnIndependentSessionWhileOrdinaryOpenReusesItsPreview() async throws { + let store = EditorTabOrderTestStore(), root = URL(fileURLWithPath: "/workspace") + let settings = AppSettings(store: store) + let model = AppModel(settings: settings, services: MacServiceContainer( + store: store, settings: settings, moduleLaunchMode: .safeMode).services) + let feature = GitFeatureModel(service: GitService(operations: DiffToolbarGitOperations(root: root)), + diffDocumentProvider: { _, _ in DiffDocument(rows: [], hunks: []) }) + model.moduleCapabilityStore.cache(GitModuleCapability(feature: feature), id: .gitWorkspace, moduleID: .git) + do { + let change = GitChange(repositoryRoot: root, path: "same.swift", originalPath: nil, + indexStatus: " ", workTreeStatus: "M") + model.selectChange(change) + let first = try #require(model.editorTabOrderFeature.repositoryDiffIDs.first) + let preview = feature.repositoryDiffSession(id: first) + try #require(await awaitChange(on: preview) { preview.selectedChange == change && !preview.isLoadingDiff }) + model.selectChange(change, inNewTab: true) + let second = try #require(model.editorTabOrderFeature.repositoryDiffIDs.last) + let independent = feature.repositoryDiffSession(id: second) + try #require(await awaitChange(on: independent) { independent.selectedChange == change && !independent.isLoadingDiff }) + #expect(first != second && preview !== independent) + model.selectChange(change) + #expect(model.selectedRepositoryDiffID == first && model.editorTabOrderFeature.repositoryDiffIDs.count == 2) + model.closeRepositoryDiffTab(first) + #expect(feature.repositoryDiffSessions[first] == nil && feature.repositoryDiffSessions[second] === independent) + } catch { feature.reset(); await model.shutdownProjectSession(); throw error } + feature.reset() + await model.shutdownProjectSession() + } + @Test func multipleDiffTabsKeepNativeScrollAndOnlySelectedTabAcceptsFocus() async throws { let store = EditorTabOrderTestStore(), root = URL(fileURLWithPath: "/workspace") let settings = AppSettings(store: store) diff --git a/macos/Tests/LitheTests/GitRollbackDialogTests.swift b/macos/Tests/LitheTests/GitRollbackDialogTests.swift new file mode 100644 index 0000000000..92d0c89144 --- /dev/null +++ b/macos/Tests/LitheTests/GitRollbackDialogTests.swift @@ -0,0 +1,102 @@ +import AppKit +import SwiftUI +import LitheGitModule +import Testing +@testable import Lithe + +@MainActor +@Suite("Git rollback dialog", .serialized) +struct GitRollbackDialogTests { + @Test(arguments: [false, true], ["en", "zh-Hans"]) + func rollbackDialogRendersReviewedFilesAndKeepsCloseSeparateFromConfirmation(dark: Bool, language: String) async throws { + MacBundledFontRegistry.registerFonts() + let root = URL(fileURLWithPath: "/workspace") + let changes = ["Sources/First.swift", "Sources/Second.swift"].map { + GitChange(repositoryRoot: root, path: $0, originalPath: nil, indexStatus: "M", workTreeStatus: "M") + } + let selection = GitRollbackSelection(contextID: UUID(), changes: changes, included: [changes[0]]) + var closed = 0 + var confirmed: [GitRollbackSelection] = [] + let host = NSHostingView(rootView: GitRollbackDialog(selection: selection, + onClose: { closed += 1 }, onRollback: { confirmed.append($0) }) + .environment(\.colorScheme, dark ? .dark : .light) + .environment(\.locale, Locale(identifier: language))) + host.sizingOptions = [] + let window = RollbackDialogTestWindow(contentRect: CGRect(x: 0, y: 0, width: 540, height: 520), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + window.orderFront(nil) + defer { window.contentView = nil; window.close() } + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = application.accessibilityAttributeValue(attribute) + application.accessibilitySetValue(true, forAttribute: attribute) + defer { application.accessibilitySetValue(previous, forAttribute: attribute) } + host.layoutSubtreeIfNeeded() + await Task.yield() + let bitmap = try nativeSRGBCapture(host) + if let directory = ProcessInfo.processInfo.environment["LITHE_ROLLBACK_CAPTURE_DIR"] { + #expect(Bundle.main.path(forResource: language, ofType: "lproj") != nil) + #expect(LitheIcons.ideaImage(resourcePath: "commit/checkBoxSelected.svg") != nil) + let destination = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: + destination.appendingPathComponent("rollback-\(language)-\(dark ? "dark" : "light").png")) + } + func elements(_ element: Any) -> [AnyObject] { + let object = element as AnyObject + let children = object.accessibilityChildren?() ?? [] + let descendants: [Any] = children.isEmpty ? ((element as? NSView)?.subviews ?? []) : children + return [object] + descendants.flatMap(elements) + } + for id in ["git-rollback-confirm", "git-rollback-close"] { + #expect(try await AgentNativeControlTestSupport.isEnabled(id, in: host)) + #expect(host.bounds.contains(try await AgentNativeControlTestSupport.frame(of: id, in: host))) + } + if ProcessInfo.processInfo.environment["LITHE_ROLLBACK_CAPTURE_DIR"] != nil { + let label = try #require(elements(host).first { $0.accessibilityIdentifier?() == "git-rollback-confirm" }) + #expect(label.accessibilityLabel?() == (language == "zh-Hans" ? "回滚" : "Rollback")) + } + let close = try #require(elements(host).first { $0.accessibilityIdentifier?() == "git-rollback-close" }) + #expect(close.accessibilityPerformPress?() == true) + #expect(closed == 1 && confirmed.isEmpty) + let first = try #require(elements(host).first { $0.accessibilityIdentifier?() == "git-rollback-include-" + changes[0].id }) + #expect(first.accessibilityPerformPress?() == true) + host.layoutSubtreeIfNeeded() + await Task.yield() + #expect(try await !AgentNativeControlTestSupport.isEnabled("git-rollback-confirm", in: host)) + let second = try #require(elements(host).first { $0.accessibilityIdentifier?() == "git-rollback-include-" + changes[1].id }) + #expect(second.accessibilityPerformPress?() == true) + host.layoutSubtreeIfNeeded() + await Task.yield() + #expect(try await AgentNativeControlTestSupport.isEnabled("git-rollback-confirm", in: host)) + let confirm = try #require(elements(host).first { $0.accessibilityIdentifier?() == "git-rollback-confirm" }) + #expect(confirm.accessibilityPerformPress?() == true) + #expect(confirmed.count == 1 && confirmed.first?.includedChanges == [changes[1]]) + #expect(selection.includedChanges == [changes[0]] && changes.allSatisfy(\.isStaged)) + } + + @Test + func emptyRollbackDraftDisablesDestructiveConfirmation() async throws { + let selection = GitRollbackSelection(contextID: UUID(), changes: [], included: []) + let host = NSHostingView(rootView: GitRollbackDialog(selection: selection, onClose: {}, onRollback: { _ in + Issue.record("Empty rollback must not execute") + })) + let window = RollbackDialogTestWindow(contentRect: CGRect(x: 0, y: 0, width: 540, height: 520), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.orderFront(nil) + defer { window.contentView = nil; window.close() } + #expect(try await !AgentNativeControlTestSupport.isEnabled("git-rollback-confirm", in: host)) + #expect(try await AgentNativeControlTestSupport.isEnabled("git-rollback-close", in: host)) + } +} + +@MainActor +private final class RollbackDialogTestWindow: NSWindow { + // Native focus behavior is tested without taking the user's desktop focus. + override var isKeyWindow: Bool { true } +} diff --git a/macos/Tests/LitheTests/GitStatusObservationTests.swift b/macos/Tests/LitheTests/GitStatusObservationTests.swift index ab905cd1c4..4ddca4677c 100644 --- a/macos/Tests/LitheTests/GitStatusObservationTests.swift +++ b/macos/Tests/LitheTests/GitStatusObservationTests.swift @@ -1,4 +1,6 @@ +import AppKit import Foundation +import SwiftUI @testable import LitheGitModule import LitheSearchModule import Testing @@ -6,6 +8,390 @@ import Testing @Suite("Git status observation", .serialized) struct GitStatusObservationTests { + @Test @MainActor + func mountedCommitPanelObservesExternalEditsAndKeepsRollbackEnabledDuringBackgroundReads() async throws { + let fixture = try GitObservationFixture(label: "mounted-commit-refresh") + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + let barrier = GitObservationReadBarrier() + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge())), + stashesProvider: { _ in await barrier.waitIfArmed(); return [] }) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, + notify: { _ in }, onStateRefreshed: {}) + let recorder = GitObservationRecorder() + let workspace = makeObservationModel(recorder: recorder, refreshGit: { await feature.refreshGitFromMetadataChange() }) + let suite = "commit-observation-\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + let settings = AppSettings(store: MacUserDefaultsStore(defaults: defaults)) + let draft = CommitDraftFeatureModel() + let workflow = CommitWorkflowCoordinator(draft: draft, activateGit: { feature }, + workspaceGeneration: { workspace.workspaceGeneration }, generate: { _ in "" }, notify: { _ in }) + let host = NSHostingView(rootView: ChangesSidebarView(feature: feature, draft: draft, + commitWorkflow: workflow, workbench: WorkbenchFeatureModel(), hasBackgroundImage: false, + openSavedDiff: { _, _, _ in }, selectChange: { _ in }, setStaging: { _, _ in }, + openFile: { _, _ in }, showLocalHistory: { _ in }, revealInFinder: { _ in }, + copyPath: { _, _ in }, showSettings: { _ in }) + .environmentObject(settings).environment(\.locale, Locale(identifier: "en"))) + host.sizingOptions = [] + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 449, height: 800), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = host + window.center() + window.orderFront(nil) + var heldRead: Task? + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (application.accessibilityAttributeValue(attribute) as? Bool) ?? false + application.accessibilitySetValue(true, forAttribute: attribute) + defer { + heldRead?.cancel(); barrier.release.open(); workspace.reset(); feature.reset() + for popup in window.childWindows ?? [] { popup.close() } + window.contentView = nil; window.close() + application.accessibilitySetValue(previous, forAttribute: attribute) + defaults.removePersistentDomain(forName: suite) + } + try await startObservation(workspace, at: root, recorder: recorder) + _ = try await AgentNativeControlTestSupport.frame(of: "git-rollback", in: host) + let tracked = root.appendingPathComponent("tracked.txt") + try Data("external edit\n".utf8).write(to: tracked) + try #require(await waitUntil { feature.gitChanges.contains { $0.path == "tracked.txt" } }) + #expect(await commitPanelRendered(host) { commitPanelContainsText("tracked.txt", in: host) }) + let added = root.appendingPathComponent("external-new.txt") + try Data("external creation\n".utf8).write(to: added) + try #require(await waitUntil { feature.gitChanges.contains { $0.path == "external-new.txt" } }) + #expect(await commitPanelRendered(host) { commitPanelContainsText("external-new.txt", in: host) }) + try FileManager.default.removeItem(at: added) + try #require(await waitUntil { !feature.gitChanges.contains { $0.path == "external-new.txt" } }) + #expect(await commitPanelRendered(host) { + !commitPanelContainsText("external-new.txt", in: host) && commitPanelContainsText("tracked.txt", in: host) + }) + try await fixture.git(["add", "tracked.txt"], at: root) + try #require(await waitUntil { feature.gitChanges.first { $0.path == "tracked.txt" }?.isStaged == true }) + // Keep this same mounted panel, then hold an unrelated read after status publication. + workspace.prepareForModuleRelease() + await barrier.arm() + let readFinished = TestGate() + heldRead = Task { await feature.refreshGit(); readFinished.open() } + try #require(await barrier.entered.waitUntilOpen()) + #expect(feature.isRefreshingGit) + #expect(try await AgentNativeControlTestSupport.isEnabled("git-rollback", in: host)) + let frame = try await AgentNativeControlTestSupport.frame(of: "git-rollback", in: host) + let point = host.convert(CGPoint(x: frame.midX, y: frame.midY), to: nil) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + window.sendEvent(try #require(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, + clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0))) + } + #expect(await commitPanelRendered(host) { + window.childWindows?.contains { $0 is NSPanel && $0.isVisible } == true + }) + #expect(feature.pendingRollbackSelection?.includedChanges.map(\.path) == ["tracked.txt"]) + feature.cancelRollbackChanges() + #expect(await commitPanelRendered(host) { window.childWindows?.contains { $0.isVisible } != true }) + barrier.release.open() + try #require(await readFinished.waitUntilOpen()) + await heldRead?.value + #expect(try await fixture.git(["diff", "--cached", "--name-only"], at: root) == "tracked.txt", + "Opening and cancelling the review must not modify the real index") + } + + @Test @MainActor + func realGitCommitFilesPreparesOnlySelectedFilesAndPreservesTheirPartialIndex() async throws { + let fixture = try GitObservationFixture(label: "prepare-commit-files") + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + let head = try await fixture.git(["rev-parse", "HEAD"], at: root) + try Data("staged content\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + try Data("unrelated staged content\n".utf8).write(to: root.appendingPathComponent("excluded.txt")) + try await fixture.git(["add", "."], at: root) + try Data("unstaged content\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + try Data("selected new content\n".utf8).write(to: root.appendingPathComponent("included.txt")) + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + let selected = feature.gitChanges.filter { ["tracked.txt", "included.txt"].contains($0.path) } + #expect(selected.count == 2 && feature.canPrepareCommitFiles(selected)) + #expect(await feature.prepareCommitFiles(selected)) + #expect(try await fixture.git(["diff", "--cached", "--name-only"], at: root) == "included.txt\ntracked.txt") + #expect(try await fixture.git(["show", ":tracked.txt"], at: root) == "staged content") + #expect(try String(contentsOf: root.appendingPathComponent("tracked.txt"), encoding: .utf8) == "unstaged content\n") + #expect(try String(contentsOf: root.appendingPathComponent("excluded.txt"), encoding: .utf8) == "unrelated staged content\n") + #expect(try await fixture.git(["rev-parse", "HEAD"], at: root) == head, "Preparation never creates a commit") + } + + @Test @MainActor + func realGitCommitFilesAcceptsAnUntrackedDirectoryExpandingIntoIndexEntries() async throws { + let fixture = try GitObservationFixture(label: "prepare-commit-directory") + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + try FileManager.default.createDirectory(at: root.appendingPathComponent("new/sub"), withIntermediateDirectories: true) + let paths = ["new/sub/two.txt"] + (1...101).map { "new/file-\($0).txt" } + for path in paths { + try Data(path.utf8).write(to: root.appendingPathComponent(path)) + } + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + let directory = try #require(feature.gitChanges.first { $0.isUntrackedDirectory }) + #expect(await feature.prepareCommitFiles([directory])) + let staged = try await fixture.git(["diff", "--cached", "--name-only"], at: root) + #expect(Set(staged.split(separator: "\n").map(String.init)) == Set(paths)) + } + + @Test @MainActor + func realGitCommitFilesUnstagesAnExcludedCopyWithoutUnstagingItsSelectedSource() async throws { + let fixture = try GitObservationFixture(label: "prepare-commit-copy") + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + try await fixture.git(["config", "status.renames", "copies"], at: root) + try Data("initial\n".utf8).write(to: root.appendingPathComponent("copy.txt")) + try Data("source staged\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + try await fixture.git(["add", "."], at: root) + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + #expect(feature.gitChanges.contains { $0.indexStatus == "C" }) + let source = try #require(feature.gitChanges.first { $0.path == "tracked.txt" }) + #expect(await feature.prepareCommitFiles([source])) + #expect(try await fixture.git(["diff", "--cached", "--name-only"], at: root) == "tracked.txt") + #expect(try await fixture.git(["show", ":tracked.txt"], at: root) == "source staged") + #expect(try String(contentsOf: root.appendingPathComponent("copy.txt"), encoding: .utf8) == "initial\n") + } + + @Test @MainActor + func realGitDeleteRefusesADirectoryContainingAnIndependentNestedRepository() async throws { + let fixture = try GitObservationFixture(label: "delete-nested-repository") + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + try FileManager.default.createDirectory(at: root.appendingPathComponent("folder"), withIntermediateDirectories: true) + for number in 1...101 { + try Data("ordinary content\n".utf8).write(to: root.appendingPathComponent("folder/file-\(number).txt")) + } + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + let directory = try #require(feature.gitChanges.first { $0.repositoryRoot == root && $0.path == "folder/" }) + // The menu captured a safe directory summary before a nested checkout + // appeared. A fresh ownership check must protect that checkout. + try await fixture.initializeRepository(at: root.appendingPathComponent("folder/nested")) + var writes = 0 + #expect(await !feature.deleteCommitFiles([directory]) { writes += 1; return true }) + #expect(writes == 0 && !feature.isDeletingCommitFiles) + } + + @Test(arguments: [false, true]) @MainActor + func realGitDeleteRefreshesMissingAdditionsAndPreservesTrackedDeletionAndUnrelatedIndex(partialFailure: Bool) async throws { + let fixture = try GitObservationFixture(label: "delete-refresh") + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + try Data("keep staged\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + for path in ["deleted.txt", "retained.txt", "unrelated.txt"] { + try Data(path.utf8).write(to: root.appendingPathComponent(path)) + } + try await fixture.git(["add", "."], at: root) + try Data("keep unstaged\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + try Data("untracked\n".utf8).write(to: root.appendingPathComponent("untracked.txt")) + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + let targets = feature.gitChanges.filter { ["deleted.txt", "retained.txt", "untracked.txt", "tracked.txt"].contains($0.path) } + #expect(targets.count == 4) + // Only fixture files are removed; native Trash ownership is tested separately. + let result = await feature.deleteCommitFiles(targets) { + for path in ["deleted.txt", "untracked.txt"] + (partialFailure ? [] : ["retained.txt", "tracked.txt"]) { + do { try FileManager.default.removeItem(at: root.appendingPathComponent(path)) } + catch { Issue.record(error); return false } + } + return !partialFailure + } + #expect(result != partialFailure) + #expect(!feature.gitChanges.contains { ["deleted.txt", "untracked.txt"].contains($0.path) }) + #expect(feature.gitChanges.contains { $0.path == "retained.txt" } == partialFailure) + let tracked = try #require(feature.gitChanges.first { $0.path == "tracked.txt" }) + #expect(tracked.workTreeStatus == (partialFailure ? "M" : "D")) + #expect(try await fixture.git(["show", ":tracked.txt"], at: root) == "keep staged") + #expect(try await fixture.git(["show", ":unrelated.txt"], at: root) == "unrelated.txt") + #expect(try await fixture.git(["diff", "--cached", "--name-only"], at: root) + == (partialFailure ? "retained.txt\ntracked.txt\nunrelated.txt" : "tracked.txt\nunrelated.txt")) + } + + @Test @MainActor + func realGitDeleteOfACopyNeverUnstagesItsIndependentlyModifiedSource() async throws { + let fixture = try GitObservationFixture(label: "delete-copy-refresh") + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + try await fixture.git(["config", "status.renames", "copies"], at: root) + try Data("initial\n".utf8).write(to: root.appendingPathComponent("copy.txt")) + try Data("staged source\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + try await fixture.git(["add", "."], at: root) + try Data("unstaged source\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + let copy = try #require(feature.gitChanges.first { $0.path == "copy.txt" && $0.indexStatus == "C" }) + #expect(await feature.deleteCommitFiles([copy]) { + do { try FileManager.default.removeItem(at: root.appendingPathComponent("copy.txt")); return true } + catch { Issue.record(error); return false } + }) + #expect(feature.gitChanges.map(\.path) == ["tracked.txt"]) + #expect(try await fixture.git(["show", ":tracked.txt"], at: root) == "staged source") + #expect(try String(contentsOf: root.appendingPathComponent("tracked.txt"), encoding: .utf8) == "unstaged source\n") + } + + @Test + @MainActor + func realGitRollbackCheckedFilesRestoresIndexAndWorktreeButNotUncheckedEdits() async throws { + let fixture = try GitObservationFixture(label: "rollback-checked") + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + for path in ["second.txt", "unchecked.txt"] { + try Data("initial\n".utf8).write(to: root.appendingPathComponent(path)) + } + try await fixture.git(["add", "."], at: root) + try await fixture.git(["commit", "-qm", "three files"], at: root) + for path in ["tracked.txt", "second.txt", "unchecked.txt"] { + try Data("staged edit\n".utf8).write(to: root.appendingPathComponent(path)) + } + try await fixture.git(["add", "tracked.txt", "second.txt"], at: root) + try Data("partially staged edit\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + let included = GitRollbackSelection.initialTargets(changes: feature.gitChanges, selectedIDs: []) + #expect(Set(included.map(\.path)) == ["tracked.txt", "second.txt"]) + let before = try await fixture.git(["diff", "--cached"], at: root) + feature.requestRollbackChanges(feature.gitChanges, included: included) + feature.cancelRollbackChanges() + #expect(try await fixture.git(["diff", "--cached"], at: root) == before) + #expect(try String(contentsOf: root.appendingPathComponent("tracked.txt"), encoding: .utf8) == "partially staged edit\n") + feature.requestRollbackChanges(feature.gitChanges, included: included) + let captured = try #require(feature.pendingRollbackSelection) + feature.cancelRollbackChanges() + await feature.confirmRollbackChanges(captured) + for path in ["tracked.txt", "second.txt"] { + #expect(try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) == "initial\n") + } + #expect(try await fixture.git(["diff", "--cached", "--name-only"], at: root).isEmpty) + #expect(try String(contentsOf: root.appendingPathComponent("unchecked.txt"), encoding: .utf8) == "staged edit\n") + #expect(feature.gitChanges.map(\.path) == ["unchecked.txt"]) + } + + @Test(arguments: [false, true]) + @MainActor + func realGitRollbackAddedFilesRequiresExplicitDeletion(deleteAdded: Bool) async throws { + let fixture = try GitObservationFixture(label: "rollback-added") + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + for path in ["added.txt", "untracked.txt", "intent.txt"] { + try Data("keep new content\n".utf8).write(to: root.appendingPathComponent(path)) + } + try await fixture.git(["add", "added.txt"], at: root) + try await fixture.git(["add", "-N", "intent.txt"], at: root) + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + feature.requestRollbackChanges(feature.gitChanges, included: feature.gitChanges) + var captured = try #require(feature.pendingRollbackSelection) + #expect(!captured.deleteAddedFiles && captured.hasIncludedAdditions) + captured.deleteAddedFiles = deleteAdded + await feature.confirmRollbackChanges(captured) + #expect(try await fixture.git(["diff", "--cached", "--name-only"], at: root).isEmpty) + for path in ["added.txt", "untracked.txt", "intent.txt"] { + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent(path).path) != deleteAdded) + if !deleteAdded { + #expect(try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) == "keep new content\n") + } + } + } + + @Test(arguments: [false, true]) + @MainActor + func realGitRollbackCopyNeverRestoresOrUnstagesItsUncheckedSource(deleteAdded: Bool) async throws { + let fixture = try GitObservationFixture(label: "rollback-copy") + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + try await fixture.git(["config", "status.renames", "copies"], at: root) + try Data("initial\n".utf8).write(to: root.appendingPathComponent("copy.txt")) + try Data("staged source edit\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + try await fixture.git(["add", "."], at: root) + try Data("unstaged source edit\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + let copy = try #require(feature.gitChanges.first { $0.indexStatus == "C" }) + #expect(copy.path == "copy.txt" && copy.originalPath == "tracked.txt") + let before = try await fixture.git(["diff", "--cached", "--", "tracked.txt"], at: root) + feature.requestRollbackChanges(feature.gitChanges, included: [copy]) + var captured = try #require(feature.pendingRollbackSelection) + #expect(captured.hasIncludedAdditions) + captured.deleteAddedFiles = deleteAdded + await feature.confirmRollbackChanges(captured) + #expect(try await fixture.git(["diff", "--cached", "--", "tracked.txt"], at: root) == before) + #expect(try String(contentsOf: root.appendingPathComponent("tracked.txt"), encoding: .utf8) == "unstaged source edit\n") + #expect(try await fixture.git(["diff", "--cached", "--name-only"], at: root) == "tracked.txt") + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent("copy.txt").path) != deleteAdded) + } + + @Test(arguments: ["rename", "delete", "retained-delete"]) + @MainActor + func realGitRollbackRestoresRenameAndDeletionIncludingRetainedDiskCopy(kind: String) async throws { + let fixture = try GitObservationFixture(label: "rollback-" + kind) + let root = fixture.url.appendingPathComponent("repository", isDirectory: true) + try await fixture.initializeRepository(at: root) + if kind == "rename" { try await fixture.git(["mv", "tracked.txt", "renamed.txt"], at: root) } + else if kind == "delete" { try await fixture.git(["rm", "tracked.txt"], at: root) } + else { + try await fixture.git(["rm", "--cached", "tracked.txt"], at: root) + try Data("retained edit\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + } + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + feature.requestRollbackChanges(feature.gitChanges, included: feature.gitChanges) + await feature.confirmRollbackChanges(try #require(feature.pendingRollbackSelection)) + #expect(try String(contentsOf: root.appendingPathComponent("tracked.txt"), encoding: .utf8) == "initial\n") + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("renamed.txt").path)) + #expect(try await fixture.git(["status", "--porcelain"], at: root).isEmpty) + } + + @Test + @MainActor + func realGitRollbackLinkedWorktreeNeverChangesPrimaryIndexOrFiles() async throws { + let fixture = try GitObservationFixture(label: "rollback-worktree") + let primary = fixture.url.appendingPathComponent("primary", isDirectory: true) + let linked = fixture.url.appendingPathComponent("linked", isDirectory: true) + try await fixture.initializeRepository(at: primary) + try await fixture.git(["worktree", "add", "-qb", "rollback-test", linked.path], at: primary) + for root in [primary, linked] { + try Data("staged edit\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + try await fixture.git(["add", "tracked.txt"], at: root) + } + let feature = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + feature.configure(workspaceURLProvider: { primary }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { feature.reset() } + await feature.refreshGit() + await feature.refreshWorktrees() + let worktree = try #require(feature.gitWorktrees.first { $0.url.standardizedFileURL == linked }) + await feature.inspectWorktree(worktree) + let scoped = feature.commitFeature(for: worktree) + scoped.requestRollbackChanges(scoped.gitChanges, included: scoped.gitChanges) + await scoped.confirmRollbackChanges(try #require(scoped.pendingRollbackSelection)) + #expect(try await fixture.git(["status", "--porcelain"], at: linked).isEmpty) + #expect(try await fixture.git(["diff", "--cached", "--name-only"], at: primary) == "tracked.txt") + #expect(try String(contentsOf: primary.appendingPathComponent("tracked.txt"), encoding: .utf8) == "staged edit\n") + } + @Test(arguments: [false, true]) @MainActor func realGitWorktreeRefreshAndStageAllExcludeIndependentNestedRepositories(linkedCheckout: Bool) async throws { @@ -639,7 +1025,10 @@ private struct GitObservationWatchContextProvider: GitWatchContextProviding { } @MainActor -private func makeObservationModel(recorder: GitObservationRecorder) -> WorkspaceFeatureModel { +private func makeObservationModel( + recorder: GitObservationRecorder, + refreshGit: @escaping @MainActor @Sendable () async -> Void = {} +) -> WorkspaceFeatureModel { let model = WorkspaceFeatureModel( operations: GitObservationWorkspaceOperations(), fileOperations: MacWorkspaceFileOperations(), @@ -668,6 +1057,7 @@ private func makeObservationModel(recorder: GitObservationRecorder) -> Workspace recorder.projectServiceReloadCount += 1 }, refreshGit: { + await refreshGit() recorder.gitRefreshCount += 1 }, updateHistoryVisibilityRules: { _ in }, @@ -676,6 +1066,43 @@ private func makeObservationModel(recorder: GitObservationRecorder) -> Workspace return model } +private actor GitObservationReadBarrier { + let entered = TestGate(), release = TestGate() + private var armed = false + + func arm() { armed = true } + + func waitIfArmed() async { + guard armed else { return } + armed = false + entered.open() + #expect(await release.waitUntilOpen(timeout: .seconds(8))) + } +} + +@MainActor +private func commitPanelContainsText(_ text: String, in value: Any) -> Bool { + let element = value as AnyObject + // Checkbox values are numbers, not NSStrings; bridge only after dispatch. + let rawValue: Any? = element.accessibilityValue?() + if rawValue as? String == text || element.accessibilityLabel?() == text { return true } + let children = element.accessibilityChildren?() ?? (value as? NSView)?.subviews ?? [] + return children.contains { commitPanelContainsText(text, in: $0) } +} + +@MainActor +private func commitPanelRendered(_ host: NSView, until condition: () -> Bool) async -> Bool { + func renderFrame() { CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0, true) } + let clock = ContinuousClock(), deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + renderFrame() + if condition() { return true } + await Task.yield() + } while clock.now < deadline + return condition() +} + @MainActor private func startObservation( _ model: WorkspaceFeatureModel, diff --git a/macos/Tests/LitheTests/GitWorktreeAppearanceTests.swift b/macos/Tests/LitheTests/GitWorktreeAppearanceTests.swift index c2c5c95a50..53befbb0c2 100644 --- a/macos/Tests/LitheTests/GitWorktreeAppearanceTests.swift +++ b/macos/Tests/LitheTests/GitWorktreeAppearanceTests.swift @@ -169,29 +169,86 @@ struct GitWorktreeAppearanceTests { window.isReleasedWhenClosed = false window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) window.contentView = host + // Leave the real dialog's footer on screen even though the owner is short. + window.center() window.orderFront(nil) - defer { window.contentView = nil; window.close() } + defer { + window.contentView = nil + for popup in window.childWindows ?? [] { popup.close() } + window.close() + } + let application = NSApplication.shared + let accessibility = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previousAccessibility = application.accessibilityAttributeValue(accessibility) + application.accessibilitySetValue(true, forAttribute: accessibility) + defer { application.accessibilitySetValue(previousAccessibility, forAttribute: accessibility) } func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } _ = try await AgentNativeControlTestSupport.frame(of: "worktree-changes-Refresh changes", in: host) - // Nothing is selected, yet the toolbar is not a row of dead buttons: staging and the diff - // work on the files as a whole, and only the irreversible discard waits for a selection. + // Rollback opens a reviewable inclusion draft even without a highlighted row. #expect(try await AgentNativeControlTestSupport.isEnabled("worktree-changes-Stage all files", in: host)) #expect(try await AgentNativeControlTestSupport.isEnabled("worktree-changes-Show Diff", in: host)) - #expect(try await !AgentNativeControlTestSupport.isEnabled("worktree-changes-Discard selected change", in: host)) + #expect(try await AgentNativeControlTestSupport.isEnabled("worktree-changes-Rollback Changes", in: host)) #expect(try await !AgentNativeControlTestSupport.isEnabled("git-commit-submit", in: host)) + func clickControl(_ identifier: String, in view: NSView) async throws { + let frame = try await AgentNativeControlTestSupport.frame(of: identifier, in: view) + let owner = try #require(view.window) + let point = view.convert(NSPoint(x: frame.midX, y: frame.midY), to: nil) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + owner.sendEvent(try #require(NSEvent.mouseEvent(with: type, location: point, + modifierFlags: [], timestamp: 0, windowNumber: owner.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0))) + } + } + func waitForPresentation(_ visible: Bool) async -> Bool { + // SwiftUI schedules the real child panel after its owner has laid out. + func renderFrame() { CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0, true) } + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if (window.childWindows?.contains { $0 is NSPanel && $0.isVisible } == true) == visible { return true } + await Task.yield() + renderFrame() + } while clock.now < deadline + Issue.record("Rollback panel visibility did not become \(visible) within two seconds") + return false + } + let beforeRollback = scoped.gitChanges + let beforeCommands = scoped.gitConsoleEntries.count + try await clickControl("worktree-changes-Rollback Changes", in: host) + try #require(await waitForPresentation(true)) + #expect(scoped.pendingRollbackSelection?.includedChanges == [change]) + let popup = try #require(window.childWindows?.first { $0 is NSPanel && $0.isVisible }) + #expect(popup.title == "Rollback Changes" && popup.styleMask.contains(.titled)) + let popupContent = try #require(popup.contentView) + _ = try nativeSRGBCapture(popupContent) + // Exercise Close's cancel shortcut through the real panel's responder + // chain; standalone dialog tests separately press its accessible button. + popup.sendEvent(try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: popup.windowNumber, context: nil, characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53))) + try #require(await waitForPresentation(false)) + #expect(scoped.pendingRollbackSelection == nil && scoped.gitChanges == beforeRollback) + #expect(scoped.gitConsoleEntries.count == beforeCommands) + try await clickControl("worktree-changes-Rollback Changes", in: host) + try #require(await waitForPresentation(true)) + let reopened = try #require(window.childWindows?.first { $0 is NSPanel && $0.isVisible }) + reopened.performClose(nil) + try #require(await waitForPresentation(false)) + #expect(scoped.pendingRollbackSelection == nil && scoped.gitConsoleEntries.count == beforeCommands) let document = try #require(descendants(host).compactMap { $0 as? GitWorktreeRowsNSView }.first) let point = document.convert(CGPoint(x: 100, y: GitWorktreeRowsView.rowHeight * 1.5), to: nil) document.mouseDown(with: try #require(NSEvent.mouseEvent(with: .leftMouseDown, location: point, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1))) #expect(opened == [change] && opened.first?.repositoryRoot == worktree.url) - #expect(try await AgentNativeControlTestSupport.isEnabled("worktree-changes-Discard selected change", in: host)) + #expect(try await AgentNativeControlTestSupport.isEnabled("worktree-changes-Rollback Changes", in: host)) var previousDocument = document var previousAxis = LitheSplitAxis.horizontal for width: CGFloat in [850, 700, 320, 600, 850] { window.setContentSize(CGSize(width: width, height: 300)) host.layoutSubtreeIfNeeded() - for title in ["Stage all files", "Discard selected change", "Show Diff"] { + for title in ["Stage all files", "Rollback Changes", "Show Diff"] { let frame = try await AgentNativeControlTestSupport.frame(of: "worktree-changes-" + title, in: host) #expect(host.bounds.contains(frame)) } diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index cd33127c79..bf80362af7 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -4076,6 +4076,55 @@ struct EditorDocumentTests { #expect(refreshCount == refreshesAfterLoad + 1) } + @Test(arguments: [false, true]) @MainActor + func gitOperationThawSchedulesOwnedRefreshWithoutHoldingTheActionOpen(resetBeforeRefresh: Bool) async throws { + let workspace = URL(fileURLWithPath: "/in-memory/git-thaw"), file = workspace.appendingPathComponent("App.swift") + let factory = TestDirectoryWatcherFactory() + let initialStarted = TestGate(), initialRelease = TestGate(), initialFinished = TestGate() + let thawStarted = TestGate(), thawRelease = TestGate(), thawFinished = TestGate(), refreshed = TestGate() + var refreshCount = 0 + let model = makeWorkspaceObservationUnitModel( + operations: SequencedWorkspaceOperations(snapshotAvailability: [true], files: [file]), + fileOperations: ExistingWorkspaceFileOperations(paths: [file.path]), + provider: SequencedGitWatchContextProvider([nil]), watcherFactory: factory, + refreshGit: { refreshCount += 1; if thawRelease.isOpen { refreshed.open() } }, + observationDelay: { _ in + if !initialStarted.isOpen { + initialStarted.open() + defer { initialFinished.open() } + guard await initialRelease.waitUntilOpen() else { throw CancellationError() } + } else { + thawStarted.open() + defer { thawFinished.open() } + guard await thawRelease.waitUntilOpen() else { throw CancellationError() } + } + try Task.checkCancellation() + }) + defer { initialRelease.open(); thawRelease.open(); model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + _ = await model.rebuild(at: workspace, rules: .default, isCurrent: { true }) + let loaded = refreshCount + let source = try #require(factory.source) + source.emit([file.path]) + try #require(await initialStarted.waitUntilOpen()) + model.beginGitOperationFreeze(); model.beginGitOperationFreeze() + initialRelease.open() + try #require(await initialFinished.waitUntilOpen()) + await model.endGitOperationFreeze(waitForRefresh: false) + #expect(model.gitOperationFreezeDepth == 1 && refreshCount == loaded) + await model.endGitOperationFreeze(waitForRefresh: false) + try #require(await thawStarted.waitUntilOpen()) + #expect(model.gitOperationFreezeDepth == 0 && refreshCount == loaded) + if resetBeforeRefresh { model.reset() } + thawRelease.open() + try #require(await thawFinished.waitUntilOpen()) + if resetBeforeRefresh { #expect(refreshCount == loaded) } + else { + try #require(await refreshed.waitUntilOpen()) + #expect(refreshCount == loaded + 1) + } + } + @Test func directoryWatchConfigurationNormalizesAndDeduplicatesCoveredRoots() { let repository = URL(fileURLWithPath: "/tmp/lithe-watch/repository") @@ -4244,6 +4293,158 @@ struct EditorDocumentTests { #expect(refreshCount == 2) } + @Test(arguments: [false, true]) + @MainActor + func fileEventsRefreshGitWhileProjectServicesAreStillReloading(thawAfterMutation: Bool) async throws { + let workspace = URL(fileURLWithPath: "/in-memory/lithe-live-git") + let build = workspace.appendingPathComponent("pom.xml") + let source = workspace.appendingPathComponent("Main.java") + let factory = TestDirectoryWatcherFactory() + let reloadEntered = TestGate(), releaseReload = TestGate(), reloadFinished = TestGate() + let firstRefresh = TestGate(), secondRefresh = TestGate() + let observing = TestGate() + var refreshCount = 0 + let delayStarted = TestGate(), releaseDelay = TestGate() + let model = makeWorkspaceObservationUnitModel( + operations: SequencedWorkspaceOperations(snapshotAvailability: [true], files: [build, source]), + fileOperations: ExistingWorkspaceFileOperations(paths: [build.path, source.path]), + provider: SequencedGitWatchContextProvider([nil]), watcherFactory: factory, + refreshGit: { + guard observing.isOpen else { return } + refreshCount += 1 + if refreshCount == 1 { firstRefresh.open() } + if refreshCount == 2 { secondRefresh.open() } + }, + reloadProjectServices: { + reloadEntered.open() + if await releaseReload.waitUntilOpen(timeout: .seconds(5)) { reloadFinished.open() } + }, observationDelay: { _ in + delayStarted.open() + guard await releaseDelay.waitUntilOpen() else { throw CancellationError() } + try Task.checkCancellation() + } + ) + defer { releaseDelay.open(); releaseReload.open(); model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + _ = await model.rebuild(at: workspace, rules: .default, isCurrent: { true }) + observing.open() + let watcher = try #require(factory.source) + watcher.emit([build.path]) + try #require(await delayStarted.waitUntilOpen()) + if thawAfterMutation { model.beginGitOperationFreeze() } + releaseDelay.open() + if thawAfterMutation { await model.endGitOperationFreeze(waitForRefresh: false) } + try #require(await reloadEntered.waitUntilOpen()) + #expect(await firstRefresh.waitUntilOpen(), "Git must not wait for project services to finish") + #expect(!reloadFinished.isOpen) + watcher.emit([source.path]) + #expect(await secondRefresh.waitUntilOpen(), "A later file batch must also reach the open Commit panel") + releaseReload.open() + } + + @Test @MainActor + func cancelledGitReadRetainsRefreshRequestedWhileMutationsAreFrozen() async throws { + let workspace = URL(fileURLWithPath: "/in-memory/cancelled-git-read") + let factory = TestDirectoryWatcherFactory() + let entered = TestGate(), release = TestGate(), refreshed = TestGate() + let read = Task { await release.waitUntilOpen(timeout: .seconds(5)) } + var refreshCount = 0 + let model = makeWorkspaceObservationUnitModel( + provider: SequencedGitWatchContextProvider([nil]), watcherFactory: factory, + refreshGit: { + refreshCount += 1 + if refreshCount == 1 { entered.open(); #expect(await read.value) } + else { refreshed.open() } + }, observationDelay: { _ in }) + defer { release.open(); read.cancel(); model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + let watcher = try #require(factory.source) + watcher.emit(DirectoryChangeBatch(gitStateMayHaveChanged: true)) + try #require(await entered.waitUntilOpen()) + model.beginGitOperationFreeze() + await model.resumeObservationAfterActivation() + await model.endGitOperationFreeze(waitForRefresh: false) + #expect(refreshCount == 1) + release.open() + try #require(await refreshed.waitUntilOpen()) + #expect(refreshCount == 2) + _ = await read.value + } + + @Test @MainActor + func oldWorkspaceGitReadCannotReleaseTheNewWorkspaceRead() async throws { + let factory = TestDirectoryWatcherFactory() + let oldEntered = TestGate(), oldRelease = TestGate(), oldFinished = TestGate() + let newEntered = TestGate(), newRelease = TestGate(), newFinished = TestGate() + var refreshCount = 0 + let model = makeWorkspaceObservationUnitModel( + provider: SequencedGitWatchContextProvider([nil]), watcherFactory: factory, + refreshGit: { + refreshCount += 1 + if refreshCount == 1 { + oldEntered.open() + #expect(await oldRelease.waitUntilOpen()) + } else if refreshCount == 2 { + newEntered.open() + #expect(await newRelease.waitUntilOpen()) + } + }, observationDelay: { _ in }) + model.beginWorkspace(at: URL(fileURLWithPath: "/in-memory/old-git-workspace"), visibilityRules: .default) + let oldRead = Task { await model.resumeObservationAfterActivation(); oldFinished.open() } + var newRead: Task? + defer { oldRelease.open(); newRelease.open(); oldRead.cancel(); newRead?.cancel(); model.reset() } + try #require(await oldEntered.waitUntilOpen()) + model.beginWorkspace(at: URL(fileURLWithPath: "/in-memory/new-git-workspace"), visibilityRules: .default) + newRead = Task { await model.resumeObservationAfterActivation(); newFinished.open() } + try #require(await newEntered.waitUntilOpen()) + oldRelease.open() + try #require(await oldFinished.waitUntilOpen()) + await oldRead.value + await model.resumeObservationAfterActivation() + #expect(refreshCount == 2, "The active read still owns this workspace; the next request must coalesce") + newRelease.open() + try #require(await newFinished.waitUntilOpen()) + await newRead?.value + #expect(refreshCount == 3, "The coalesced request must run after the active read completes") + } + + @Test @MainActor + func thawGitReadCannotConsumeTheNextWorkspaceFileBatch() async throws { + let oldWorkspace = URL(fileURLWithPath: "/in-memory/old-thaw-workspace") + let newWorkspace = URL(fileURLWithPath: "/in-memory/new-thaw-workspace") + let file = newWorkspace.appendingPathComponent("Main.java") + let factory = TestDirectoryWatcherFactory() + let delayStarted = TestGate(), releaseDelay = TestGate() + let readStarted = TestGate(), releaseRead = TestGate(), thawFinished = TestGate(), projected = TestGate() + let read = Task { await releaseRead.waitUntilOpen(timeout: .seconds(5)) } + let model = makeWorkspaceObservationUnitModel( + fileOperations: ExistingWorkspaceFileOperations(paths: [file.path]), + provider: SequencedGitWatchContextProvider([nil]), watcherFactory: factory, + refreshGit: { readStarted.open(); #expect(await read.value) }, + processExternalChanges: { paths in if paths.contains(file) { projected.open() }; return false }, + observationDelay: { _ in + delayStarted.open() + guard await releaseDelay.waitUntilOpen() else { throw CancellationError() } + try Task.checkCancellation() + }) + model.beginWorkspace(at: oldWorkspace, visibilityRules: .default) + model.beginGitOperationFreeze() + await model.resumeObservationAfterActivation() + let thaw = Task { await model.endGitOperationFreeze(); thawFinished.open() } + defer { releaseDelay.open(); releaseRead.open(); read.cancel(); thaw.cancel(); model.reset() } + try #require(await readStarted.waitUntilOpen()) + model.beginWorkspace(at: newWorkspace, visibilityRules: .default) + let watcher = try #require(factory.source) + watcher.emit(DirectoryChangeBatch(workspacePaths: [file.path])) + try #require(await delayStarted.waitUntilOpen()) + releaseRead.open() + try #require(await thawFinished.waitUntilOpen()) + await thaw.value + releaseDelay.open() + try #require(await projected.waitUntilOpen(), "The old thaw must not drain the new workspace's paths") + _ = await read.value + } + @Test @MainActor func workspaceWatcherProjectsTypedJavaChangesWithoutReloadingForSourceEdits() async throws { @@ -4264,11 +4465,9 @@ struct EditorDocumentTests { fileOperations: ExistingWorkspaceFileOperations(paths: [source.path, build.path]), provider: SequencedGitWatchContextProvider([nil]), watcherFactory: watcherFactory, - refreshGit: { - if projectReloadCount > 0 { refreshFinished.open() } - }, + refreshGit: {}, notifyWorkspaceFileChanges: { projectedChanges.append(contentsOf: $0) }, - reloadProjectServices: { projectReloadCount += 1 }, + reloadProjectServices: { projectReloadCount += 1; refreshFinished.open() }, observationDelay: { duration in #expect(duration == .milliseconds(350)) delayStarted.open() diff --git a/macos/Tests/LitheTests/VcsProviderHostTests.swift b/macos/Tests/LitheTests/VcsProviderHostTests.swift index 82286279b7..e27f701de6 100644 --- a/macos/Tests/LitheTests/VcsProviderHostTests.swift +++ b/macos/Tests/LitheTests/VcsProviderHostTests.swift @@ -2,6 +2,7 @@ import Foundation import AppKit import SwiftUI import LitheCoreContracts +import LitheGitModule import LitheModuleAPI import Testing @testable import Lithe @@ -129,6 +130,143 @@ struct VcsProviderHostTests { // MARK: Feature model + @Test + func addAndDeleteDispatchExactPathsToTheOwningProviderAndRefreshStatus() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([ + ("z.txt", "unversioned"), ("a.txt", "unversioned"), ("tracked.txt", "modified") + ]) + try #require(await fixture.open()) + let changes = try #require(fixture.feature.root("")?.snapshot?.changes) + let added = changes.filter { $0.status == .unversioned } + let tracked = changes.filter { $0.status == .modified } + #expect(fixture.feature.canAdd(added, in: "") && !fixture.feature.canAdd(tracked, in: "")) + #expect(await fixture.feature.add(added + added, in: "")) + try #require(await fixture.settled()) + #expect(await fixture.feature.delete(tracked, in: "")) + try #require(await fixture.settled()) + #expect(fixture.providers.requests.filter { $0.operation.name == "fileOperation" } == [ + .init(operation: .add(paths: ["a.txt", "z.txt"]), root: fixture.workspaceURL.path), + .init(operation: .delete(paths: ["tracked.txt"]), root: fixture.workspaceURL.path) + ]) + #expect(fixture.providers.statusCount(root: fixture.workspaceURL.path) == 3) + fixture.providers.failures["fileOperation"] = VcsProviderFailure(code: "vcs.operation.failed", message: "Provider refused deletion") + #expect(await !fixture.feature.delete(tracked, in: "")) + #expect(fixture.feature.failure?.message == "Provider refused deletion") + #expect(await fixture.settled()) + } + + @Test(arguments: [false, true]) + func fileActionsRefuseNestedOrAmbiguousRootsAndMissingProviderCapability(ambiguous: Bool) async throws { + let fixture = try VcsFeatureFixture(roots: ambiguous ? [("", "svn")] : [("", "svn"), ("folder/nested", "git")], + ambiguous: ambiguous ? ["folder/nested"] : []) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([ + ("folder", "unversioned"), ("folder/nested/file.txt", "unversioned"), ("tracked", "modified") + ]) + try #require(await fixture.open()) + let changes = try #require(fixture.feature.root("")?.snapshot?.changes) + let directory = changes.filter { $0.path == "folder" } + let nested = changes.filter { $0.path.hasSuffix("file.txt") } + #expect(!fixture.feature.canAdd(directory, in: "") && !fixture.feature.canTrash(directory, in: "")) + #expect(await !fixture.feature.add(directory, in: "")) + #expect(await !fixture.feature.add(nested, in: "")) + fixture.providers.capabilities.removeValue(forKey: VcsCapability.fileOperations.rawValue) + fixture.feature.refresh("") + try #require(await fixture.settled()) + #expect(!fixture.feature.canDelete(changes.filter { $0.path == "tracked" }, in: "")) + #expect(fixture.providers.requests.allSatisfy { $0.operation.name != "fileOperation" }) + } + + /// Native Trash removes the whole directory, so a working copy created + /// inside it after the last detection must stop the deletion. + @Test(arguments: [false, true]) + func trashDetectsRootsAgainBeforeRemovingADirectory(nestedRootAppeared: Bool) async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("folder", "unversioned")]) + try #require(await fixture.open()) + let directory = try #require(fixture.feature.root("")?.snapshot?.changes) + try #require(fixture.feature.canTrash(directory, in: "")) + if nestedRootAppeared { + fixture.planner.detection = try VcsTestData.detection( + roots: [("", "svn"), ("folder/nested", "git")], workspaceURL: fixture.workspaceURL) + } + let detections = fixture.planner.detectCount + var trashed = 0 + let result = await fixture.feature.trash(directory, in: "") { trashed += 1; return true } + #expect(result == !nestedRootAppeared && trashed == (nestedRootAppeared ? 0 : 1)) + #expect(fixture.planner.detectCount > detections) + #expect((fixture.feature.failure != nil) == nestedRootAppeared) + try #require(await fixture.settled()) + #expect(fixture.feature.canTrash(directory, in: "") == !nestedRootAppeared) + } + + @Test + func fileActionsRejectLateOwnershipAndSerializeMutations() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("new.txt", "unversioned")]) + try #require(await fixture.open()) + let changes = try #require(fixture.feature.root("")?.snapshot?.changes) + let entered = TestGate(), released = TestGate(), completed = TestGate() + fixture.planner.heldOwners = (["new.txt"], entered, released) + var result = true + let pending = Task { result = await fixture.feature.add(changes, in: ""); completed.open() } + defer { released.open(); pending.cancel() } + try #require(await entered.waitUntilOpen()) + fixture.feature.reset(workspaceURL: nil) + released.open() + try #require(await completed.waitUntilOpen()) + await pending.value + #expect(!result && !fixture.planner.ownersTimedOut) + #expect(fixture.providers.requests.allSatisfy { $0.operation.name != "fileOperation" }) + + fixture.planner.heldOwners = nil + try #require(await fixture.open()) + let releaseRequest = TestGate(), finished = TestGate() + fixture.providers.holds["fileOperation"] = releaseRequest + let running = Task { _ = await fixture.feature.add(changes, in: ""); finished.open() } + defer { releaseRequest.open(); running.cancel() } + try #require(await awaitChange(on: fixture.feature) { fixture.feature.runningOperation != nil }) + #expect(!fixture.feature.canAdd(changes, in: "")) + #expect(await !fixture.feature.add(changes, in: "")) + #expect(await !fixture.feature.delete(changes, in: "")) + releaseRequest.open() + try #require(await finished.waitUntilOpen()) + await running.value + #expect(await fixture.settled()) + #expect(fixture.providers.requests.filter { $0.operation.name == "fileOperation" }.count == 1) + } + + @Test + func commitContextMenuTargetsClickedSelectionAndInvokesProviderAdd() async throws { + let fixture = try VcsFeatureFixture(roots: [("", "svn")]) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("first.txt", "unversioned"), ("second.txt", "unversioned")]) + try #require(await fixture.open()) + let root = try #require(fixture.feature.root("")), changes = try #require(root.snapshot?.changes) + #expect(VcsProviderToolWindowView.actionTargets(changes, selection: ["first.txt", "second.txt"], clicked: changes[0]) == changes) + #expect(VcsProviderToolWindowView.actionTargets(changes, selection: ["second.txt"], clicked: changes[0]) == [changes[0]]) + var compared: String?, deleted: [VcsChange] = [] + let view = VcsProviderToolWindowView(feature: fixture.feature, title: "Subversion", pluginID: VcsTestData.pluginID, vcs: "svn", + actions: .init(openFile: { _ in }, revealInFinder: { _ in }, copyPath: { _ in }, hide: {}, + showDiffInNewTab: { compared = $0.path; #expect($1 == "") }, deleteFiles: { deleted = $0; #expect($1 == "") })) + let menu = view.contextMenu(for: changes[0], root: root) + for title in ["Commit Files…", "Show Diff in New Tab", "Delete…", "Add to VCS"] { + #expect(menu.first { $0.title == title }?.isEnabled == true) + } + let diff = try #require(menu.first { $0.title == "Show Diff in New Tab" }) + diff.action() + let delete = try #require(menu.first { $0.title == "Delete…" }) + delete.action() + #expect(compared == changes[0].path && deleted == [changes[0]]) + let add = try #require(menu.first { $0.title == "Add to VCS" }) + add.action() + #expect(await fixture.settled { fixture.providers.requests.contains { $0.operation == .add(paths: [changes[0].path]) } }) + } + @Test func detectsPluginRootsReadsStatusAndColorsOnlyTheFilesTheRootOwns() async throws { // A Git repository nested in the working copy takes part in @@ -595,6 +733,62 @@ struct VcsProviderHostTests { @Suite("VCS providers in the app model", .serialized) @MainActor struct VcsProviderAppModelTests { + @Test(arguments: ["finish", "close", "workspace-close"]) + func providerDiffTabsOwnTheirPendingRequestsWithoutStealingFocus(lifecycle: String) async throws { + let root = try Self.makeWorkspace() + defer { try? FileManager.default.removeItem(at: root) } + let file = root.appendingPathComponent("a.txt").standardizedFileURL + try Data("working content\n".utf8).write(to: file) + let fixture = try VcsFeatureFixture(workspaceURL: root, roots: [("", "svn")], + readFile: { try? Data(contentsOf: $0) }) + defer { fixture.close() } + fixture.providers.replies["status"] = try VcsTestData.statusReply([("a.txt", "modified")]) + fixture.providers.replies["baseContent"] = try VcsTestData.json(["content": "base content\n", "binary": false]) + let model = Self.makeModel(fixture) + model.openProjectDirectly(root) + defer { model.workspaceFeature.reset(); model.closeAllRepositoryDiffTabs() } + do { + try #require(await awaitChange(on: model) { model.projectFiles.contains(file) }) + model.loadVcsProviders(at: root) + try #require(await fixture.settled()) + let change = try #require(fixture.feature.root("")?.snapshot?.changes.first) + let release = TestGate(), entered = TestGate() + fixture.providers.holds["baseContent"] = release + fixture.providers.requestEntered = { if $0.name == "baseContent" { entered.open() } } + defer { release.open() } + model.showVcsDiffInNewTab(change, rootID: "") + let id = try #require(model.editorTabOrderFeature.repositoryDiffIDs.last) + let task = try #require(model.editorTabOrderFeature.providerDiffTasks[id]) + defer { task.cancel() } + try #require(await entered.waitUntilOpen()) + #expect(model.editorTabOrderFeature.loadingProviderDiffs == [id]) + model.documentFeature.openVirtualDocument(URL(string: "lithe-test://documents/Return.txt")!, + text: "unsaved content", displayPath: nil) + let document = try #require(model.activeDocument) + model.selectEditorDocument(document) + if lifecycle == "close" { model.closeRepositoryDiffTab(id) } + if lifecycle == "workspace-close" { model.closeProject() } + if lifecycle != "finish" { #expect(task.isCancelled) } + release.open() + await task.value + let tabs = model.editorTabOrderFeature + #expect(tabs.providerDiffTasks.isEmpty && tabs.loadingProviderDiffs.isEmpty) + if lifecycle == "finish" { + #expect(tabs.repositoryDiffIDs == [id] && tabs.providerDiffs[id]?.base == "base content\n") + #expect(tabs.providerDiffs[id]?.current == "working content\n") + #expect(model.activeDocument === document && !model.isRepositoryDiffSelected) + model.selectRepositoryDiffTab(id) + #expect(model.selectedRepositoryDiffID == id) + model.closeRepositoryDiffTab(id) + #expect(model.activeDocument === document && document.text == "unsaved content") + } else { + #expect(tabs.repositoryDiffIDs.isEmpty && tabs.providerDiffs.isEmpty) + #expect(fixture.feature.failure == nil) + } + } catch { await model.shutdownProjectSession(); throw error } + await model.shutdownProjectSession() + } + /// A tracked file goes to the provider, an unversioned one stays a plain /// disk move. @Test @@ -674,6 +868,64 @@ struct VcsProviderAppModelTests { #expect(!feature.isDetecting) } + /// A Git deletion of an untracked directory keeps out of every provider + /// root: an ambiguous directory already known, and one that only a fresh + /// detection finds after the confirmation opened. + @Test(arguments: ["ambiguous", "created-during-confirmation"]) + func gitDirectoryDeletionKeepsOutOfProviderRoots(nested: String) async throws { + let root = try Self.makeWorkspace() + defer { try? FileManager.default.removeItem(at: root) } + try await Self.git(["init", "-q"], at: root) + try Data("tracked\n".utf8).write(to: root.appendingPathComponent("tracked.txt")) + try await Self.git(["add", "tracked.txt"], at: root) + try await Self.git(["commit", "-q", "-m", "initial"], at: root) + let folder = root.appendingPathComponent("folder", isDirectory: true).standardizedFileURL + try FileManager.default.createDirectory(at: folder.appendingPathComponent("nested"), withIntermediateDirectories: true) + try Data("content\n".utf8).write(to: folder.appendingPathComponent("nested/file.txt")) + // Enough files that Git reports the directory as one summary row. + for number in 1...101 { + try Data("ordinary content\n".utf8).write(to: folder.appendingPathComponent("file-\(number).txt")) + } + + let fixture = try VcsFeatureFixture(workspaceURL: root, roots: [("", "git")], + ambiguous: nested == "ambiguous" ? ["folder/nested"] : []) + defer { fixture.close() } + let model = Self.makeModel(fixture) + defer { model.commitFileDeletion.reset() } + let git = GitFeatureModel(service: GitService(operations: RustGitOperations(core: RustCoreBridge()))) + git.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, notify: { _ in }, onStateRefreshed: {}) + defer { git.reset() } + await git.refreshGit() + let directory = try #require(git.gitChanges.first { $0.path == "folder/" }) + fixture.feature.reset(workspaceURL: root) + try #require(await awaitChange(on: fixture.feature) { + !fixture.feature.isDetecting && !fixture.feature.knownRootURLs.isEmpty + }) + + model.requestDeleteCommitFiles([directory], feature: git) + if nested == "ambiguous" { + #expect(model.commitFileDeletion.pending == nil) + return + } + let request = try #require(model.commitFileDeletion.pending) + // A checkout appears inside the directory before the user confirms. + fixture.planner.detection = try VcsTestData.detection( + roots: [("", "git"), ("folder/nested", "svn")], workspaceURL: root) + let detections = fixture.planner.detectCount + await model.commitFileDeletion.confirm(request) + #expect(fixture.planner.detectCount > detections) + #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("nested/file.txt").path)) + } + + private static func git(_ arguments: [String], at directory: URL) async throws { + let result = try await TestProcess.run( + executableURL: URL(fileURLWithPath: "/usr/bin/git"), + arguments: ["-c", "user.email=tests@lithe.local", "-c", "user.name=Lithe Tests"] + arguments, + currentDirectoryURL: directory + ) + try #require(result.terminationStatus == 0, "\(String(decoding: result.output, as: UTF8.self))") + } + private static func makeWorkspace() throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("lithe-vcs-model-\(UUID().uuidString)", isDirectory: true) @@ -828,10 +1080,12 @@ private final class FakeVcsProviders: VcsProviderRequesting { } var replies: [String: ToolingJSONValue] = [:] + var capabilities = Dictionary(uniqueKeysWithValues: VcsCapability.allCases.map { ($0.rawValue, 1) }) var failures: [String: VcsProviderFailure] = [:] /// Requests that wait for the gate before answering. var holds: [String: TestGate] = [:] var onFileOperation: ((VcsOperation, String) throws -> Void)? + var requestEntered: ((VcsOperation) -> Void)? var historyReply: ((VcsOperation) async throws -> ToolingJSONValue)? private(set) var requests: [Request] = [] private(set) var retainCount = 0 @@ -843,7 +1097,7 @@ private final class FakeVcsProviders: VcsProviderRequesting { } func negotiation(for target: VcsProviderTarget) async throws -> VcsProviderNegotiation { - Self.negotiation(target.vcs) + negotiation(target.vcs) } func send( @@ -854,15 +1108,16 @@ private final class FakeVcsProviders: VcsProviderRequesting { ) async throws -> VcsProviderReply { requests.append(Request(operation: operation, root: root)) running.insert(target) + requestEntered?(operation) 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) } if ["history", "fileHistory"].contains(operation.name), let historyReply { - return VcsProviderReply(value: try await historyReply(operation), negotiation: Self.negotiation(target.vcs)) + return VcsProviderReply(value: try await historyReply(operation), negotiation: negotiation(target.vcs)) } - return VcsProviderReply(value: replies[operation.name] ?? .object([:]), negotiation: Self.negotiation(target.vcs)) + return VcsProviderReply(value: replies[operation.name] ?? .object([:]), negotiation: negotiation(target.vcs)) } func retainProviders(_ keep: (VcsProviderTarget) -> Bool) { @@ -870,10 +1125,8 @@ private final class FakeVcsProviders: VcsProviderRequesting { running = running.filter(keep) } - private static func negotiation(_ vcs: String) -> VcsProviderNegotiation { - VcsProviderNegotiation( - vcs: vcs, capabilities: Dictionary(uniqueKeysWithValues: VcsCapability.allCases.map { ($0.rawValue, 1) }) - ) + private func negotiation(_ vcs: String) -> VcsProviderNegotiation { + VcsProviderNegotiation(vcs: vcs, capabilities: capabilities) } } @@ -937,7 +1190,8 @@ private final class VcsFeatureFixture { init( workspaceURL: URL = URL(fileURLWithPath: "/virtual/workspace", isDirectory: true), roots: [(id: String, vcs: String)], - ambiguous: [String] = [] + ambiguous: [String] = [], + readFile: @escaping @MainActor (URL) -> Data? = { _ in nil } ) throws { self.workspaceURL = workspaceURL.standardizedFileURL planner = FakeVcsPlanner(detection: try VcsTestData.detection( @@ -949,6 +1203,7 @@ private final class VcsFeatureFixture { planner: planner, providers: providers, mappingStore: MacVcsRootMappingStore(store: keyValues), + readFile: readFile, fileExists: { files.exists($0) }, sleep: { _ in try Task.checkCancellation() }, now: { clock.now }, diff --git a/macos/Tests/LitheTests/WorkbenchMavenLayoutTests.swift b/macos/Tests/LitheTests/WorkbenchMavenLayoutTests.swift index 9f863cb234..d368a9e99e 100644 --- a/macos/Tests/LitheTests/WorkbenchMavenLayoutTests.swift +++ b/macos/Tests/LitheTests/WorkbenchMavenLayoutTests.swift @@ -6,6 +6,17 @@ import Testing @Suite("Workbench Maven layout") @MainActor struct WorkbenchMavenLayoutTests { + @Test func initialLayoutWidensTheSidebarWithoutOverwritingSavedDimensions() { + let store = WorkbenchLayoutStore(store: MavenLayoutTestStore()) + let workspace = URL(fileURLWithPath: "/fixture/default-layout") + #expect(store.load(for: workspace).sidebarWidth == 360) + #expect(store.load(for: workspace).topPaneHeight == nil) + #expect(WorkbenchLayout.defaultTopPaneFraction == 0.72) + store.save(WorkbenchLayout(sidebarWidth: 320, topPaneHeight: 410), for: workspace) + #expect(store.load(for: workspace).sidebarWidth == 320) + #expect(store.load(for: workspace).topPaneHeight == 410) + } + @Test func legacyLayoutStillDecodes() throws { let data = Data(#"{"sidebarWidth":320,"topPaneHeight":280}"#.utf8) let layout = try JSONDecoder().decode(WorkbenchLayout.self, from: data) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 5d837c726b..33f98b7f30 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -5655,28 +5655,46 @@ fn failed_git_result(error: CoreError) -> GitCommandResponse { fn discard_all(root: &str, paths: &[String]) -> Result { let mut tracked = Vec::new(); let mut untracked = Vec::new(); - let status_arguments = vec![ - "status".to_string(), - "--porcelain=v1".to_string(), - "-z".to_string(), - "--untracked-files=all".to_string(), - ]; - let status = execute_git(root, &status_arguments, None)?; - if status.exit_code != 0 { - return Ok(status); + let mut tracked_paths = HashSet::new(); + let mut untracked_paths = HashSet::new(); + let literal_paths = [("GIT_LITERAL_PATHSPECS".to_string(), "1".to_string())]; + let mut final_response = successful_git_result(); + // Rollback is often called once per selected file. Never enumerate unrelated + // untracked trees for every file, and keep path arguments below Windows limits. + for batch in pathspec_batches(paths, LS_FILES_PATHSPEC_BUDGET) { + let mut arguments = vec![ + "status".to_string(), + "--porcelain=v2".to_string(), + "-z".to_string(), + "--untracked-files=all".to_string(), + "--".to_string(), + ]; + arguments.extend_from_slice(batch); + let status = execute_git_with_environment(root, &arguments, None, true, &literal_paths)?; + if status.exit_code != 0 { + return Ok(status); + } + // The shared parser consumes rename source records too; a source name + // beginning with `?? ` must not be mistaken for an untracked path. + for change in parse_status(status.stdout.as_bytes(), true) { + if change.untracked { + untracked_paths.insert(change.path); + } else { + tracked_paths.insert(change.path); + } + } + final_response = status; } - let untracked_paths = status - .output - .split('\0') - .filter_map(|record| record.strip_prefix("?? ")) - .collect::>(); for path in paths { // A summarized untracked directory (`dir/`) is reported file by file. - let is_untracked = untracked_paths.contains(path.as_str()) - || (path.ends_with('/') - && untracked_paths - .iter() - .any(|untracked| untracked.starts_with(path.as_str()))); + // A staged deletion can coexist with an untracked disk copy at the + // same path. Restore HEAD rather than cleaning that retained copy. + let is_untracked = !tracked_paths.contains(path.as_str()) + && (untracked_paths.contains(path.as_str()) + || (path.ends_with('/') + && untracked_paths + .iter() + .any(|untracked| untracked.starts_with(path.as_str())))); if is_untracked { untracked.push(path.clone()); } else { @@ -5684,7 +5702,6 @@ fn discard_all(root: &str, paths: &[String]) -> Result Result(); - let restored = execute_git(root, &arguments, Some(pathspec_input))?; + let restored = execute_git_with_environment( + root, + &arguments, + Some(pathspec_input), + false, + &literal_paths, + )?; if restored.exit_code != 0 { return Ok(restored); } final_response = restored; } - if !untracked.is_empty() { + for batch in pathspec_batches(&untracked, LS_FILES_PATHSPEC_BUDGET) { let mut arguments = vec![ "clean".to_string(), "-f".to_string(), "-d".to_string(), "--".to_string(), ]; - arguments.extend(untracked); - return execute_git(root, &arguments, None); + arguments.extend_from_slice(batch); + let cleaned = execute_git_with_environment(root, &arguments, None, false, &literal_paths)?; + if cleaned.exit_code != 0 { + return Ok(cleaned); + } + final_response = cleaned; } Ok(final_response) } diff --git a/rust/lithe-core/src/tests/git_untracked_directories.rs b/rust/lithe-core/src/tests/git_untracked_directories.rs index f253d8b3cf..7e9add460b 100644 --- a/rust/lithe-core/src/tests/git_untracked_directories.rs +++ b/rust/lithe-core/src/tests/git_untracked_directories.rs @@ -266,3 +266,200 @@ fn summarized_directory_diff_fails_instead_of_dropping_a_file_it_cannot_export() assert_eq!(response["ok"], false, "{response}"); assert!(repository.0.join("generated/bad:name.txt").exists()); } + +#[test] +fn rollback_status_scans_only_selected_paths_and_restores_both_versions() { + let repository = Repository::new("rollback-scoped-status"); + repository.write_many("unrelated", 201); + repository.write("tracked/kept.txt", "staged\n"); + repository.git(&["add", "tracked/kept.txt"]); + repository.write("tracked/kept.txt", "working\n"); + let result = data( + "git.write", + json!({"root": repository.0, "operation": "discardAll", "paths": ["tracked/kept.txt"]}), + ); + assert_eq!(result["exitCode"], 0, "{result}"); + let status = &result["invocations"][0]; + assert_eq!( + status["arguments"], + json!([ + "status", + "--porcelain=v2", + "-z", + "--untracked-files=all", + "--", + "tracked/kept.txt" + ]) + ); + assert!(!status["stdout"].as_str().unwrap().contains("unrelated")); + assert_eq!( + fs::read_to_string(repository.0.join("tracked/kept.txt")).unwrap(), + "kept\n" + ); + let index = data( + "git.command", + json!({"root": repository.0, "arguments": ["show", ":tracked/kept.txt"]}), + ); + assert_eq!(index["stdout"], "kept\n"); + assert_eq!( + fs::read_dir(repository.0.join("unrelated/deps")) + .unwrap() + .count(), + 201 + ); +} + +#[test] +fn rollback_restores_staged_deletion_with_a_retained_untracked_copy() { + let repository = Repository::new("rollback-retained-deletion"); + repository.git(&["rm", "--cached", "tracked/kept.txt"]); + repository.write("tracked/kept.txt", "retained edit\n"); + let before = status(&repository.0, true); + assert_eq!(before["changes"].as_array().unwrap().len(), 2); + let result = data( + "git.write", + json!({"root": repository.0, "operation": "discardAll", "paths": ["tracked/kept.txt"]}), + ); + assert_eq!(result["exitCode"], 0, "{result}"); + assert_eq!( + fs::read_to_string(repository.0.join("tracked/kept.txt")).unwrap(), + "kept\n" + ); + assert!(status(&repository.0, true)["changes"] + .as_array() + .unwrap() + .is_empty()); + assert!(!result["invocations"] + .as_array() + .unwrap() + .iter() + .any(|call| call["arguments"][0] == "clean")); +} + +#[test] +fn rollback_batches_large_selections_without_cleaning_unselected_files() { + let repository = Repository::new("rollback-path-batches"); + let paths: Vec<_> = (0..200) + .map(|index| format!("{}-{index:03}.txt", "selected".repeat(13))) + .collect(); + for path in &paths { + repository.write(path, "selected\n"); + } + repository.write("unselected.txt", "keep\n"); + let result = data( + "git.write", + json!({"root": repository.0, "operation": "discardAll", "paths": paths}), + ); + assert_eq!(result["exitCode"], 0, "{result}"); + let calls = result["invocations"].as_array().unwrap(); + for command in ["status", "clean"] { + let batches: Vec<_> = calls + .iter() + .filter(|call| call["arguments"][0] == command) + .collect(); + assert!( + batches.len() > 1, + "{command} must have bounded path batches" + ); + let selected: Vec<_> = batches + .iter() + .flat_map(|call| { + let arguments = call["arguments"].as_array().unwrap(); + let start = arguments.iter().position(|value| value == "--").unwrap() + 1; + arguments[start..] + .iter() + .map(|value| value.as_str().unwrap()) + }) + .collect(); + assert_eq!( + selected, + paths.iter().map(String::as_str).collect::>() + ); + } + assert!(paths.iter().all(|path| !repository.0.join(path).exists())); + assert_eq!( + fs::read_to_string(repository.0.join("unselected.txt")).unwrap(), + "keep\n" + ); +} + +#[test] +fn rollback_treats_bracketed_paths_literally() { + let repository = Repository::new("rollback-literal-paths"); + for path in ["literal[ab].txt", "literala.txt"] { + repository.write(path, "base\n"); + } + repository.git(&["add", "."]); + repository.git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "literal names", + ]); + for path in ["literal[ab].txt", "literala.txt"] { + repository.write(path, "edit\n"); + } + repository.write("new[ab].txt", "remove\n"); + repository.write("newa.txt", "keep\n"); + let result = data( + "git.write", + json!({"root": repository.0, "operation": "discardAll", "paths": ["literal[ab].txt", "new[ab].txt"]}), + ); + assert_eq!(result["exitCode"], 0, "{result}"); + assert_eq!( + fs::read_to_string(repository.0.join("literal[ab].txt")).unwrap(), + "base\n" + ); + assert_eq!( + fs::read_to_string(repository.0.join("literala.txt")).unwrap(), + "edit\n" + ); + assert!(!repository.0.join("new[ab].txt").exists()); + assert_eq!( + fs::read_to_string(repository.0.join("newa.txt")).unwrap(), + "keep\n" + ); +} + +// Question marks are valid Unix file names but are rejected by Windows. +#[cfg(not(windows))] +#[test] +fn rollback_does_not_parse_a_rename_source_as_an_untracked_status_record() { + let repository = Repository::new("rollback-rename-record"); + for path in ["?? decoy.txt", "decoy.txt"] { + repository.write(path, "base\n"); + } + repository.git(&["add", "."]); + repository.git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "rename source", + ]); + repository.git(&["mv", "?? decoy.txt", "renamed.txt"]); + repository.write("decoy.txt", "independent edit\n"); + let result = data( + "git.write", + json!({"root": repository.0, "operation": "discardAll", "paths": ["renamed.txt", "?? decoy.txt", "decoy.txt"]}), + ); + assert_eq!(result["exitCode"], 0, "{result}"); + assert_eq!( + fs::read_to_string(repository.0.join("?? decoy.txt")).unwrap(), + "base\n" + ); + assert_eq!( + fs::read_to_string(repository.0.join("decoy.txt")).unwrap(), + "base\n" + ); + assert!(!repository.0.join("renamed.txt").exists()); +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 3ad9acb254..22e77a8c10 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -6039,7 +6039,6 @@ "native.showLocalHistory": "Show Local History…", "native.showLongGraphEdges": "Show long graph edges", "native.showMinimap": "Show minimap", - "native.javaKeywordNavigationFilter": "Skip definition lookups for Java keywords", "native.showOrHideGitHistory": "Show or hide Git history", "native.showOrHideJavaDiagnostics": "Show or hide Java diagnostics", "native.showOrHideLanguageDiagnostics": "Show or hide language diagnostics", @@ -9517,6 +9516,26 @@ "document.fileAccess.thisFileTypeCannotBeOpenedInThisEditor": "This file type cannot be opened in this editor", "document.fileAccess.updateAllHardLinks": "Update All Hard Links", "document.fileAccess.savePermissionDenied": "Could not save {arg1}: permission denied. Check the file and parent folder permissions. Your edits are still unsaved.", + "git.rollback.title": "Rollback Changes", + "git.rollback.confirm": "Rollback", + "git.rollback.include": "Include in rollback", + "git.rollback.deleteAdded": "Delete local copies of added files", + "git.rollback.warning": "Staged and unstaged changes in the checked files will be rolled back. This cannot be undone by Lithe.", + "git.rollback.stale": "The selected changes changed. Reopen the rollback dialog and review them again.", + "git.rollback.success": "Rolled back {arg1}", + "vcs.commitFiles.commitFiles": "Commit Files…", + "vcs.commitFiles.diffNewTab": "Show Diff in New Tab", + "vcs.commitFiles.deleteAction": "Delete…", + "vcs.commitFiles.addToVcs": "Add to VCS", + "vcs.commitFiles.adding": "Adding to VCS…", + "vcs.commitFiles.deleting": "Deleting…", + "vcs.commitFiles.deleteTitle": "Delete Files", + "vcs.commitFiles.trashWarning": "These files will be moved to the macOS Trash.", + "vcs.commitFiles.vcsDeleteWarning": "The version control provider will delete these files and record their removal. They may not be moved to Trash.", + "vcs.commitFiles.ownershipChanged": "Some selected files belong to another version control root. Refresh and select them again.", + "vcs.commitFiles.stagingChanged": "The staged files changed. Review the checked files before committing.", + "git.status.refreshFailed": "Could not refresh Git status. Refresh to verify the changes.", + "native.javaKeywordNavigationFilter": "Skip definition lookups for Java keywords", "native.editorAppearanceAbsolute": "Absolute", "native.editorAppearanceAppliesImmediatelyToSourceEditorsInAllProjects": "Applies immediately to source editors in all projects.", "native.editorAppearanceBlink": "Blink", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 85971480f5..a0c4b04e87 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -6039,7 +6039,6 @@ "native.showLocalHistory": "查看本地历史…", "native.showLongGraphEdges": "显示完整长连线", "native.showMinimap": "显示代码缩略图", - "native.javaKeywordNavigationFilter": "Java 关键字不查询定义", "native.showOrHideGitHistory": "显示或隐藏 Git 历史", "native.showOrHideJavaDiagnostics": "显示或隐藏 Java 诊断", "native.showOrHideLanguageDiagnostics": "显示或隐藏语言诊断", @@ -9517,6 +9516,26 @@ "document.fileAccess.thisFileTypeCannotBeOpenedInThisEditor": "此编辑器不支持打开这种文件类型", "document.fileAccess.updateAllHardLinks": "更新所有硬链接", "document.fileAccess.savePermissionDenied": "无法保存 {arg1}:权限不足。请检查文件及其父文件夹的权限,编辑内容尚未保存。", + "git.rollback.title": "回滚更改", + "git.rollback.confirm": "回滚", + "git.rollback.include": "纳入回滚", + "git.rollback.deleteAdded": "删除新增文件的本地副本", + "git.rollback.warning": "将回滚勾选文件中已暂存和未暂存的更改。Lithe 无法撤销此操作。", + "git.rollback.stale": "所选更改已发生变化。请重新打开回滚弹窗并再次检查。", + "git.rollback.success": "已回滚 {arg1}", + "vcs.commitFiles.commitFiles": "提交文件…", + "vcs.commitFiles.diffNewTab": "在新标签页中显示差异", + "vcs.commitFiles.deleteAction": "删除…", + "vcs.commitFiles.addToVcs": "添加到 VCS", + "vcs.commitFiles.adding": "正在添加到 VCS…", + "vcs.commitFiles.deleting": "正在删除…", + "vcs.commitFiles.deleteTitle": "删除文件", + "vcs.commitFiles.trashWarning": "这些文件将移到 macOS 废纸篓。", + "vcs.commitFiles.vcsDeleteWarning": "版本控制插件将删除这些文件并记录删除操作,文件可能不会移入废纸篓。", + "vcs.commitFiles.ownershipChanged": "部分所选文件属于其他版本控制根目录。请刷新后重新选择。", + "vcs.commitFiles.stagingChanged": "已暂存的文件发生了变化。请在提交前检查勾选的文件。", + "git.status.refreshFailed": "无法刷新 Git 状态。请刷新以确认更改结果。", + "native.javaKeywordNavigationFilter": "Java 关键字不查询定义", "native.editorAppearanceAbsolute": "绝对行号", "native.editorAppearanceAppliesImmediatelyToSourceEditorsInAllProjects": "立即应用于所有项目的源码编辑器。", "native.editorAppearanceBlink": "闪烁", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 781867a89d..46635766c1 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -56810,17 +56810,6 @@ "arguments": [] } }, - { - "id": "native.javaKeywordNavigationFilter", - "description": "Native UI: Skip definition lookups for Java keywords", - "platforms": [ - "macos" - ], - "macos": { - "key": "Skip definition lookups for Java keywords", - "arguments": [] - } - }, { "id": "native.showMinimap", "description": "Native UI: Show minimap", @@ -90809,6 +90798,231 @@ ] } }, + { + "id": "git.rollback.title", + "description": "macOS reviewed Git rollback dialog and operation feedback.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rollback Changes", + "arguments": [] + } + }, + { + "id": "git.rollback.confirm", + "description": "macOS reviewed Git rollback dialog and operation feedback.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Rollback", + "arguments": [] + } + }, + { + "id": "git.rollback.include", + "description": "macOS reviewed Git rollback dialog and operation feedback.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Include in rollback", + "arguments": [] + } + }, + { + "id": "git.rollback.deleteAdded", + "description": "macOS reviewed Git rollback dialog and operation feedback.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete local copies of added files", + "arguments": [] + } + }, + { + "id": "git.rollback.warning", + "description": "macOS reviewed Git rollback dialog and operation feedback.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Staged and unstaged changes in the checked files will be rolled back. This cannot be undone by Lithe.", + "arguments": [] + } + }, + { + "id": "git.rollback.stale", + "description": "macOS reviewed Git rollback dialog and operation feedback.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected changes changed. Reopen the rollback dialog and review them again.", + "arguments": [] + } + }, + { + "id": "git.rollback.success", + "description": "macOS reviewed Git rollback dialog and operation feedback.", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + }, + "macos": { + "key": "Rolled back %@", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "vcs.commitFiles.commitFiles", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Commit Files…", + "arguments": [] + } + }, + { + "id": "vcs.commitFiles.diffNewTab", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Diff in New Tab", + "arguments": [] + } + }, + { + "id": "vcs.commitFiles.deleteAction", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete…", + "arguments": [] + } + }, + { + "id": "vcs.commitFiles.addToVcs", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add to VCS", + "arguments": [] + } + }, + { + "id": "vcs.commitFiles.adding", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Adding to VCS…", + "arguments": [] + } + }, + { + "id": "vcs.commitFiles.deleting", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Deleting…", + "arguments": [] + } + }, + { + "id": "vcs.commitFiles.deleteTitle", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Delete Files", + "arguments": [] + } + }, + { + "id": "vcs.commitFiles.trashWarning", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "These files will be moved to the macOS Trash.", + "arguments": [] + } + }, + { + "id": "vcs.commitFiles.vcsDeleteWarning", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The version control provider will delete these files and record their removal. They may not be moved to Trash.", + "arguments": [] + } + }, + { + "id": "vcs.commitFiles.ownershipChanged", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Some selected files belong to another version control root. Refresh and select them again.", + "arguments": [] + } + }, + { + "id": "git.status.refreshFailed", + "description": "A local Git mutation could not confirm the updated status; keep the previous rows until retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not refresh Git status. Refresh to verify the changes.", + "arguments": [] + } + }, + { + "id": "vcs.commitFiles.stagingChanged", + "description": "macOS Commit file context actions, confirmations and validation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The staged files changed. Review the checked files before committing.", + "arguments": [] + } + }, + { + "id": "native.javaKeywordNavigationFilter", + "description": "Native UI: Skip definition lookups for Java keywords", + "platforms": [ + "macos" + ], + "macos": { + "key": "Skip definition lookups for Java keywords", + "arguments": [] + } + }, { "id": "native.editorAppearanceAbsolute", "description": "Source editor appearance: Absolute", diff --git a/shared/platform-feature-matrix/features/git-diff-review.json b/shared/platform-feature-matrix/features/git-diff-review.json index 0c92d40eaf..6da5df23b3 100644 --- a/shared/platform-feature-matrix/features/git-diff-review.json +++ b/shared/platform-feature-matrix/features/git-diff-review.json @@ -21,11 +21,15 @@ "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", ".agents/notes/implemented/architecture/2026-10-12-editor-tab-split-docking.md", "macos/Sources/LitheGitModule/Models/GitRepositoryDiffSession.swift", - "macos/Sources/Lithe/Views/Diff/RepositoryDiffSessionView.swift" + "macos/Sources/Lithe/Views/Diff/RepositoryDiffSessionView.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+VcsProviders.swift", + "macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift", + "macos/Tests/LitheTests/VcsProviderHostTests.swift", + ".agents/notes/implemented/feature/2026-10-29-commit-file-context-actions.md" ], "implementationStatus": "implemented", "verificationStatus": "pending", - "notes": "工作树更改详情可打开选中checkout的工作区Diff,不隐式切换活动仓库;已有整文件与块级暂存/取消暂存/回滚后刷新对应inspection,保留已加载历史与cursor,Diff文件仍有变更时更新、变干净后关闭。正常主仓库刷新优先从对应工作树inspection解析所选文件,不因主仓库同名文件缺失而清空linked-worktree Diff。" + "notes": "工作树更改详情可打开选中checkout的工作区Diff,不隐式切换活动仓库;已有整文件与块级暂存/取消暂存/回滚后刷新对应inspection,保留已加载历史与cursor,Diff文件仍有变更时更新、变干净后关闭。正常主仓库刷新优先从对应工作树inspection解析所选文件,不因主仓库同名文件缺失而清空linked-worktree Diff。 macOS Commit 右键在新标签页中显示差异允许同文件创建独立比较;普通显示仍去重。插件 VCS 比较复用普通 repositoryDiff 标签及分屏,不激活 Git;占位标签拥有异步任务,关闭标签/工作区取消并清理,迟到成功/失败不复活或抢焦点。" }, "windows": { "evidence": [ @@ -54,6 +58,6 @@ "verificationStatus": "verified" }, "owner": "Git", - "verification": "验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。 调整下方 Git Log 或终端高度时,上方 Diff 的全文测量、折叠和双栏规划不随尺寸重复计算;核对 GitCommitDiffReviewView、DiffReviewView 和共享 DiffPaneView 的新增/删除单栏、双栏、搜索/折叠/差异选择及收缩恢复行为。 长 Diff 双栏只创建可见区域附近的行;用 1,200 行夹具检查初始视口、滚动到末行和连续改变可见高度。 macOS 原生双栏 Diff 按 IDEA Islands/Darcula 配色显示新增绿、删除灰、修改蓝;JetBrains Mono Regular 13pt 与 22pt 行高;原版/新版行号分别固定在中央两侧,单侧增删后仍对应各自源文件行号。核对深浅外观、中央 1pt 分隔线、横向滚动和非等宽拖动后的连接带位置;单栏增删同步使用该配色。 双栏代码通过原生 NSTextView 连续选中,深色/浅色光标行号分别使用 A1A3AB/767A8A;整行修改背景按 IDEA 混合淡化,词内使用原修改色。分隔条允许先隐藏代码、再隐藏行号,双向完全收起后可恢复;窄窗口无负尺寸。1,200 行组件回归连续调整 60 次,检查文字存储不重建且固定容器排版不失效、透明定位/连接带不拦截代码点击。组件绘制耗时仅作诊断,完整应用的连续拖动视觉验收仍 pending。 历史提交 Diff 两行顶部显示紧凑工具栏与父/当前提交短哈希,左版本附中间省略的路径;隐藏补丁区块头,设置菜单保留词内高亮和关闭。切换单栏后版本标题上下排列,正文按旧/新顺序显示修改,左侧双源行号,仍可连续选中/复制。两侧滚动条按各自完整代码流标出增删改,点击跳至变更;纵向按视口三分之一锚点映射已有差异边界,匹配行连接带随滚动变平。检查真实原生 clip 偏移、标记颜色/点击、通知卸载和文字缓存不重建,保留共享折叠/区块操作。 历史提交页顶部按 Islands 独立工具栏层验证:40pt 内容、上 2pt/左右 6pt 留白与 6pt 圆角;版本标题四边 6pt 内边距、29pt 行高、1pt TEARLINE 底边;路径 13pt Regular/#73767C。布局选择由一个父外框及选中项边框绘制,检查未选项不重复描边、实际深浅像素和原生点击切换。 Windows 实现未改;完整工作区视觉与连续滚动性能验收保持 pending。 macOS 连续修改加多行插入、反向删除使用同一个左右源范围配对;纯插入/删除仍为绿/灰。检查行号、连接带、标记与整块正文一致,词内单侧变化使用增删色,滚动后空范围边界保留 1pt 线。 历史提交工具栏复用现有打开文件、当前提交内文件导航和 DiffCollapse,首尾/单文件箭头及删除文件打开入口禁用;双栏/单栏折叠、单区域展开和切换文件状态清理。版本标题使用真实布局高度,比较实际原生代码行转换到连接带的坐标,初始及滚动均无 29pt 偏移;中央宽度 24pt,源行号按真实字型测量并共享最大宽度,右数字不进入折叠空白区。Diff 错误条使用 14pt 宽标记(右 x=4、左 x=0),末行端点和 2pt 最小高度,滑块覆盖与点击定位保留。 Diff 正文和连接带不得绘制到工具栏或左右侧栏,平直连接带与行号区使用相同的排他行末端;差异导航不绘制蓝色竖线。只保留右侧纵向 thumb,左侧保留变更标记;真实窗口鼠标拖动右侧和底部横向 thumb 均改变代码偏移。嵌套工作区中将中央分隔条拖至两端后再次拉回,确认命中区域留在 Diff 内且外层面板不移动。多行调用重排按整段左右文本计算内部高亮,避免逐行配对造成绿色/灰色碎片;保留纯单侧内部变化颜色。 核对相邻等行数修改只高亮各行变化参数,正文保持淡色、中央保持原修改色;深浅主题下以 0/0.25/0.5/0.75pt 偏移验证相邻行背景无细缝。 中央连接带端点与行号填充采用相同设备像素覆盖范围;1×/2× 与小数滚动/拖动坐标下检查平直块跨中央区域无边框或接缝,保留曲线抗锯齿。 从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows Commit、Git Log 及统一 Diff 中确认完整上下文不折叠,没有 hidden lines;核对中央行号两侧留白与竖线、Commit 动作区比 Git Log 更宽且不遮挡数字或代码,每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,2026-10-17 起与文件列表及整仓库丢弃一致遵守「丢弃前确认」设置:开启时先确认再恢复该块且暂存区不变,关闭时直接执行,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。 在 Windows 从提交面板点击未提交文件,确认差异标签标题为「提交: 文件名」(英文为 Commit: )并显示差异图标,悬停显示完整标题;在提交面板切换到另一个文件时同一标签标题随之更新;从编辑器 gutter 打开整文件差异时标题保持「未提交的更改」。 macOS 工作区、linked worktree、目录、冲突、行标记、分支/引用、历史提交和 Stash/Shelf Diff 均显示普通共享编辑标签;切回文件/媒体/终端/Agent 不清空比较,重新进入不重复添加标签。重排、拖动分屏与关闭空组合并沿用普通分组逻辑;关闭当前项只关闭活动标签,后台 Diff 不截获文件关闭命令;从 Diff 按钮、设置菜单或 ⌘W 关闭时只清理目标比较状态并拒绝迟到结果,关闭活动 Diff 优先恢复本组标签。 审核回归另核对无末尾换行、CRLF-only 和内容伴随 CRLF:不能用有损行投影显示已包含或写入块,整文件暂存及实际提交的 blob 字节必须与磁盘一致。暂存 B 后磁盘恢复 HEAD 的 a,仍应显示暂存版本和文件框;二进制 A→B 暂存后磁盘 C,文件框显示部分包含,点击包含剩余内容。Unix 在公共可遍历父目录验证临时上下文没有组/其他用户访问权限,Drop 清理 HEAD/index;本机 Windows 权限执行范围与 macOS CI 断言应分别记录。 Windows Git Log 单文件和 Commit 文件 Diff 的词级高亮、显示空白符、统一/并排切换均在左侧,最右侧统计当前文件差异,区分无差异/单数/复数;Commit 勾选、部分包含、取消及刷新后 included 数同步变化,全部包含仍显示数量;加载、切换文件及图片/二进制不显示文本差异统计。核对中英文、明暗主题、窄窗口和按钮选中/禁用状态。 Windows 单文件 Diff 上箭头在首处继续进入上一文件最后一处,下箭头在末处进入下一文件首处,集合首尾不循环、加载中禁用;Commit 文件列表随跨文件导航更新选中行并展开祖先、滚动到可见位置,保留编辑器焦点和 include 状态;同文件刷新、历史 Diff、无关仓库和迟到读取不扰动列表。 Windows Git Log 鼠标或上下键选择提交后,异步 Diff 自动定位第一处改动但保持提交列表键盘焦点;并排与统一预览均不调用初始 focus。双击/Enter 打开完整 Diff、主动差异跳转及跨文件导航仍进入编辑器。 macOS Commit/Shelf 标题栏创建补丁按钮使用共享工具栏按钮样式,悬停保持箭头;打开补丁弹窗检查共享浮层外观与主次按钮,列表选择、生成预览、关闭及应用确认流程保持不变。 macOS 标题栏仅显示创建补丁入口,不额外显示应用补丁按钮。创建和应用补丁表单使用 Commit 共享标题栏对话框、背景、留白与主次按钮;打开或取消原生文件选择器以及内部下拉菜单均不得清空表单。窗口打开时后台主窗口不能触发另一编辑,关闭后恢复交互;标题和内容继承同一应用语言,主窗口关闭仍释放面板与监听。 macOS 共享居中弹窗需在单次点击后立即显示,关闭和重新打开无需其他交互;重复点击不得新增窗口,取消或卸载后不得显示过期弹窗。原生 SwiftUI 状态回归由 LitheCenteredPopupTests 覆盖。 macOS Commit 与 Git Log/Stash 共用 RepositoryDiffView;核对搜索、空白比较、整文件及块级暂存/取消暂存/丢弃仍可用,新建/删除文件使用新版单栏,历史入口无写入动作。旧面板与无调用渲染组件删除;真实工作区视觉验收仍需执行。 字节保真回退:CRLF(autocrlf=false/true、.gitattributes eol=crlf)与无末尾换行文件在预置非空暂存的仓库中逐块回退,目标块恢复为暂存区对应内容、未涉及区域原样保留、index 内容与 staged diff 前后不变;filter 或 working-tree-encoding 属性不安全的文件继续整文件降级。 块级回退确认:「丢弃前确认」开启时,预览面与 Monaco 块箭头均先弹确认,取消不写、确认后按打开时目标执行;确认期间同文件快照刷新后确认返回时不写入并提示选中内容已变化,切换文件或仓库后确认返回时不写入且不在新目标显示提示;重复点击只保留一个确认;设置关闭时直接执行。 macOS GitModuleTests 验证从linked worktree打开Diff后暂存/取消暂存仍保留比较、回滚后关闭,调用根目录不变且历史cursor不被关闭;实机工作树整文件与块级动作、切换工作树/仓库及后台刷新仍需验证。 多 Diff 回归:同时打开两个文件和同一文件的不同提交,检查独立标签、同一比较去重、切换保留内容/滚动、分屏各自显示、关闭一个不影响另一加载、Git Log 换提交不改变旧比较及工作区退出释放全部会话。", + "verification": "验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。 调整下方 Git Log 或终端高度时,上方 Diff 的全文测量、折叠和双栏规划不随尺寸重复计算;核对 GitCommitDiffReviewView、DiffReviewView 和共享 DiffPaneView 的新增/删除单栏、双栏、搜索/折叠/差异选择及收缩恢复行为。 长 Diff 双栏只创建可见区域附近的行;用 1,200 行夹具检查初始视口、滚动到末行和连续改变可见高度。 macOS 原生双栏 Diff 按 IDEA Islands/Darcula 配色显示新增绿、删除灰、修改蓝;JetBrains Mono Regular 13pt 与 22pt 行高;原版/新版行号分别固定在中央两侧,单侧增删后仍对应各自源文件行号。核对深浅外观、中央 1pt 分隔线、横向滚动和非等宽拖动后的连接带位置;单栏增删同步使用该配色。 双栏代码通过原生 NSTextView 连续选中,深色/浅色光标行号分别使用 A1A3AB/767A8A;整行修改背景按 IDEA 混合淡化,词内使用原修改色。分隔条允许先隐藏代码、再隐藏行号,双向完全收起后可恢复;窄窗口无负尺寸。1,200 行组件回归连续调整 60 次,检查文字存储不重建且固定容器排版不失效、透明定位/连接带不拦截代码点击。组件绘制耗时仅作诊断,完整应用的连续拖动视觉验收仍 pending。 历史提交 Diff 两行顶部显示紧凑工具栏与父/当前提交短哈希,左版本附中间省略的路径;隐藏补丁区块头,设置菜单保留词内高亮和关闭。切换单栏后版本标题上下排列,正文按旧/新顺序显示修改,左侧双源行号,仍可连续选中/复制。两侧滚动条按各自完整代码流标出增删改,点击跳至变更;纵向按视口三分之一锚点映射已有差异边界,匹配行连接带随滚动变平。检查真实原生 clip 偏移、标记颜色/点击、通知卸载和文字缓存不重建,保留共享折叠/区块操作。 历史提交页顶部按 Islands 独立工具栏层验证:40pt 内容、上 2pt/左右 6pt 留白与 6pt 圆角;版本标题四边 6pt 内边距、29pt 行高、1pt TEARLINE 底边;路径 13pt Regular/#73767C。布局选择由一个父外框及选中项边框绘制,检查未选项不重复描边、实际深浅像素和原生点击切换。 Windows 实现未改;完整工作区视觉与连续滚动性能验收保持 pending。 macOS 连续修改加多行插入、反向删除使用同一个左右源范围配对;纯插入/删除仍为绿/灰。检查行号、连接带、标记与整块正文一致,词内单侧变化使用增删色,滚动后空范围边界保留 1pt 线。 历史提交工具栏复用现有打开文件、当前提交内文件导航和 DiffCollapse,首尾/单文件箭头及删除文件打开入口禁用;双栏/单栏折叠、单区域展开和切换文件状态清理。版本标题使用真实布局高度,比较实际原生代码行转换到连接带的坐标,初始及滚动均无 29pt 偏移;中央宽度 24pt,源行号按真实字型测量并共享最大宽度,右数字不进入折叠空白区。Diff 错误条使用 14pt 宽标记(右 x=4、左 x=0),末行端点和 2pt 最小高度,滑块覆盖与点击定位保留。 Diff 正文和连接带不得绘制到工具栏或左右侧栏,平直连接带与行号区使用相同的排他行末端;差异导航不绘制蓝色竖线。只保留右侧纵向 thumb,左侧保留变更标记;真实窗口鼠标拖动右侧和底部横向 thumb 均改变代码偏移。嵌套工作区中将中央分隔条拖至两端后再次拉回,确认命中区域留在 Diff 内且外层面板不移动。多行调用重排按整段左右文本计算内部高亮,避免逐行配对造成绿色/灰色碎片;保留纯单侧内部变化颜色。 核对相邻等行数修改只高亮各行变化参数,正文保持淡色、中央保持原修改色;深浅主题下以 0/0.25/0.5/0.75pt 偏移验证相邻行背景无细缝。 中央连接带端点与行号填充采用相同设备像素覆盖范围;1×/2× 与小数滚动/拖动坐标下检查平直块跨中央区域无边框或接缝,保留曲线抗锯齿。 从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows Commit、Git Log 及统一 Diff 中确认完整上下文不折叠,没有 hidden lines;核对中央行号两侧留白与竖线、Commit 动作区比 Git Log 更宽且不遮挡数字或代码,每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,2026-10-17 起与文件列表及整仓库丢弃一致遵守「丢弃前确认」设置:开启时先确认再恢复该块且暂存区不变,关闭时直接执行,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。 在 Windows 从提交面板点击未提交文件,确认差异标签标题为「提交: 文件名」(英文为 Commit: )并显示差异图标,悬停显示完整标题;在提交面板切换到另一个文件时同一标签标题随之更新;从编辑器 gutter 打开整文件差异时标题保持「未提交的更改」。 macOS 工作区、linked worktree、目录、冲突、行标记、分支/引用、历史提交和 Stash/Shelf Diff 均显示普通共享编辑标签;切回文件/媒体/终端/Agent 不清空比较,重新进入不重复添加标签。重排、拖动分屏与关闭空组合并沿用普通分组逻辑;关闭当前项只关闭活动标签,后台 Diff 不截获文件关闭命令;从 Diff 按钮、设置菜单或 ⌘W 关闭时只清理目标比较状态并拒绝迟到结果,关闭活动 Diff 优先恢复本组标签。 审核回归另核对无末尾换行、CRLF-only 和内容伴随 CRLF:不能用有损行投影显示已包含或写入块,整文件暂存及实际提交的 blob 字节必须与磁盘一致。暂存 B 后磁盘恢复 HEAD 的 a,仍应显示暂存版本和文件框;二进制 A→B 暂存后磁盘 C,文件框显示部分包含,点击包含剩余内容。Unix 在公共可遍历父目录验证临时上下文没有组/其他用户访问权限,Drop 清理 HEAD/index;本机 Windows 权限执行范围与 macOS CI 断言应分别记录。 Windows Git Log 单文件和 Commit 文件 Diff 的词级高亮、显示空白符、统一/并排切换均在左侧,最右侧统计当前文件差异,区分无差异/单数/复数;Commit 勾选、部分包含、取消及刷新后 included 数同步变化,全部包含仍显示数量;加载、切换文件及图片/二进制不显示文本差异统计。核对中英文、明暗主题、窄窗口和按钮选中/禁用状态。 Windows 单文件 Diff 上箭头在首处继续进入上一文件最后一处,下箭头在末处进入下一文件首处,集合首尾不循环、加载中禁用;Commit 文件列表随跨文件导航更新选中行并展开祖先、滚动到可见位置,保留编辑器焦点和 include 状态;同文件刷新、历史 Diff、无关仓库和迟到读取不扰动列表。 Windows Git Log 鼠标或上下键选择提交后,异步 Diff 自动定位第一处改动但保持提交列表键盘焦点;并排与统一预览均不调用初始 focus。双击/Enter 打开完整 Diff、主动差异跳转及跨文件导航仍进入编辑器。 macOS Commit/Shelf 标题栏创建补丁按钮使用共享工具栏按钮样式,悬停保持箭头;打开补丁弹窗检查共享浮层外观与主次按钮,列表选择、生成预览、关闭及应用确认流程保持不变。 macOS 标题栏仅显示创建补丁入口,不额外显示应用补丁按钮。创建和应用补丁表单使用 Commit 共享标题栏对话框、背景、留白与主次按钮;打开或取消原生文件选择器以及内部下拉菜单均不得清空表单。窗口打开时后台主窗口不能触发另一编辑,关闭后恢复交互;标题和内容继承同一应用语言,主窗口关闭仍释放面板与监听。 macOS 共享居中弹窗需在单次点击后立即显示,关闭和重新打开无需其他交互;重复点击不得新增窗口,取消或卸载后不得显示过期弹窗。原生 SwiftUI 状态回归由 LitheCenteredPopupTests 覆盖。 macOS Commit 与 Git Log/Stash 共用 RepositoryDiffView;核对搜索、空白比较、整文件及块级暂存/取消暂存/丢弃仍可用,新建/删除文件使用新版单栏,历史入口无写入动作。旧面板与无调用渲染组件删除;真实工作区视觉验收仍需执行。 字节保真回退:CRLF(autocrlf=false/true、.gitattributes eol=crlf)与无末尾换行文件在预置非空暂存的仓库中逐块回退,目标块恢复为暂存区对应内容、未涉及区域原样保留、index 内容与 staged diff 前后不变;filter 或 working-tree-encoding 属性不安全的文件继续整文件降级。 块级回退确认:「丢弃前确认」开启时,预览面与 Monaco 块箭头均先弹确认,取消不写、确认后按打开时目标执行;确认期间同文件快照刷新后确认返回时不写入并提示选中内容已变化,切换文件或仓库后确认返回时不写入且不在新目标显示提示;重复点击只保留一个确认;设置关闭时直接执行。 macOS GitModuleTests 验证从linked worktree打开Diff后暂存/取消暂存仍保留比较、回滚后关闭,调用根目录不变且历史cursor不被关闭;实机工作树整文件与块级动作、切换工作树/仓库及后台刷新仍需验证。 多 Diff 回归:同时打开两个文件和同一文件的不同提交,检查独立标签、同一比较去重、切换保留内容/滚动、分屏各自显示、关闭一个不影响另一加载、Git Log 换提交不改变旧比较及工作区退出释放全部会话。 EditorTabOrderFeatureModelTests 验证同文件显式新标签与普通去重并存、关闭一个不影响另一个;VcsProviderAppModelTests 用可控基线请求覆盖成功、关闭标签、关闭工作区及焦点保持,确认任务和数据映射清空。真实插件新标签与分屏仍待目标产品验收。", "notes": "Windows Commit 单文件预览复用 Repository Diff 的新页面:中央行号、动态连接带、独立轨道与对应行同步,右侧块级包含框、左侧回退双箭头和手动/事件刷新。点击文件保持 HEAD 到工作区的完整比较,块包含通过真实暂存/取消暂存同步到文件列表;提交只使用已包含块的暂存内容。相邻修改不因 Git hunk 上下文合并而一起包含。回退只撤销未暂存部分并保留已有包含内容,点击双箭头按「丢弃前确认」设置执行(2026-10-17 规则变更,此前为直接执行),写入前重新验证快照;块写入期间提交保护,刷新保留滚动位置。核对新增/删除单标题、没有外圈与增删符号、轨道背景色一致、明暗主题和统一 gutter widget;真实临时仓库与有界组件回归已覆盖,完整 WebView2 的勾选、刷新及提交视觉/原生验收仍 pending。gutter 入口的整文件差异保持原标签。 修复状态映射备用入口和旧预览缺少工作区目标导致块控件缺失;按原显示路径解析嵌套仓库并补全元数据,迟到解析不得覆盖新比较。中央行号取消重复内边距并固定按钮左缘,关闭只读整行光标底色及内容宿主 title 气泡。真实 Chromium 中加载生产 React/Monaco,核对 Commit/Log 字体与色值、恢复缺失目标、块包含/取消/回退和滚动时控件及连接带位置;IPC 使用可控返回,不替代原生 WebView2 验收。 修复原生 Git 监听与只读快照的刷新循环:快照索引改用系统临时目录,不再创建 .git 子目录;保留外部变更监听与快照失效保护。真实 Git 回归检查读取前后仓库元数据目录时间及真实索引字节不变。 2026-10-05 完整原生 WebView2 独立配置验收通过 Commit 文件点击显示块框/双箭头、部分包含同步文件列表、回退保留索引及实际提交只包含勾选块;完整主题/长文件/快捷键验收仍 pending。 Windows Diff 取消默认上下文折叠,中央行号增加留白及主题边界竖线,Commit 每侧额外预留块控件宽度;双箭头与勾选框放在各自行号列的图标区,距外侧竖线保留对称留白、与数字保持间隔,不贴代码边缘。整文件新增保持无外圈。 行号与图标间距进一步收紧,方框水平居中于右侧行号和外侧竖线之间,左侧双箭头与行号镜像对齐,图标两侧等距;控件按首个改动行垂直居中;Commit 单文件预览保留轻量文件顺序,下箭头到最后差异后按需进入下一文件并定位首个改动,同一标签不循环。块包含、手动及事件刷新保留导航顺序,切换或关闭使迟到读取失效。 审核修复:EOF/CRLF 有损显示标识阻止新旧 Diff 的块写入,保留整文件入口;HEAD 到磁盘为空时显示真实暂存区内容并标注版本,显示回退不改变写入身份。整文件三态直接读取 staged/worktree,覆盖二进制、重命名及没有安全块的情况。新旧 Diff 块级双箭头 2026-10-17 起与文件/整仓库的丢弃确认统一遵守设置,设置描述已同步更新范围。Unix 临时索引目录在创建时设置 0700,保持原子占有及错误/正常销毁清理;Windows 保持用户临时目录继承权限。 Windows 两处单文件 Diff 共用工具栏:显示选项移到左侧,右端显示 IDEA 差异统计及 Commit included 数;英文文案、单复数和空白符稳定开关名参考 Community 源码。组件回归覆盖统计及开关行为,原生视觉验收 pending。 Windows 上下差异箭头补齐双向跨文件导航;Commit 列表从活动工作区预览的仓库/路径同步行选择和一次性虚拟滚动,沿用原有加载代次保护。组件回归覆盖双向边界、嵌套仓库、折叠展开、刷新及焦点/include 保持,完整原生验收 pending。 2026-10-06:用户确认 PR #1083 涉及的 Windows 功能改动均已完成实机测试,未发现问题;实机验收依据为用户反馈。 2026-10-22:Windows 单文件并排预览在 Git 连续变更块内部增加仅显示的 IDEA 式子块拆分(参考 Community ByWord.compareAndSplit / LineFragmentSplitter):按块内行词元相似度配对,完全相同的行按上下文不绘制,仅单侧存在的行恢复新增绿/删除灰,其余相似行保持修改蓝;无共享词元的相邻子块仍合并为修改块,超预算回退为整个 Git 块。Git 脚本、差异计数、块包含、回退与导航不变,单个 Git 块的控件只绘制一次。真实 17_menus.jsonl 的 115/113 行单块被拆出第 2、3 行新增子块;完整 WebView2 像素验收仍 pending。 2026-10-22 起文件导航按用户看到的列表顺序:Commit 面板单文件/批量 Diff 使用仓库→changelist→文件夹树或状态分组的行顺序,Git Log 提交预览、完整提交、范围与离散多选 Diff 使用提交文件树顺序;未跟踪目录汇总行不参与导航,重复路径稳定相邻。展示顺序由 orderGitStatusFilesForDisplay 与 gitFileTreeOrder 共用,面板和导航不再各自排序;组件回归覆盖混合状态与重复路径。 复用标签标题随当前选中比较更新:Git Log 预览箭头切换文件后,标签栏比较选中文件名而不是只看虚拟路径。 macOS 仓库 Diff 不再在根编辑区覆盖标签栏;采用带 UUID 的独立 repositoryDiff 标签及 GitRepositoryDiffSession:不同文件、提交、目录和引用比较可以并存,同一比较复用已有标签,服务与写入工作流仍由 GitFeatureModel 共享。EditorTabOrderFeatureModelTests 在深浅主题覆盖八种入口的实际原生分组、切换、分屏与关闭,GitModuleTests 覆盖异步结果隔离;Windows 现有 working-tree/commit-file-preview 已走普通标签,仅核对源码,完整两端工作区验收仍 pending。" } diff --git a/shared/platform-feature-matrix/features/git-large-untracked-directories.json b/shared/platform-feature-matrix/features/git-large-untracked-directories.json index 6c56f30394..94e93b42b3 100644 --- a/shared/platform-feature-matrix/features/git-large-untracked-directories.json +++ b/shared/platform-feature-matrix/features/git-large-untracked-directories.json @@ -13,7 +13,8 @@ "macos/Sources/LitheGitModule/Models/GitModels.swift", "macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift", "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", - ".agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md" + ".agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md", + "rust/lithe-core/src/git/mod.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -29,12 +30,14 @@ "windows/tauri/src/features/git/utils/git-changelists.ts", "windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts", "windows/tauri/src/features/git/utils/git-untracked-directories.test.ts", - "windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx" + "windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx", + "rust/lithe-core/src/git/mod.rs", + "rust/lithe-core/src/tests/git_untracked_directories.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "在仓库中放入未被忽略、超过 100 个文件的构建目录(如 rust/lithe-core/target/),以及包含多个 linked worktree 的 .worktrees/。打开 Commit 面板:构建目录只显示一行目录节点和「N 个文件」,状态栏与 ChangeList 计数按文件统计;.worktrees/ 不被汇总,已发现的 worktree 不出现在父仓库列表。展开目录只加载下一层,子目录继续显示为带数量的节点;构建写入触发刷新后展开状态保持,目录被删除或暂存后展开状态清除。勾选目录节点暂存全部文件,丢弃目录节点删除其中未跟踪文件,搁置后能完整恢复目录内容;项目树中该目录下的文件仍显示未跟踪颜色,编辑器行标记正常。多仓库和单仓库模式下滚动与帧率不随变更数量下降。 Windows 另检查目录视图与平铺视图:折叠的汇总显示为带「N 个文件」的目录行,展开后由同名文件夹代表该汇总,折叠该文件夹即收起汇总,已记住的文件夹折叠不能挡住展开内容;单击汇总行不打开 Diff,「显示 Diff」与查看全部变更跳过汇总本身;删除汇总只删一次目录;页脚计数、项目树颜色、编辑器装订线与行历史对汇总目录下的文件按未跟踪处理;把汇总移到 ChangeList 后暂存并提交,提交范围包含其文件。", - "notes": "#1385:Core 的 git.status 新增 summarizeUntrackedDirectories,使用 Git 的 normal 未跟踪视图,只汇总不含嵌套仓库的整目录;git.untrackedDirectory 按层列出。macOS 变更列表改为扁平行直接交给 LazyVStack,只构建可见行。Windows 由写入 repositoryStatuses 的状态读取开启汇总,展开内容在同一次读取中列出并发布,Shelf 等一次性读取保持逐文件;状态列表原本已虚拟化。" + "verification": "在仓库中放入未被忽略、超过 100 个文件的构建目录(如 rust/lithe-core/target/),以及包含多个 linked worktree 的 .worktrees/。打开 Commit 面板:构建目录只显示一行目录节点和「N 个文件」,状态栏与 ChangeList 计数按文件统计;.worktrees/ 不被汇总,已发现的 worktree 不出现在父仓库列表。展开目录只加载下一层,子目录继续显示为带数量的节点;构建写入触发刷新后展开状态保持,目录被删除或暂存后展开状态清除。勾选目录节点暂存全部文件,丢弃目录节点删除其中未跟踪文件,搁置后能完整恢复目录内容;项目树中该目录下的文件仍显示未跟踪颜色,编辑器行标记正常。多仓库和单仓库模式下滚动与帧率不随变更数量下降。 Windows 另检查目录视图与平铺视图:折叠的汇总显示为带「N 个文件」的目录行,展开后由同名文件夹代表该汇总,折叠该文件夹即收起汇总,已记住的文件夹折叠不能挡住展开内容;单击汇总行不打开 Diff,「显示 Diff」与查看全部变更跳过汇总本身;删除汇总只删一次目录;页脚计数、项目树颜色、编辑器装订线与行历史对汇总目录下的文件按未跟踪处理;把汇总移到 ChangeList 后暂存并提交,提交范围包含其文件。 Core真实仓库回归核对status argv/output不含201个无关文件、200个长路径分批且未选文件保留、方括号字面文件名不影响邻居、以??开头的重命名源不误解析为未跟踪;macOS测试核对同级展开保留、旧目录列出不能覆盖新局部状态,实机大型仓库滚动与延迟仍需验收。", + "notes": "#1385:Core 的 git.status 新增 summarizeUntrackedDirectories,使用 Git 的 normal 未跟踪视图,只汇总不含嵌套仓库的整目录;git.untrackedDirectory 按层列出。macOS 变更列表改为扁平行直接交给 LazyVStack,只构建可见行。Windows 由写入 repositoryStatuses 的状态读取开启汇总,展开内容在同一次读取中列出并发布,Shelf 等一次性读取保持逐文件;状态列表原本已虚拟化。 共享Core回滚查询仅扫描字面所选路径,复用porcelain v2解析消费重命名源记录,状态和clean按路径预算分批;不再按每个文件枚举全库未跟踪目录。macOS局部发布只清理已确认根的展开状态,过期子项读取不能恢复旧行或折叠较新展开。" } diff --git a/shared/platform-feature-matrix/features/git-status-commit.json b/shared/platform-feature-matrix/features/git-status-commit.json index 7dd3ddf0a9..aa2579112f 100644 --- a/shared/platform-feature-matrix/features/git-status-commit.json +++ b/shared/platform-feature-matrix/features/git-status-commit.json @@ -10,10 +10,23 @@ "macos/Sources/LitheGitModule/Application/GitFeatureModel.swift", "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", "macos/Tests/LitheTests/GitStatusObservationTests.swift", - ".agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md" + ".agents/notes/implemented/feature/2026-09-27-workspace-git-commit-plans.md", + "macos/Sources/Lithe/Views/Git/GitRollbackDialog.swift", + "macos/Sources/LitheGitModule/Models/GitRollbackSelection.swift", + "macos/Tests/LitheGitModuleTests/GitRollbackSelectionTests.swift", + "macos/Tests/LitheTests/GitRollbackDialogTests.swift", + ".agents/notes/implemented/feature/2026-10-29-git-rollback-confirmation.md", + "macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift", + "macos/Sources/Lithe/Views/Git/CommitFileDeletionPresentation.swift", + "macos/Tests/LitheTests/CommitFileDeletionTests.swift", + "macos/Tests/LitheTests/CommitDraftFeatureModelTests.swift", + ".agents/notes/implemented/feature/2026-10-29-commit-file-context-actions.md", + "macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift", + "macos/Tests/LitheTests/LitheCoreLogicTests.swift" ], "implementationStatus": "implemented", - "verificationStatus": "pending" + "verificationStatus": "pending", + "notes": "macOS 现有弯箭头打开 IDEA 式回滚确认文件树:当前 ChangeList 已暂存文件优先默认纳入,可独立增减文件、分组勾选,关闭不写入;确认恢复已暂存与未暂存内容,新增文件默认保留本地副本,显式选择才删除。Worktrees 使用固定 checkout 模型复用流程;忙时阻止暂存/提交交错,首错停止,取消/重置拒绝后续写入。Windows 保留原有按行选择、受确认设置控制的回滚入口,本次未同步该确认树。 macOS Commit 右键补齐提交文件、回滚、普通/新标签差异、删除与添加到 VCS。提交文件显式准备实际索引并聚焦原草稿,不直接提交;所选已部分暂存文件保持原索引,其它已暂存文件被排除。删除先确认确切文件,排空编辑器输入并拒绝脏文档,使用废纸篓且重新检查嵌套仓库;取消、重置及过期同意不能重放。 删除后仅清理确认已消失的所选新增索引记录(含复制目标及部分失败),普通跟踪删除和无关/部分暂存保持。暂存、取消暂存、删除与回滚先读取并发布涉及根的真实状态,再由工作区持有的任务刷新项目树及服务,不等待同级仓库、Stash或历史;同根和全局过期读取、重置后迟到结果被拒绝,读取失败保留未确认状态。 普通外部文件事件与写入后的非等待式恢复分别调度Git和项目内容刷新,慢速项目导入、搜索或后续事件取消不会阻塞已打开Commit面板;恢复扫描先确认监听配置及快照,再读Git,项目服务在后。工作区代次与独立刷新身份隔离旧任务,取消期间的新请求保留补读。后台全量只读查询不再禁用回滚,暂存待确认与真实写操作仍互斥,较早查询不能恢复已回滚行。" }, "windows": { "evidence": [ @@ -46,5 +59,5 @@ }, "owner": "Git", "notes": "Windows Commit Diff 增加块级包含:复选框来自真实 Git 暂存区,支持相邻块独立包含/取消、部分包含三态、暂存插入后的索引坐标;Commit 仅提交已包含块,文件列表部分包含不会退回整文件暂存。包含/回退及刷新持有提交保护,关闭预览后等待已发出的写入结束才释放;回退沿用未暂存修改契约。验证两个相邻修改只勾选一块后实际提交、随后取消/继续包含、另仓库事件隔离与回退校验期间文件变化;双箭头回退对应未包含块并保留已包含内容,2026-10-17 起同样遵守「丢弃前确认」设置。新增/删除使用整文件块,重命名/二进制等保留整文件操作。目标产品端到端验收仍 pending。 Commit 单文件比较保留文件顺序,末个差异后下箭头按需打开下一文件,块包含和刷新不丢失导航;同时收紧中央行号/图标留白并使块方框居中。 审核修复:EOF/CRLF 有损显示标识阻止新旧 Diff 的块写入,保留整文件入口;HEAD 到磁盘为空时显示真实暂存区内容并标注版本,显示回退不改变写入身份。整文件三态直接读取 staged/worktree,覆盖二进制、重命名及没有安全块的情况。新旧 Diff 块级双箭头 2026-10-17 起与文件/整仓库的丢弃确认统一遵守设置,设置描述已同步更新范围。Unix 临时索引目录在创建时设置 0700,保持原子占有及错误/正常销毁清理;Windows 保持用户临时目录继承权限。 Windows 上下差异箭头补齐双向跨文件导航;Commit 列表从活动工作区预览的仓库/路径同步行选择和一次性虚拟滚动,沿用原有加载代次保护。组件回归覆盖双向边界、嵌套仓库、折叠展开、刷新及焦点/include 保持,完整原生验收 pending。 Windows 提交面板提供 Commit/Shelf 页签,Amend 默认关闭。切换页签保留提交草稿;无文件或说明为空时按钮禁用,快捷键保留校验提示。变更行支持 Ctrl/Shift 范围选择,选中与包含状态独立。Shelf 和 Stash 写操作互斥于提交、暂存和 changelist 操作。 Windows Commit 与保存改动树正文使用共同的 ui-text-sm 字号;相关 Git 刷新入口复用 RefreshIcon 对应的 IDEA refresh.svg,保持重做图标原有映射。 按当前 Swift 源码重排:41px 圆角页签标题栏、40px 操作栏在 ChangeList 之前;父仓库选项由 GitView 持有并移到选择器下方;文件按 ChangeList 统一分组,新配置默认平铺。底部包含带历史图标的 Amend last commit、11px 线框 AI、12px 暂存计数和 50px 默认等宽说明框;窄侧栏只对顶部控件有序换行,两个提交按钮与设置同行。用户原有布局偏好保留,原生视觉验收 pending。 仓库标题使用普通树行、仓库名和前置三态框;窄面板隐藏父路径。首项预览独立于行点击偏好,始终打开完整 Diff。宽度测量保留隐藏描述的零文字宽度,避免空 flex 槽触发无限扩宽。", - "verification": "macOS 补充验证 #1033:在临时仓库对已提交文件执行 git rm --cached 并保留磁盘文件,打开项目及重复刷新 Git 状态应不闪退,保留暂存删除和未跟踪两条记录;暂存或取消暂存时旧快照不能提前清除待确认状态,两种记录顺序结果一致;实际暂存或取消暂存后,文件恢复干净时待确认状态必须清除。多仓库手动刷新时,A 成功且已干净、B 读取失败不能解除 B 的暂存等待状态或提交保护;B 随后返回旧状态仍保留乐观勾选值,只有成功读取到确认状态或干净快照后才解除。修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。 Windows 另验证提交区(对齐 IntelliJ 非模态提交面板):顶部为 Amend last commit、AI 与实际已暂存文件数(悬停显示按状态统计的图例);中间为提交说明编辑框(占位文字为“Commit Message/提交说明”,不带省略号;4px 圆角,常态 1px 控件边框,聚焦 2px 强调色边框,失败时错误色边框);底部左侧为 IDEA 线框样式的 Commit 与 Commit and Push... 按钮(28px 高、4px 圆角、1px 边框),右侧为设置按钮。提交区与变更列表之间只有一条分割线、无外框,拖动分割线(或聚焦后按上/下方向键)可调整提交说明编辑框高度:向上拖动变高,变更列表至少保留 160px(40px 操作栏和 120px ChangeList 区域),编辑框最小 50px,松开后保存并在重启后恢复,拖动过程中不写入持久化。提交按钮在未选文件或说明为空时禁用;快捷键显示缺少文件或提交说明的提示。默认普通提交,勾选 Amend 后在审核计划中明确追加提交;切换仓库、分支和成功提交后重置 Amend。变更列表按当前 Swift 分为默认更改列表与命名 ChangeList,已跟踪和未跟踪文件同组,节点名后显示灰色“N 个文件”,复选框为三态(部分包含时显示短横);文件、文件夹、分组节点的包含复选框位于展开箭头之后、图标之前,箭头为 IDEA 细线 chevron(16px、1px 描边,亮色 #818594、暗色 #B4B8BF,悬停不变色),图标与文字在行内垂直居中,箭头/复选框/图标/文字间距 4px,文件夹名后紧跟灰色文件数;长文件名不再截断,变更列表出现横向滚动条。 变更列表工具栏有 IDEA 式“视图选项”(更多图标,眼睛用于预览首项),菜单中直接列出“目录 / 平铺显示”并勾选当前项,两次点击即可切换(平铺时文件名后附灰色目录),选择持久化;文件行末尾不再显示暂存 +/- 按钮;列表很长时纵向/横向滚动条滑块按可视比例显示长度,笔记本触控板左右滑动可横向滚动;面板从隐藏切回显示、侧栏改宽或字体加载完成后,长文件名仍完整显示而不被截断;名称超出面板可视右边缘时,鼠标悬停该行会像 IDEA 一样在行上方弹出完整名称(含文件数或目录),移开、滚动或点击即消失。 Windows 补充验证:先拉高提交说明,再缩小窗口或侧栏可用高度、触发按钮换行,确认变更列表仍保留 160px;恢复空间后恢复原高度偏好,隐藏时不覆盖偏好,继续拖动从实际高度开始。提交比较选择器查询失败时显示错误和重试;重试成功且结果为空才显示无提交;关闭、切换仓库或工作区后迟到结果不得覆盖当前状态。 审核回归另核对无末尾换行、CRLF-only 和内容伴随 CRLF:不能用有损行投影显示已包含或写入块,整文件暂存及实际提交的 blob 字节必须与磁盘一致。暂存 B 后磁盘恢复 HEAD 的 a,仍应显示暂存版本和文件框;二进制 A→B 暂存后磁盘 C,文件框显示部分包含,点击包含剩余内容。Unix 在公共可遍历父目录验证临时上下文没有组/其他用户访问权限,Drop 清理 HEAD/index;本机 Windows 权限执行范围与 macOS CI 断言应分别记录。 Windows 单文件 Diff 上箭头在首处继续进入上一文件最后一处,下箭头在末处进入下一文件首处,集合首尾不循环、加载中禁用;Commit 文件列表随跨文件导航更新选中行并展开祖先、滚动到可见位置,保留编辑器焦点和 include 状态;同文件刷新、历史 Diff、无关仓库和迟到读取不扰动列表。 Windows 在默认及舒适密度下修改界面字号,检查 Commit/Stash 树正文与 Project 一致;核对 Git 菜单、分支及仓库选择器的刷新图标和原有加载反馈。 以当前 Swift 五个源文件为基准检查 Commit/Shelf 圆角页签、操作栏顺序与活动列表范围、父仓库选项传入计划及忙时禁用。检查 218/320/449px、中英文和明暗主题下按钮不掉行、说明框不包含配置项,暂存计数不随行选择变化;保留目录偏好、无保存偏好默认平铺,拖动不重置已有说明框高度。 另检查多仓库名称、完整路径提示及三态框范围;关闭行点击 Diff 后,预览按钮仍打开完整首项比较。窄宽切换、隐藏父路径后无 React 更新循环;提交说明实际计算字体为编辑器字体及 13px 默认字号。Windows Commit 面板与 macOS 对齐:ChangeList 下拉保持固定宽度、新建/重命名/删除按钮靠右;ChangeList 标题为常规字重,仅仓库行加粗;父仓库选项使用 caption 字号;多仓库时仓库行文件夹图标按工作区仓库顺序着色;工具栏不再有独立的“选择差异来源”按钮,该入口位于“视图选项”的子菜单中。" + "verification": "macOS 补充验证 #1033:在临时仓库对已提交文件执行 git rm --cached 并保留磁盘文件,打开项目及重复刷新 Git 状态应不闪退,保留暂存删除和未跟踪两条记录;暂存或取消暂存时旧快照不能提前清除待确认状态,两种记录顺序结果一致;实际暂存或取消暂存后,文件恢复干净时待确认状态必须清除。多仓库手动刷新时,A 成功且已干净、B 读取失败不能解除 B 的暂存等待状态或提交保护;B 随后返回旧状态仍保留乐观勾选值,只有成功读取到确认状态或干净快照后才解除。修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。 Windows 另验证提交区(对齐 IntelliJ 非模态提交面板):顶部为 Amend last commit、AI 与实际已暂存文件数(悬停显示按状态统计的图例);中间为提交说明编辑框(占位文字为“Commit Message/提交说明”,不带省略号;4px 圆角,常态 1px 控件边框,聚焦 2px 强调色边框,失败时错误色边框);底部左侧为 IDEA 线框样式的 Commit 与 Commit and Push... 按钮(28px 高、4px 圆角、1px 边框),右侧为设置按钮。提交区与变更列表之间只有一条分割线、无外框,拖动分割线(或聚焦后按上/下方向键)可调整提交说明编辑框高度:向上拖动变高,变更列表至少保留 160px(40px 操作栏和 120px ChangeList 区域),编辑框最小 50px,松开后保存并在重启后恢复,拖动过程中不写入持久化。提交按钮在未选文件或说明为空时禁用;快捷键显示缺少文件或提交说明的提示。默认普通提交,勾选 Amend 后在审核计划中明确追加提交;切换仓库、分支和成功提交后重置 Amend。变更列表按当前 Swift 分为默认更改列表与命名 ChangeList,已跟踪和未跟踪文件同组,节点名后显示灰色“N 个文件”,复选框为三态(部分包含时显示短横);文件、文件夹、分组节点的包含复选框位于展开箭头之后、图标之前,箭头为 IDEA 细线 chevron(16px、1px 描边,亮色 #818594、暗色 #B4B8BF,悬停不变色),图标与文字在行内垂直居中,箭头/复选框/图标/文字间距 4px,文件夹名后紧跟灰色文件数;长文件名不再截断,变更列表出现横向滚动条。 变更列表工具栏有 IDEA 式“视图选项”(更多图标,眼睛用于预览首项),菜单中直接列出“目录 / 平铺显示”并勾选当前项,两次点击即可切换(平铺时文件名后附灰色目录),选择持久化;文件行末尾不再显示暂存 +/- 按钮;列表很长时纵向/横向滚动条滑块按可视比例显示长度,笔记本触控板左右滑动可横向滚动;面板从隐藏切回显示、侧栏改宽或字体加载完成后,长文件名仍完整显示而不被截断;名称超出面板可视右边缘时,鼠标悬停该行会像 IDEA 一样在行上方弹出完整名称(含文件数或目录),移开、滚动或点击即消失。 Windows 补充验证:先拉高提交说明,再缩小窗口或侧栏可用高度、触发按钮换行,确认变更列表仍保留 160px;恢复空间后恢复原高度偏好,隐藏时不覆盖偏好,继续拖动从实际高度开始。提交比较选择器查询失败时显示错误和重试;重试成功且结果为空才显示无提交;关闭、切换仓库或工作区后迟到结果不得覆盖当前状态。 审核回归另核对无末尾换行、CRLF-only 和内容伴随 CRLF:不能用有损行投影显示已包含或写入块,整文件暂存及实际提交的 blob 字节必须与磁盘一致。暂存 B 后磁盘恢复 HEAD 的 a,仍应显示暂存版本和文件框;二进制 A→B 暂存后磁盘 C,文件框显示部分包含,点击包含剩余内容。Unix 在公共可遍历父目录验证临时上下文没有组/其他用户访问权限,Drop 清理 HEAD/index;本机 Windows 权限执行范围与 macOS CI 断言应分别记录。 Windows 单文件 Diff 上箭头在首处继续进入上一文件最后一处,下箭头在末处进入下一文件首处,集合首尾不循环、加载中禁用;Commit 文件列表随跨文件导航更新选中行并展开祖先、滚动到可见位置,保留编辑器焦点和 include 状态;同文件刷新、历史 Diff、无关仓库和迟到读取不扰动列表。 Windows 在默认及舒适密度下修改界面字号,检查 Commit/Stash 树正文与 Project 一致;核对 Git 菜单、分支及仓库选择器的刷新图标和原有加载反馈。 以当前 Swift 五个源文件为基准检查 Commit/Shelf 圆角页签、操作栏顺序与活动列表范围、父仓库选项传入计划及忙时禁用。检查 218/320/449px、中英文和明暗主题下按钮不掉行、说明框不包含配置项,暂存计数不随行选择变化;保留目录偏好、无保存偏好默认平铺,拖动不重置已有说明框高度。 另检查多仓库名称、完整路径提示及三态框范围;关闭行点击 Diff 后,预览按钮仍打开完整首项比较。窄宽切换、隐藏父路径后无 React 更新循环;提交说明实际计算字体为编辑器字体及 13px 默认字号。Windows Commit 面板与 macOS 对齐:ChangeList 下拉保持固定宽度、新建/重命名/删除按钮靠右;ChangeList 标题为常规字重,仅仓库行加粗;父仓库选项使用 caption 字号;多仓库时仓库行文件夹图标按工作区仓库顺序着色;工具栏不再有独立的“选择差异来源”按钮,该入口位于“视图选项”的子菜单中。 新增 macOS 回滚回归:检查提交多选框默认纳入、行选择和窗口勾选分离、取消及空集合无写入、批量和部分暂存恢复、未选文件保持、新增及意向新增文件保留/删除、复制来源不变、重命名/删除及保留磁盘副本、linked Worktree 隔离;中英文深浅主题检查确认文件树及关闭/确认按钮,忙时互斥、首错停止及取消/重置有界回归。仍需完整工作台人工验收,verificationStatus 保持 pending。 通过 GitModuleTests 的 commitFiles 与 GitStatusObservationTests 的 realGitCommitFiles 验证执行顺序、变更列表、最终真实索引、部分暂存、复制来源、目录汇总展开、失败/重置/重入;CommitFileDeletionTests 验证原生确认/取消、编辑器同步后脏状态及过期保护、首错停止和干净文档清理。完整产品菜单工作流仍需实机验收。 临时真实仓库验证 staged-new 删除后绿色行立即消失、批次部分失败只清除已删除项、跟踪删除仍可提交、复制源和无关索引不变。受控异步测试阻塞解冻/同级扫描/旧状态/目录列出,确认本根行和Diff先更新、同级展开不折叠、最后完成写入对应最终状态、重置后旧刷新不结束新刷新;工作区解冻调度与取消有界验证。仍需完整产品测量操作到行更新的延迟,不以测试耗时宣称实机性能。 新增有界gate回归阻塞项目重载,验证普通事件与写入后解冻仍刷新Git并继续接收后续批次;取消在途读取后保留请求,切换工作区后旧读取不能释放新读取。真实临时Git仓库挂载同一个ChangesSidebarView,外部修改、新建、删除通过MacDirectoryWatcher自动更新模型及原生无障碍行;暂存后阻塞Stash只读,真实回滚按钮仍可点击并打开正确纳入集合,取消后真实索引不变。模块层另阻塞同级仓库旧读取,完成回滚后释放旧结果不得恢复行。完整工作台人工与大型仓库延迟测量仍pending。" } diff --git a/shared/platform-feature-matrix/features/git-worktrees.json b/shared/platform-feature-matrix/features/git-worktrees.json index ca68baa45d..c96b340d9a 100644 --- a/shared/platform-feature-matrix/features/git-worktrees.json +++ b/shared/platform-feature-matrix/features/git-worktrees.json @@ -10,11 +10,22 @@ "macos/Sources/LitheGitModule/Application/GitFeatureModel.swift", "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", "macos/Sources/Lithe/Application/Features/WorktreeCommitSession.swift", - "macos/Tests/LitheTests/GitStatusObservationTests.swift" + "macos/Tests/LitheTests/GitStatusObservationTests.swift", + "macos/Sources/Lithe/Views/Git/GitRollbackDialog.swift", + "macos/Sources/LitheGitModule/Models/GitRollbackSelection.swift", + "macos/Tests/LitheGitModuleTests/GitRollbackSelectionTests.swift", + "macos/Tests/LitheTests/GitRollbackDialogTests.swift", + ".agents/notes/implemented/feature/2026-10-29-git-rollback-confirmation.md", + ".agents/notes/implemented/feature/2026-10-29-commit-file-context-actions.md", + "macos/Sources/Lithe/Views/Git/GitWorktreeActions.swift", + "macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift", + "macos/Tests/LitheTests/CommitFileDeletionTests.swift", + "macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift", + "macos/Tests/LitheTests/LitheCoreLogicTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending", - "notes": "Worktrees 页接入共享搜索框、工具栏 SVG 与按钮、工具窗口标签、焦点选中态、原生滚动条及表单样式;详情采用平面分区,保留现有工作树操作与安全确认。 #1503:概览动作分组左对齐、6pt间距并按自然宽度换行;设置锁定按钮与下方按钮左边缘对齐,彩色状态紧邻按钮右侧,Protection说明位于整行最右,整行垂直居中,未锁定/已锁定采用共享成功/警告色;禁用保护操作,危险动作使用共享角色颜色;列表为状态列统一留白,选中背景内左右各留12pt;路径第二行使用完整可用宽度,有空间就完整显示,不再受260pt限制;实际不足时中间截断,悬停采用与Stash文件树相同的原生行内展开。 提交历史移除正常加载更多按钮,原生视口接近底部自动以有界 cursor 续页;追加保持偏移,同宽度下文档高度随行数更新,切换工作树/搜索重置视口。失败暂停并保留显式重试,无进展/耗尽停止,离开页面取消并关闭 cursor;加载历史内搜索不自动拉取全库。后台状态扫描保留已追加历史,不再由无主预热覆盖。Windows 当前仅有工作树管理弹窗,无对应历史详情,未宣称同步实现。 更改详情新增共享复选框、Command/Shift多选与全选、暂存/取消暂存、打开文件与Diff、定位/复制路径,以及明确提示未跟踪文件删除的回滚确认。应用层复用现有Git操作,匹配最新工作树及文件状态,拒绝外来/过期目标并去重在途批次;首错停止,取消/重置拒绝迟到结果。刷新仅替换状态、保留历史窗口与cursor,失败保留行,已打开Diff随状态刷新或关闭。 更改页复选框点击后立即显示目标暂存状态(乐观显示,Git 完成后以实际结果为准),且仅等待本工作树状态扫描完成即恢复可用,不再被全项目刷新阻塞;工具栏仅保留有实际动作的按钮(刷新、暂存全部、丢弃所选、显示 Diff),未生效项不再灰显占位。概览按钮自适应换行为单一动作流,宽窗口详情自适应两列;提交历史移除常驻加载更多按钮及固定行高页脚,列表撑满剩余空间;加载指示和失败显式重试覆盖在视口底部,不挤占列表高度。主工作区状态刷新同步已选详情,工作树列表刷新补读已选工作树文件状态,保留历史窗口与cursor,未变化快照不重复发布,扫描失败保留旧行并显示错误。 更改页收紧路径/分支标题间距,移除文件区外层留白,采用共享可拖动左右分栏,窄详情列上下排列。左侧按ChangeList分组,复用列表选择/新建/重命名/删除/多选移动及当前列表批量暂存;右侧复用原有提交说明、AI、Amend、提交及推送审阅/重试。提交绑定固定唯一选中工作树根,不把同级或嵌套仓库加入提交计划;状态扫描与目录展开另行发现所选checkout内仓库并沿用Core过滤,首次检查及手动/列表自动/独立提交模型刷新均排除独立嵌套仓库,包含工作区外linked根,防止全部暂存生成意外gitlink。Diff直接文件及区块暂存/取消暂存同样检查跨模型提交互斥,准备计划期间拒绝修改索引;认证/取消/模块保活由父Git模型持有,主提交和工作树提交互斥,同根列表及存储故障同步,linked根单独存储。各工作树草稿在当前工作区会话保留,重置使旧提交模型失效。Windows工作树弹窗无此详情提交区,不宣称同步实现。" + "notes": "Worktrees 页接入共享搜索框、工具栏 SVG 与按钮、工具窗口标签、焦点选中态、原生滚动条及表单样式;详情采用平面分区,保留现有工作树操作与安全确认。 #1503:概览动作分组左对齐、6pt间距并按自然宽度换行;设置锁定按钮与下方按钮左边缘对齐,彩色状态紧邻按钮右侧,Protection说明位于整行最右,整行垂直居中,未锁定/已锁定采用共享成功/警告色;禁用保护操作,危险动作使用共享角色颜色;列表为状态列统一留白,选中背景内左右各留12pt;路径第二行使用完整可用宽度,有空间就完整显示,不再受260pt限制;实际不足时中间截断,悬停采用与Stash文件树相同的原生行内展开。 提交历史移除正常加载更多按钮,原生视口接近底部自动以有界 cursor 续页;追加保持偏移,同宽度下文档高度随行数更新,切换工作树/搜索重置视口。失败暂停并保留显式重试,无进展/耗尽停止,离开页面取消并关闭 cursor;加载历史内搜索不自动拉取全库。后台状态扫描保留已追加历史,不再由无主预热覆盖。Windows 当前仅有工作树管理弹窗,无对应历史详情,未宣称同步实现。 更改详情新增共享复选框、Command/Shift多选与全选、暂存/取消暂存、打开文件与Diff、定位/复制路径,以及明确提示未跟踪文件删除的回滚确认。应用层复用现有Git操作,匹配最新工作树及文件状态,拒绝外来/过期目标并去重在途批次;首错停止,取消/重置拒绝迟到结果。刷新仅替换状态、保留历史窗口与cursor,失败保留行,已打开Diff随状态刷新或关闭。 更改页复选框点击后立即显示目标暂存状态(乐观显示,Git 完成后以实际结果为准),且仅等待本工作树状态扫描完成即恢复可用,不再被全项目刷新阻塞;工具栏仅保留有实际动作的按钮(刷新、暂存全部、丢弃所选、显示 Diff),未生效项不再灰显占位。概览按钮自适应换行为单一动作流,宽窗口详情自适应两列;提交历史移除常驻加载更多按钮及固定行高页脚,列表撑满剩余空间;加载指示和失败显式重试覆盖在视口底部,不挤占列表高度。主工作区状态刷新同步已选详情,工作树列表刷新补读已选工作树文件状态,保留历史窗口与cursor,未变化快照不重复发布,扫描失败保留旧行并显示错误。 更改页收紧路径/分支标题间距,移除文件区外层留白,采用共享可拖动左右分栏,窄详情列上下排列。左侧按ChangeList分组,复用列表选择/新建/重命名/删除/多选移动及当前列表批量暂存;右侧复用原有提交说明、AI、Amend、提交及推送审阅/重试。提交绑定固定唯一选中工作树根,不把同级或嵌套仓库加入提交计划;状态扫描与目录展开另行发现所选checkout内仓库并沿用Core过滤,首次检查及手动/列表自动/独立提交模型刷新均排除独立嵌套仓库,包含工作区外linked根,防止全部暂存生成意外gitlink。Diff直接文件及区块暂存/取消暂存同样检查跨模型提交互斥,准备计划期间拒绝修改索引;认证/取消/模块保活由父Git模型持有,主提交和工作树提交互斥,同根列表及存储故障同步,linked根单独存储。各工作树草稿在当前工作区会话保留,重置使旧提交模型失效。Windows工作树弹窗无此详情提交区,不宣称同步实现。 macOS 现有弯箭头打开 IDEA 式回滚确认文件树:当前 ChangeList 已暂存文件优先默认纳入,可独立增减文件、分组勾选,关闭不写入;确认恢复已暂存与未暂存内容,新增文件默认保留本地副本,显式选择才删除。Worktrees 使用固定 checkout 模型复用流程;忙时阻止暂存/提交交错,首错停止,取消/重置拒绝后续写入。Windows 保留原有按行选择、受确认设置控制的回滚入口,本次未同步该确认树。 Worktrees 更改列表复用同一文件菜单与固定 checkout 的提交准备/回滚模型,显式新标签差异沿用主编辑区生命周期;删除由共享确认模型检查编辑器和工作区有效性,不操作其它 checkout。 文件操作在释放工作区监听前确认对应checkout状态,不再等待主项目或同级仓库扫描;固定checkout模型与父模型按请求身份同步,保留目录展开、历史窗口和cursor,较早状态或取消/重置后的结果不覆盖新状态。 固定checkout模型复用回滚可用性规则:后台全量只读不单独禁用回滚,暂存待确认、提交及其他写操作仍互斥,实际确认继续校验最新已知状态与请求身份。" }, "windows": { "evidence": [ @@ -24,5 +35,5 @@ "verificationStatus": "pending" }, "owner": "Git", - "verification": "列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 macOS 深浅主题核对搜索及清除、列表焦点/失焦和窗口失活选中态、上下键选择(首尾边界不重复触发选择回调)、相同宽度下筛选/刷新导致的列表行数增减与底部可达性、详情标签、窄宽度、提交历史滚动与新建弹窗;检查右键菜单的保护/忙碌状态保持不变。 检查#1503工作树动作自然宽度、6pt间距、宽窄换行、主工作区禁用色;聚焦父区域及鼠标悬停时边框按钮无文字下划线,Tab仍可定位操作。 检查列表选中背景内左右12pt对称留白、路径随整行可用宽度自适应、宽栏完整显示与窄栏收缩,悬停截断路径显示完整内容,移到名称/分支或空白后关闭展开。 GitWorktreeAppearanceTests 原生深浅色视口检查溢出/短内容、接近底部、同页去重、同宽追加高度与偏移、缩放、失败/耗尽停止和查询重置;GitModuleTests 检查 50 条续页、cursor 关闭、5000 条上限、选择切换与取消、失败显式重试、状态扫描不覆盖追加历史。实机需在大型历史工作树持续滚动并切换工作树/搜索,确认无需点击且位置稳定。 GitWorktreeAppearanceTests 用真实原生事件验证复选框不打开Diff、Command/Shift选区、空格与Return、右键批量目标、禁用状态、共享动作挂载与选中工作树Diff回调,并检查深浅主题及320/600/850pt布局。GitModuleTests 验证暂存/取消暂存/回滚根目录隔离、部分暂存、Diff刷新关闭、历史cursor保留、旧目标拒绝、首错停止、扫描失败保留、在途去重、选择切换及取消/重置迟到错误隔离。实机仍需验证确认弹窗取消/确认及真实文件结果;Windows无更改详情,不宣称对应功能运行验收。 GitModuleTests 与 GitWorktreeAppearanceTests 额外覆盖:暂存请求在 Git 返回前即显示目标状态、失败回落真实状态、点击被拒绝期间仍显示请求状态、状态扫描完成即空闲而不等项目刷新、工具栏按钮启用条件、历史视口在空闲/加载/失败及120/300/600pt高度下撑满,失败保留显式重试;新增模块回归覆盖主工作区状态同步、HEAD未变时工作树刷新补读状态、未变快照版本保持、失败保留旧行及历史cursor不重置。实机仍需在大型仓库测量点击复选框到状态更新的真实延迟。 新增更改页深浅色分组展开/勾选分离及分组行键盘偏移、左右/上下切换保留选择和草稿、同轴缩放保留原生文档、尺寸边界及每根独立草稿回归;模块测试检查唯一根提交范围、ChangeList隔离/主根同步、存储故障同步与重置失效。真实Git集成用例通过共享Core提交linked工作树,断言主仓库HEAD和暂存文件不变;新增真实Git参数用例分别覆盖主checkout和工作区外linked中的独立嵌套仓库,首次检查、显式/自动/独立模型刷新、全部暂存及提交不产生mode-160000条目或.gitmodules,嵌套HEAD和索引保持不变。受控有界gate在提交计划读取后暂停准备返回,验证父Diff直接文件和区块暂存/取消暂存无service写调用,释放后提交成功且写入口恢复。实机仍需验收完整工作台分栏拖动、ChangeList管理、提交确认和推送流程;verificationStatus保持pending。" + "verification": "列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 macOS 深浅主题核对搜索及清除、列表焦点/失焦和窗口失活选中态、上下键选择(首尾边界不重复触发选择回调)、相同宽度下筛选/刷新导致的列表行数增减与底部可达性、详情标签、窄宽度、提交历史滚动与新建弹窗;检查右键菜单的保护/忙碌状态保持不变。 检查#1503工作树动作自然宽度、6pt间距、宽窄换行、主工作区禁用色;聚焦父区域及鼠标悬停时边框按钮无文字下划线,Tab仍可定位操作。 检查列表选中背景内左右12pt对称留白、路径随整行可用宽度自适应、宽栏完整显示与窄栏收缩,悬停截断路径显示完整内容,移到名称/分支或空白后关闭展开。 GitWorktreeAppearanceTests 原生深浅色视口检查溢出/短内容、接近底部、同页去重、同宽追加高度与偏移、缩放、失败/耗尽停止和查询重置;GitModuleTests 检查 50 条续页、cursor 关闭、5000 条上限、选择切换与取消、失败显式重试、状态扫描不覆盖追加历史。实机需在大型历史工作树持续滚动并切换工作树/搜索,确认无需点击且位置稳定。 GitWorktreeAppearanceTests 用真实原生事件验证复选框不打开Diff、Command/Shift选区、空格与Return、右键批量目标、禁用状态、共享动作挂载与选中工作树Diff回调,并检查深浅主题及320/600/850pt布局。GitModuleTests 验证暂存/取消暂存/回滚根目录隔离、部分暂存、Diff刷新关闭、历史cursor保留、旧目标拒绝、首错停止、扫描失败保留、在途去重、选择切换及取消/重置迟到错误隔离。实机仍需验证确认弹窗取消/确认及真实文件结果;Windows无更改详情,不宣称对应功能运行验收。 GitModuleTests 与 GitWorktreeAppearanceTests 额外覆盖:暂存请求在 Git 返回前即显示目标状态、失败回落真实状态、点击被拒绝期间仍显示请求状态、状态扫描完成即空闲而不等项目刷新、工具栏按钮启用条件、历史视口在空闲/加载/失败及120/300/600pt高度下撑满,失败保留显式重试;新增模块回归覆盖主工作区状态同步、HEAD未变时工作树刷新补读状态、未变快照版本保持、失败保留旧行及历史cursor不重置。实机仍需在大型仓库测量点击复选框到状态更新的真实延迟。 新增更改页深浅色分组展开/勾选分离及分组行键盘偏移、左右/上下切换保留选择和草稿、同轴缩放保留原生文档、尺寸边界及每根独立草稿回归;模块测试检查唯一根提交范围、ChangeList隔离/主根同步、存储故障同步与重置失效。真实Git集成用例通过共享Core提交linked工作树,断言主仓库HEAD和暂存文件不变;新增真实Git参数用例分别覆盖主checkout和工作区外linked中的独立嵌套仓库,首次检查、显式/自动/独立模型刷新、全部暂存及提交不产生mode-160000条目或.gitmodules,嵌套HEAD和索引保持不变。受控有界gate在提交计划读取后暂停准备返回,验证父Diff直接文件和区块暂存/取消暂存无service写调用,释放后提交成功且写入口恢复。实机仍需验收完整工作台分栏拖动、ChangeList管理、提交确认和推送流程;verificationStatus保持pending。 新增 macOS 回滚回归:检查提交多选框默认纳入、行选择和窗口勾选分离、取消及空集合无写入、批量和部分暂存恢复、未选文件保持、新增及意向新增文件保留/删除、复制来源不变、重命名/删除及保留磁盘副本、linked Worktree 隔离;中英文深浅主题检查确认文件树及关闭/确认按钮,忙时互斥、首错停止及取消/重置有界回归。仍需完整工作台人工验收,verificationStatus 保持 pending。 在非当前 linked worktree 多选文件,分别核对提交准备、回滚、删除取消和新标签差异都使用该 checkout,主工作区索引不变;GitWorktreeAppearanceTests 检查实际工具栏及范围多选,完整新增菜单整机验收保持 pending。 用有界gate阻塞工作区解冻,确认Worktree本地状态、暂存勾选与忙状态先完成;分别从父更改页和固定checkout模型写入,确认只读目标根、同级无额外扫描、目录展开与80条历史及cursor保持。真实linked仓库回滚与删除流程仍结合GitStatusObservationTests核对主仓库索引不变,完整产品端到端验收保持pending。 GitModuleTests新增阻塞后台全量读取时回滚可用并拒绝旧行回填的共享模型回归;固定checkout完整产品人工流程仍保持pending。" } diff --git a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json index 4c87947b71..49df62d44f 100644 --- a/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json +++ b/shared/platform-feature-matrix/features/vcs-subversion-and-plugin-providers.json @@ -27,11 +27,15 @@ "macos/Sources/Lithe/Application/Features/VcsHistoryListModel.swift", "macos/Sources/Lithe/Views/Components/LitheScrollLoadTrigger.swift", "macos/Tests/LitheTests/VcsHistoryListTests.swift", - "macos/Tests/LitheTests/ScrollLoadTriggerTests.swift" + "macos/Tests/LitheTests/ScrollLoadTriggerTests.swift", + "macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift", + "macos/Sources/Lithe/Views/Git/CommitFileDeletionPresentation.swift", + "macos/Tests/LitheTests/CommitFileDeletionTests.swift", + ".agents/notes/implemented/feature/2026-10-29-commit-file-context-actions.md" ], "implementationStatus": "partial", "verificationStatus": "pending", - "notes": " macOS 工作副本与单文件历史删除正常加载更多,原生视口接近底部自动请求 Provider 的下一页(每页50条),保留已有修订身份和偏移;模型去重在途/修订、拒绝旧上下文响应,空页/无进展/循环 cursor 停止,错误保留数据并等待显式重试。共享探针复用 MCP 目录,不影响 Git 原生渲染器或 Provider 协议。Windows 未接入。" + "notes": " macOS 工作副本与单文件历史删除正常加载更多,原生视口接近底部自动请求 Provider 的下一页(每页50条),保留已有修订身份和偏移;模型去重在途/修订、拒绝旧上下文响应,空页/无进展/循环 cursor 停止,错误保留数据并等待显式重试。共享探针复用 MCP 目录,不影响 Git 原生渲染器或 Provider 协议。Windows 未接入。 macOS 插件 Commit 菜单补齐提交文件、添加到 VCS、删除和新标签差异。添加实际派发所属 Provider 的 .add,已跟踪删除实际派发 .delete,无 fileOperations 能力则禁用且失败不退回磁盘删除;未版本化文件确认后进入废纸篓。根归属、嵌套/歧义根、截断发现、操作互斥和过期响应均在宿主检查。" }, "windows": { "evidence": [ @@ -46,6 +50,6 @@ "verificationStatus": "not-applicable" }, "owner": "Git", - "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp 与 scripts/architecture/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例;Node SDK 的 litheVcs 与示例 Provider 端到端测试在 lithe-plugin-docs 运行(scripts/sdk-e2e.mjs)。Subversion 社区插件:独立源码由 https://github.com/Lithe-IDEA/lithe-plugin-svn 维护;插件 CI 校验固定 SDK 的源码哈希,用 svn 录制的 status/info/log/blame --xml 与错误输出样例覆盖解析和错误码映射,并按 sdk.lock.json 的宿主提交构建 Core 桥接,在 svnadmin create 建立的 file:// 仓库上经 Core 跑通状态(含 changelist 中的路径)、提交、过期提交、更新与冲突、resolve、回滚、移动(未版本化文件不覆盖已有目标)、删除、历史分页、Annotate 和二进制文件;固定宿主基线不代表已验证未来 preview。主仓库只保留通用 VCS 契约回归(清单 fixture 与 Core 测试)。macOS 宿主自动化:VcsProviderHostTests 覆盖根识别与状态着色、每个根单独监听元数据目录(含包住工作区的工作副本)、工作区内新出现的工作副本重新识别、项目树移动两端都归同一个根才交给 Provider、等待归属判断时开始别的操作则退回磁盘移动、找不到 VCS 客户端时报 vcs.tool.unavailable、关闭项目窗口后停止 Provider 且之后的工作区加载不再启动、工具窗口使用通用版本控制图标。宿主验收:从磁盘或 Lithe Store 安装 Subversion 社区插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/macos/verify/verify-runtime-bundle-immutability.sh)。 VcsHistoryListTests 覆盖修订去重、耗尽/无进展/循环 cursor、失败保留与显式同页重试、在途去重、重置旧回复及取消;VcsProviderHostTests 使用 fake Provider 和真实 SwiftUI/NSScrollView 验证工作副本/单文件历史自动请求第二页、50条限额、追加偏移不动和耗尽停止。真实插件历史仍需 macOS 实机连续滚动验收,不据组件测试标记全部能力已验证。", + "verification": "Core 共享层自动化:cargo test -p lithe-core vcs、cargo test -p lithe-core --lib lsp 与 scripts/architecture/verify-shared-contracts.sh,覆盖 vcsProviders 清单校验与 git 保留、onVcsRoot 激活、svn 根识别(嵌套、歧义目录与映射、git worktree 文件、工作区位于工作副本内部、剪枝目录、深度上限)、vcs.ownerOf 最深根归属、litheVcs 只向插件会话提供、协商缺必需能力时拒绝、lsp.vcsRequest 按操作生成请求与能力检查、Provider 错误码白名单、vcs.decodeReply 的回复校验用例;Node SDK 的 litheVcs 与示例 Provider 端到端测试在 lithe-plugin-docs 运行(scripts/sdk-e2e.mjs)。Subversion 社区插件:独立源码由 https://github.com/Lithe-IDEA/lithe-plugin-svn 维护;插件 CI 校验固定 SDK 的源码哈希,用 svn 录制的 status/info/log/blame --xml 与错误输出样例覆盖解析和错误码映射,并按 sdk.lock.json 的宿主提交构建 Core 桥接,在 svnadmin create 建立的 file:// 仓库上经 Core 跑通状态(含 changelist 中的路径)、提交、过期提交、更新与冲突、resolve、回滚、移动(未版本化文件不覆盖已有目标)、删除、历史分页、Annotate 和二进制文件;固定宿主基线不代表已验证未来 preview。主仓库只保留通用 VCS 契约回归(清单 fixture 与 Core 测试)。macOS 宿主自动化:VcsProviderHostTests 覆盖根识别与状态着色、每个根单独监听元数据目录(含包住工作区的工作副本)、工作区内新出现的工作副本重新识别、项目树移动两端都归同一个根才交给 Provider、等待归属判断时开始别的操作则退回磁盘移动、找不到 VCS 客户端时报 vcs.tool.unavailable、关闭项目窗口后停止 Provider 且之后的工作区加载不再启动、工具窗口使用通用版本控制图标。宿主验收:从磁盘或 Lithe Store 安装 Subversion 社区插件后,在本地 svnadmin create 建立的 file:// 仓库检出的工作副本上验证根识别、文件状态颜色、gutter、提交、更新、回滚、add/move/delete 跟踪、文件历史、Annotate 和冲突显示;同时打开一个包含 Git 仓库与 SVN 工作副本的工作区,确认文件只归属最深的根,Git 行为不变;运行前后 app bundle 文件清单不变(scripts/macos/verify/verify-runtime-bundle-immutability.sh)。 VcsHistoryListTests 覆盖修订去重、耗尽/无进展/循环 cursor、失败保留与显式同页重试、在途去重、重置旧回复及取消;VcsProviderHostTests 使用 fake Provider 和真实 SwiftUI/NSScrollView 验证工作副本/单文件历史自动请求第二页、50条限额、追加偏移不动和耗尽停止。真实插件历史仍需 macOS 实机连续滚动验收,不据组件测试标记全部能力已验证。 VcsProviderHostTests 验证真实菜单回调的排序精确 .add/.delete 批次、缺能力、嵌套/歧义根、迟到所有权、串行操作及重置;CommitFileDeletionTests 验证Provider删除确认与成功后的干净文档关闭;在真实SVN社区插件工作副本上仍需验证添加、跟踪删除、取消不写及新标签完整流程,不能用fake Provider代替插件验收。", "notes": "Issue #827。Rust Core 已实现 vcs.detectRoots、vcs.ownerOf、vcs.decodeReply、lsp.vcsRequest 与 litheVcs 协商,Node SDK 的 litheVcs 支持随 SDK 迁到 lithe-plugin-docs(P1);Subversion 社区插件在独立仓库 Lithe-IDEA/lithe-plugin-svn 实现全部 11 种操作,ID 为 io.github.lithe-idea.svn-support,不需要 Lithe 签名(P2)。macOS 宿主已声明 vcsProviders:打开工作区时识别根并记住歧义目录的选择,插件的左侧工具窗口使用通用版本控制图标,首次读取状态时显示读取中,列出冲突、变更和未版本化文件,行支持辅助功能的选择、打开和勾选操作,支持勾选提交、更新、回滚、标记解决、清理、文件与工作副本历史、与基线比较;项目树按 Provider 状态着色并在右键菜单提供比较,项目树中的重命名和移动在两端都属于同一个根时交给 Provider 跟踪;项目树、搜索和索引像 .git 一样隐藏 .svn,每个根单独监听自己的元数据目录(含工作区位于工作副本内部时的上层 .svn),工作区里新检出的工作副本会被识别;找不到 svn 客户端时提示安装;关闭项目窗口即停止该窗口的 Provider;安装审核显示 Provider 的启动命令、客户端命令和是否联网(P3)。macOS 尚未接入编辑器 gutter 与 Annotate,所以记为部分实现;插件要等带 vcsProviders 能力的 Lithe 版本发布后再在 Lithe Store 上架。Windows 宿主尚未接入。分阶段计划见提议 Note。" } diff --git a/shared/platform-feature-matrix/features/workbench-panes.json b/shared/platform-feature-matrix/features/workbench-panes.json index 8d559a373a..59d549b214 100644 --- a/shared/platform-feature-matrix/features/workbench-panes.json +++ b/shared/platform-feature-matrix/features/workbench-panes.json @@ -24,10 +24,14 @@ "macos/Sources/Lithe/Views/Workbench/OutputTextView.swift", "macos/Tests/LitheTests/ConsoleInlineInputTests.swift", "macos/Tests/LitheTests/LitheToolWindowHoverTests.swift", + "macos/Tests/LitheTests/WorkbenchMavenLayoutTests.swift", + "macos/Tests/LitheTests/CommitActionButtonStyleTests.swift", + ".agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md", "macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift" ], "implementationStatus": "implemented", - "verificationStatus": "pending" + "verificationStatus": "pending", + "notes": "macOS 无已保存布局的工作区侧栏默认 360pt,上半区默认高度比例 0.72,使中文修订提交保持一行、底部工具窗初始更矮;主布局及停靠预览共用常量,已有布局和手动拖动尺寸优先,不提高最小宽高。" }, "windows": { "evidence": [ @@ -38,6 +42,6 @@ "verificationStatus": "pending" }, "owner": "Workbench", - "verification": "打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;macOS 主工作区窗口不再被固定的 980×640 内容下限阻止缩小,侧栏、右侧工具窗、编辑区和底部工具窗按 IDEA 主分隔器默认 30pt 下限与实时可用空间调整,无固定 520/760pt 上限,极窄窗口不产生负尺寸或越界,恢复窗口后保留原先保存的面板宽度;拖动右侧 Maven 工具窗时保留当前 Commit 侧栏宽度并由编辑区承受剩余空间变化,底部 Git Log 与终端始终横跨 Project、编辑区及右侧 Maven 所在的整行,向上拖动时上半行按共享 30pt 主分隔器下限收缩,Commit 不设专属高度下限;未打开文件时,将编辑区缩窄或缩矮,空状态提示保持原始文字尺寸、不换行或缩字,超出内容在编辑区内裁剪,恢复尺寸后完整显示;编辑区和左右工具窗共用按可见尺寸布局的面板容器,在 30pt 高度下 Project、Commit、Search 及右侧 Maven 标题仍贴顶且四角圆角保持可见;macOS Project、Commit、Search、Database 和 Pull Requests 侧栏标题右端使用 IDEA 明暗收起图标与共用悬停样式,点击后收起侧栏并保留活动栏和编辑区,再点击对应活动栏图标恢复侧栏及原宽度;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏运行配置使用 16pt 图标与 13pt Regular 文字、内容自然宽度和 6pt 图文间距;运行与调试使用 Community 描边资源、34×40pt 槽位及 30×30pt 悬停区域,运行、调试、更新和背景按钮悬停保持默认箭头,检查深浅主题、菜单开合、窄窗口和原动作;顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。 macOS 主工具栏仅保留运行配置、Run、Debug 及执行中的 Stop;确认右上角无新增搜索和设置按钮,原运行/调试动作及既有快捷键、应用菜单入口正常。 macOS 共享分隔条普通、hover 和按住拖动使用相同分隔线颜色,不增亮;原调整光标、命中范围和释放保存尺寸保持可用。 macOS 设置入口位于主工具栏最右侧,移除左下重复入口,沿用 IDEA settings SVG 和原设置动作,核对点击、悬停与窄窗口显示。 右上设置按钮先打开共享动作菜单,仅有设置/插件、分隔线、主题/快捷键四项,设置显示当前 Keymap 快捷键;各项打开现有通用/插件/快捷键分类,主题在通用主题设置中配置,Esc/外部点击关闭菜单。 右上齿轮与右侧通知/数据库入口共用竖直中心线,窄宽窗口保持对齐;右侧活动栏不再显示插件按钮,齿轮菜单插件项仍有效。 设置图标普通、悬停及菜单展开时使用右侧入口的 toolWindowButtonText 前景色,深浅主题跟随相同 token;保留现有菜单和悬停背景。 运行配置/运行/调试组与末端设置之间有可收缩留白,宽窗口对照参考位置左移,窄窗口收缩留白且设置保持右侧栏对齐;打开运行配置弹窗及运行/调试动作保持原行为。 A/B 分屏后在 A 发起跨文件 Rename/Code Action,验证 B 的同步 owner 与保存/关闭屏障不被接管,待处理输入先排空,修订递增并检查撤销;各组右键 Other/Left/Right/All 按组内重排后的可见顺序关闭文档,不关闭其他组或混合终端标签。 macOS 内置与声明式插件工具窗口通过稳定贡献 ID 注册,宿主菜单支持底部/右侧位置、顺序、入口显隐和默认布局恢复;几何尺寸提交不覆盖这些偏好。 macOS Project、Commit、Search 和 Pull Requests 入口通过贡献注册表显示(Pull Requests 仍受原功能开关限制);把内置侧栏和插件工具窗移动到左侧/右侧/底部,检查原内容、收起按钮、独立显隐与按工作区保存的入口顺序,重开项目恢复左栏选择和展开状态;旧会话名称与旧尺寸配置继续可读,禁用模块不实例化且关闭内容,插件左栏恢复必须激活所属模块。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 插件卸载后恢复包含旧侧栏 ID 的会话,确认回退项目入口且不出现未知底部加载窗口。 macOS Run/Debug/Stop 主工具栏入口与插件按钮共用显隐和顺序偏好,重开项目及尺寸提交后保持;找回隐藏工具栏或菜单入口不执行命令,菜单项可从菜单命令管理入口恢复。 恢复先前可见的 Services 后,不借助其他入口完成所属模块激活并能操作;Show 不会 toggle 关闭已经可见的窗口。Maven renderer 在非 Maven 项目不可见时,旧布局中的可见标记不占据停靠内容空间。prepareForSleep 挂起时并发 activate 等待休眠完成,只在新实例中重新激活,贡献不重复发布、旧实例资源全部释放。 关闭模块在 gate 暂停时取消等待激活的调用方,放行关闭后构造与贡献发布仍各一次,无实例及资源残留;首次点击和 Show 打开 A 时暂停激活,同组选择 B 后放行 A,最终仍显示 B,不同组可以同时显示;默认右侧 Database → Agent → Maven 中将 Maven 前移,再后移,检查实际 tools(in:) 顺序随之变化。 开启/替换/关闭工作台背景时,检查Debug Console正文、左侧工具栏及Performance展开/收起区均露出同一工作台背景;移除背景恢复主题底色,保留ANSI背景、选区、输入草稿和原生文本控件身份。 所有主工具窗口隐藏按钮共用整块面板悬停环境;鼠标离开时隐藏且不可鼠标命中,保留槽位与键盘/VoiceOver 焦点显示;分别检查普通宿主与 Terminal/Git 保留宿主,Project 正文悬停也显示定位、刷新和展开/收起动作。 共享分隔容器在横向/纵向、面板为空或含多个直接子视图时,仍保留两块独立面板及分隔条;搜索的更多提示不改变面板数量。核对尺寸限制、收起恢复、嵌套拖动和原生表面身份不变。", + "verification": "打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;macOS 主工作区窗口不再被固定的 980×640 内容下限阻止缩小,侧栏、右侧工具窗、编辑区和底部工具窗按 IDEA 主分隔器默认 30pt 下限与实时可用空间调整,无固定 520/760pt 上限,极窄窗口不产生负尺寸或越界,恢复窗口后保留原先保存的面板宽度;拖动右侧 Maven 工具窗时保留当前 Commit 侧栏宽度并由编辑区承受剩余空间变化,底部 Git Log 与终端始终横跨 Project、编辑区及右侧 Maven 所在的整行,向上拖动时上半行按共享 30pt 主分隔器下限收缩,Commit 不设专属高度下限;未打开文件时,将编辑区缩窄或缩矮,空状态提示保持原始文字尺寸、不换行或缩字,超出内容在编辑区内裁剪,恢复尺寸后完整显示;编辑区和左右工具窗共用按可见尺寸布局的面板容器,在 30pt 高度下 Project、Commit、Search 及右侧 Maven 标题仍贴顶且四角圆角保持可见;macOS Project、Commit、Search、Database 和 Pull Requests 侧栏标题右端使用 IDEA 明暗收起图标与共用悬停样式,点击后收起侧栏并保留活动栏和编辑区,再点击对应活动栏图标恢复侧栏及原宽度;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏运行配置使用 16pt 图标与 13pt Regular 文字、内容自然宽度和 6pt 图文间距;运行与调试使用 Community 描边资源、34×40pt 槽位及 30×30pt 悬停区域,运行、调试、更新和背景按钮悬停保持默认箭头,检查深浅主题、菜单开合、窄窗口和原动作;顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。 macOS 主工具栏仅保留运行配置、Run、Debug 及执行中的 Stop;确认右上角无新增搜索和设置按钮,原运行/调试动作及既有快捷键、应用菜单入口正常。 macOS 共享分隔条普通、hover 和按住拖动使用相同分隔线颜色,不增亮;原调整光标、命中范围和释放保存尺寸保持可用。 macOS 设置入口位于主工具栏最右侧,移除左下重复入口,沿用 IDEA settings SVG 和原设置动作,核对点击、悬停与窄窗口显示。 右上设置按钮先打开共享动作菜单,仅有设置/插件、分隔线、主题/快捷键四项,设置显示当前 Keymap 快捷键;各项打开现有通用/插件/快捷键分类,主题在通用主题设置中配置,Esc/外部点击关闭菜单。 右上齿轮与右侧通知/数据库入口共用竖直中心线,窄宽窗口保持对齐;右侧活动栏不再显示插件按钮,齿轮菜单插件项仍有效。 设置图标普通、悬停及菜单展开时使用右侧入口的 toolWindowButtonText 前景色,深浅主题跟随相同 token;保留现有菜单和悬停背景。 运行配置/运行/调试组与末端设置之间有可收缩留白,宽窗口对照参考位置左移,窄窗口收缩留白且设置保持右侧栏对齐;打开运行配置弹窗及运行/调试动作保持原行为。 A/B 分屏后在 A 发起跨文件 Rename/Code Action,验证 B 的同步 owner 与保存/关闭屏障不被接管,待处理输入先排空,修订递增并检查撤销;各组右键 Other/Left/Right/All 按组内重排后的可见顺序关闭文档,不关闭其他组或混合终端标签。 macOS 内置与声明式插件工具窗口通过稳定贡献 ID 注册,宿主菜单支持底部/右侧位置、顺序、入口显隐和默认布局恢复;几何尺寸提交不覆盖这些偏好。 macOS Project、Commit、Search 和 Pull Requests 入口通过贡献注册表显示(Pull Requests 仍受原功能开关限制);把内置侧栏和插件工具窗移动到左侧/右侧/底部,检查原内容、收起按钮、独立显隐与按工作区保存的入口顺序,重开项目恢复左栏选择和展开状态;旧会话名称与旧尺寸配置继续可读,禁用模块不实例化且关闭内容,插件左栏恢复必须激活所属模块。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 插件卸载后恢复包含旧侧栏 ID 的会话,确认回退项目入口且不出现未知底部加载窗口。 macOS Run/Debug/Stop 主工具栏入口与插件按钮共用显隐和顺序偏好,重开项目及尺寸提交后保持;找回隐藏工具栏或菜单入口不执行命令,菜单项可从菜单命令管理入口恢复。 恢复先前可见的 Services 后,不借助其他入口完成所属模块激活并能操作;Show 不会 toggle 关闭已经可见的窗口。Maven renderer 在非 Maven 项目不可见时,旧布局中的可见标记不占据停靠内容空间。prepareForSleep 挂起时并发 activate 等待休眠完成,只在新实例中重新激活,贡献不重复发布、旧实例资源全部释放。 关闭模块在 gate 暂停时取消等待激活的调用方,放行关闭后构造与贡献发布仍各一次,无实例及资源残留;首次点击和 Show 打开 A 时暂停激活,同组选择 B 后放行 A,最终仍显示 B,不同组可以同时显示;默认右侧 Database → Agent → Maven 中将 Maven 前移,再后移,检查实际 tools(in:) 顺序随之变化。 开启/替换/关闭工作台背景时,检查Debug Console正文、左侧工具栏及Performance展开/收起区均露出同一工作台背景;移除背景恢复主题底色,保留ANSI背景、选区、输入草稿和原生文本控件身份。 所有主工具窗口隐藏按钮共用整块面板悬停环境;鼠标离开时隐藏且不可鼠标命中,保留槽位与键盘/VoiceOver 焦点显示;分别检查普通宿主与 Terminal/Git 保留宿主,Project 正文悬停也显示定位、刷新和展开/收起动作。 新工作区检查侧栏默认 360pt 及底部约28%高度;中英文明暗 Commit 原生布局验证修订提交一行且不与 AI/暂存数重叠;保存320pt侧栏和自定义上半高度后重开仍保持原值,极窄布局、底部隐藏恢复与拖动不回跳。资源截图不替代完整工作台验收。 共享分隔容器在横向/纵向、面板为空或含多个直接子视图时,仍保留两块独立面板及分隔条;搜索的更多提示不改变面板数量。核对尺寸限制、收起恢复、嵌套拖动和原生表面身份不变。", "notes": " 工作台共享表面向后代传递背景图片状态,原生输出视图使用同一状态控制滚动视图底色,防止局部不透明背景遮挡;同样覆盖既有共享输出调用者。" }