diff --git a/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md b/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md
index 966c0ab29..b8621be71 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-macos-monaco-feasibility-probe.md
@@ -430,6 +430,29 @@ Windows 单文件工作区 Diff 还要能展开省略的未改动代码(#557
才开启 Monaco 的未改动区域折叠。多文件、历史和提交比较仍用稀疏补丁且不折叠,
因为它们缺少的行同样无法展示。
+Windows 工作区差异还能按块回滚(#688),与 macOS 的“Discard this change block”
+对齐。回滚按钮和暂存按钮放在同一个分块操作区,由共享渲染器的多 action 机制
+渲染,没有改动共享渲染器;分隔带上的箭头需要在两侧编辑器之间额外绘制并跟随
+Monaco 的对齐与折叠,收益不足以承担 macOS 对照入口的回归风险,暂不复刻。
+按钮回传的仍是原始分块身份,Windows 适配器取出同一份补丁,通过
+`git_discard_hunk` 翻译为 `git.apply` 的 `discard` 模式(`git apply --reverse`,
+只写工作区)。回滚只在满足以下条件时出现:未暂存的单文件 review、有工作区刷新
+目标、文件不是新增/删除/重命名/二进制,且 Git 状态里该文件没有已暂存改动。
+未暂存单文件 review 用的是 HEAD 到工作区的快照,若文件同时有暂存改动,
+补丁会包含暂存块,反向应用到工作区会失败或误删,所以这时只保留暂存按钮;
+刷新时按最新状态重新判断。多文件 review 没有刷新目标,回滚后旧补丁会留在
+屏幕上,因此也不提供。点击后先走现有“丢弃前确认”设置(`confirmBeforeDiscard`)
+的确认框;确认框打开期间同一份补丁拒绝其他操作,补丁在等待期间被刷新替换时,
+用户的回答直接作废。成功后照常等待 Git 变更事件刷新,最后一块回滚、文件
+不再出现在状态中时 review 关闭。Core 用真实仓库验证重建补丁(含完整上下文
+切出的分块)只恢复所选块、重复点击的旧补丁不写入、暂存区保持不变。
+
+这是受限的首版能力,不能视为所有工作区 Diff 都支持块级回滚。现有前端补丁解析
+会丢失行尾 `\r` 和文件末尾无换行标记:`core.autocrlf=false` 的 CRLF 文件,
+以及涉及末尾无换行的修改块,重建补丁可能被 Git 拒绝。拒绝时文件不变,用户只能
+退回文件级回滚;后续完善应保留原始补丁信息并覆盖暂存和回滚两条路径,不能通过
+忽略补丁错误或直接按显示行号改写文件来绕过校验。
+
整份文件只有一个 `@@`,直接拿来暂存会把整个文件一次写进索引。因此 Windows
适配器按 Git 默认的 3 行上下文重新切出分块,自己生成 `@@` 范围;单测用同一次
修改的真实 `git diff` 输出逐块比对。完整上下文没有 `@@` 行,渲染器改用行上的
diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv
index c298455e0..1a5bd70e4 100644
--- a/docs/development/platform-parity-matrix.csv
+++ b/docs/development/platform-parity-matrix.csv
@@ -32,7 +32,7 @@ search-replace,搜索,搜索与导航,项目替换与取消,已实现,待验证,
git-status-commit,版本控制,Git,状态、暂存与提交,已实现,待验证,已实现,待验证,Git,修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Services,windows/tauri/src/features/git; shared/contracts/application-boundary.md; windows/tauri/src/features/git/api/git-repository-path.ts; rust/lithe-core/tests/git_path_roundtrip.rs; shared/fixtures/git/windows-paths.json
git-multi-repository-change-groups,版本控制,Git,多仓库变更折叠分组,已实现,待验证,已实现,待验证,Git,在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift; macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift; macos/Sources/LitheGitModule/Models/GitModels.swift; rust/lithe-core/src/git/mod.rs,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json
git-branches-remotes,版本控制,Git,分支、标签与远程,已实现,待验证,已实现,待验证,Git,创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git
-git-diff-review,版本控制,Git,Diff 与变更审查,已实现,待验证,已实现,待验证,Git,验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Views/Diff,windows/tauri/src/features/git; windows/tauri/src/features/viewer
+git-diff-review,版本控制,Git,Diff 与变更审查,已实现,待验证,已实现,待验证,Git,验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,确认弹出确认框、取消不改文件、确认后只恢复该块且暂存区不变,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Views/Diff,windows/tauri/src/features/git; windows/tauri/src/features/viewer
git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts; windows/tauri/src/features/git/components/log/git-commit-details.tsx; windows/tauri/src/features/git/components/log/git-commit-details.test.tsx; windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx; windows/tauri/src/features/git/hooks/use-commit-file-preview.ts; windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx
git-rebase-stash,版本控制,Git,Rebase 与 Stash,已实现,待验证,已实现,待验证,Git,执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git
git-worktrees,版本控制,Git,Worktree 管理,已实现,待验证,已实现,待验证,Git,列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git
diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md
index a40c0602f..3533caaa1 100644
--- a/docs/development/platform-parity-matrix.md
+++ b/docs/development/platform-parity-matrix.md
@@ -115,7 +115,7 @@
| Git | **状态、暂存与提交**
git-status-commit | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`shared/contracts/application-boundary.md`、`windows/tauri/src/features/git/api/git-repository-path.ts`、`rust/lithe-core/tests/git_path_roundtrip.rs`、`shared/fixtures/git/windows-paths.json` | Git | 修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。 | |
| Git | **多仓库变更折叠分组**
git-multi-repository-change-groups | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift`、`macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift`、`macos/Sources/LitheGitModule/Models/GitModels.swift`、`rust/lithe-core/src/git/mod.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/components/status/git-status-panel.tsx`、`windows/tauri/src/features/git/components/git-commit-panel.tsx`、`windows/tauri/src/features/git/components/git-workspace-commit-review.tsx`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts`、`windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx`、`windows/tauri/src-tauri/src/platform.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | Git | 在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 |
| Git | **分支、标签与远程**
git-branches-remotes | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 | |
-| Git | **Diff 与变更审查**
git-diff-review | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Views/Diff` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/viewer` | Git | 验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存。 | |
+| Git | **Diff 与变更审查**
git-diff-review | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Views/Diff` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/viewer` | Git | 验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,确认弹出确认框、取消不改文件、确认后只恢复该块且暂存区不变,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。 | |
| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts`、`windows/tauri/src/features/git/components/log/git-commit-details.tsx`、`windows/tauri/src/features/git/components/log/git-commit-details.test.tsx`、`windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.ts`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 | |
| Git | **Rebase 与 Stash**
git-rebase-stash | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。 | |
| Git | **Worktree 管理**
git-worktrees | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 | |
diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs
index 96be997bd..3197e9c1b 100644
--- a/rust/lithe-core/src/tests/git.rs
+++ b/rust/lithe-core/src/tests/git.rs
@@ -3055,6 +3055,119 @@ fn git_snapshot_diff_returns_the_whole_file_for_maximum_context() {
fs::remove_dir_all(root).expect("temporary workspace should be removable");
}
+/// Windows diff reviews roll back one block by reverse-applying a patch the
+/// host rebuilds from parsed lines (#688). Full-context reviews re-derive
+/// blocks with three context lines and their own `@@` ranges, so the rebuilt
+/// patch must still apply, restore only its block, and leave the index alone.
+#[test]
+fn git_apply_discard_reverts_one_rebuilt_hunk_and_keeps_the_index() {
+ struct RemoveOnDrop(std::path::PathBuf);
+ impl Drop for RemoveOnDrop {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.0);
+ }
+ }
+
+ let root = temporary_root("git-discard-hunk");
+ let _cleanup = RemoveOnDrop(root.clone());
+ fs::create_dir_all(&root).expect("temporary workspace should be creatable");
+ // Use the existing process adapter's deadline and process-tree cleanup;
+ // fixture setup must not hang on user hooks or commit signing prompts.
+ let run = |arguments: &[&str]| -> String {
+ let mut isolated_arguments = vec![
+ "-c",
+ "core.hooksPath=disabled-fixture-hooks",
+ "-c",
+ "commit.gpgSign=false",
+ ];
+ isolated_arguments.extend_from_slice(arguments);
+ let response: Value = serde_json::from_str(&execute_json(
+ &serde_json::json!({
+ "id": "discard-hunk-setup",
+ "timeoutMilliseconds": 5_000,
+ "command": "git.command",
+ "payload": { "root": root, "arguments": isolated_arguments }
+ })
+ .to_string(),
+ ))
+ .expect("setup response should be JSON");
+ assert_eq!(response["ok"], true, "{response}");
+ assert_eq!(response["data"]["exitCode"], 0, "{response}");
+ response["data"]["stdout"]
+ .as_str()
+ .expect("stdout")
+ .to_string()
+ };
+ run(&["init", "-q"]);
+ run(&["config", "core.autocrlf", "false"]);
+ run(&["config", "user.email", "test@example.invalid"]);
+ run(&["config", "user.name", "Lithe Test"]);
+ let original = (1..=40)
+ .map(|line| format!("line {line}\n"))
+ .collect::();
+ fs::write(root.join("long.txt"), &original).expect("file should be writable");
+ fs::write(root.join("mixed.txt"), "one\ntwo\nthree\n").expect("file should be writable");
+ run(&["add", "."]);
+ run(&["commit", "-qm", "initial"]);
+
+ let edited = original
+ .replace("line 5\n", "line five\n")
+ .replace("line 30\n", "line thirty\n");
+ fs::write(root.join("long.txt"), &edited).expect("file should be writable");
+ // Block shape the Windows adapter derives from a full-context review: the
+ // change at line 30 with three lines of context and a synthesized header.
+ let discard = |patch: &str| -> Value {
+ let request = serde_json::json!({
+ "id": "discard-hunk",
+ "timeoutMilliseconds": 5_000,
+ "command": "git.apply",
+ "payload": { "root": root, "patch": patch, "mode": "discard" }
+ });
+ serde_json::from_str(&execute_json(
+ &serde_json::to_string(&request).expect("apply request should encode"),
+ ))
+ .expect("apply response should be JSON")
+ };
+ let response = discard(
+ "diff --git a/long.txt b/long.txt\n--- a/long.txt\n+++ b/long.txt\n\
+ @@ -27,7 +27,7 @@\n line 27\n line 28\n line 29\n-line 30\n+line thirty\n line 31\n line 32\n line 33\n",
+ );
+ assert_eq!(response["ok"], true, "{response}");
+ assert_eq!(response["data"]["exitCode"], 0, "{response}");
+ assert_eq!(
+ fs::read_to_string(root.join("long.txt")).expect("file should be readable"),
+ original.replace("line 5\n", "line five\n"),
+ "only the selected block may be restored"
+ );
+
+ // A second click with the same, now stale block must fail without writing.
+ let stale = discard(
+ "diff --git a/long.txt b/long.txt\n--- a/long.txt\n+++ b/long.txt\n\
+ @@ -27,7 +27,7 @@\n line 27\n line 28\n line 29\n-line 30\n+line thirty\n line 31\n line 32\n line 33\n",
+ );
+ assert_ne!(stale["data"]["exitCode"], 0, "{stale}");
+ assert_eq!(
+ fs::read_to_string(root.join("long.txt")).expect("file should be readable"),
+ original.replace("line 5\n", "line five\n")
+ );
+
+ // Index-to-worktree blocks of a file with staged edits leave the index intact.
+ fs::write(root.join("mixed.txt"), "ONE\ntwo\nthree\n").expect("file should be writable");
+ run(&["add", "mixed.txt"]);
+ fs::write(root.join("mixed.txt"), "ONE\ntwo\nTHREE\n").expect("file should be writable");
+ let response = discard(
+ "diff --git a/mixed.txt b/mixed.txt\n--- a/mixed.txt\n+++ b/mixed.txt\n\
+ @@ -1,3 +1,3 @@\n ONE\n two\n-three\n+THREE\n",
+ );
+ assert_eq!(response["data"]["exitCode"], 0, "{response}");
+ assert_eq!(
+ fs::read_to_string(root.join("mixed.txt")).expect("file should be readable"),
+ "ONE\ntwo\nthree\n"
+ );
+ let status = run(&["status", "--porcelain", "--", "mixed.txt"]);
+ assert_eq!(status, "M mixed.txt\n");
+}
+
#[test]
fn git_diff_resolves_the_empty_tree_for_a_sha256_repository() {
let root = temporary_root("git-diff-sha256-empty-tree");
diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json
index 0ff095ba2..ef781746f 100644
--- a/shared/platform-feature-matrix.json
+++ b/shared/platform-feature-matrix.json
@@ -925,7 +925,7 @@
"verificationStatus": "pending"
},
"owner": "Git",
- "verification": "验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存。"
+ "verification": "验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存;在 Windows 未暂存单文件 Diff 中点击某个修改块的“回滚”,确认弹出确认框、取消不改文件、确认后只恢复该块且暂存区不变,最后一块回滚后文件从状态中消失时 Diff 关闭;暂存区、历史、分支比较与新增/未跟踪文件不显示回滚按钮。"
},
{
"id": "git-history",
diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs
index 6535b8f99..b8b4004ae 100644
--- a/windows/tauri/src-tauri/src/platform.rs
+++ b/windows/tauri/src-tauri/src/platform.rs
@@ -627,17 +627,19 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> {
payload.insert("arguments".into(), json!(["reset", "HEAD"]));
"git.command"
}
- "git_stage_hunk" | "git_unstage_hunk" => {
+ "git_stage_hunk" | "git_unstage_hunk" | "git_discard_hunk" => {
let hunk = payload
.remove("hunk")
.ok_or_else(|| "Hunk payload is required".to_string())?;
payload.insert("patch".into(), json!(hunk_patch(&hunk)?));
payload.insert(
"mode".into(),
- json!(if command == "git_stage_hunk" {
- "stage"
- } else {
- "unstage"
+ // `discard` reverse-applies the worktree hunk without touching
+ // the index, so staged content for the file survives.
+ json!(match command {
+ "git_stage_hunk" => "stage",
+ "git_unstage_hunk" => "unstage",
+ _ => "discard",
}),
);
"git.apply"
@@ -1600,4 +1602,40 @@ mod tests {
"diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\n"
);
}
+
+ #[test]
+ fn translates_hunk_discard_to_reverse_worktree_apply() {
+ let hunk = json!({
+ "file_path": "src/main.rs",
+ "lines": [
+ { "line_type": "header", "content": "@@ -1,2 +1,2 @@" },
+ { "line_type": "context", "content": "keep" },
+ { "line_type": "removed", "content": "old" },
+ { "line_type": "added", "content": "new" }
+ ]
+ });
+ let (command, payload) = translate(
+ "git_discard_hunk",
+ json!({ "repoPath": "C:/work", "hunk": hunk.clone() }),
+ )
+ .unwrap();
+
+ assert_eq!(command, "git.apply");
+ assert_eq!(
+ payload,
+ json!({
+ "root": "C:/work",
+ "mode": "discard",
+ "patch": "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,2 +1,2 @@\n keep\n-old\n+new\n"
+ })
+ );
+
+ // Stage and unstage keep their modes when the discard arm is shared.
+ for (name, mode) in [("git_stage_hunk", "stage"), ("git_unstage_hunk", "unstage")] {
+ let (_, payload) =
+ translate(name, json!({ "repoPath": "C:/work", "hunk": hunk.clone() })).unwrap();
+ assert_eq!(payload["mode"], mode);
+ }
+ assert!(translate("git_discard_hunk", json!({ "repoPath": "C:/work" })).is_err());
+ }
}
diff --git a/windows/tauri/src/features/git/api/git-status-api.test.ts b/windows/tauri/src/features/git/api/git-status-api.test.ts
index 81b28ca8e..a01a489db 100644
--- a/windows/tauri/src/features/git/api/git-status-api.test.ts
+++ b/windows/tauri/src/features/git/api/git-status-api.test.ts
@@ -41,6 +41,7 @@ let invokeSpy: ReturnType>;
const {
addPathsToGitignore,
addPathsToLocalGitExclude,
+ discardHunk,
rollbackFilesChanges,
setFilesStaged,
getWorkspaceGitStatus,
@@ -48,6 +49,7 @@ const {
getGitStatus,
} = await import("./git-status-api");
const { getFullContextFileDiff, getWorkingTreePathDiff } = await import("./git-diff-api");
+const gitEvents = await import("../events/git-events");
beforeEach(() => {
invokeSpy = spyOn(tauriCore, "invoke").mockImplementation(invoke as typeof tauriCore.invoke);
@@ -103,6 +105,44 @@ describe("Git status batch mutations", () => {
});
});
+ test("discards one hunk in its owning repository and announces the file change", async () => {
+ const hunk = {
+ file_path: "src/App.tsx",
+ lines: [
+ { line_type: "header" as const, content: "@@ -1 +1 @@" },
+ { line_type: "removed" as const, content: "old", old_line_number: 1 },
+ { line_type: "added" as const, content: "new", new_line_number: 1 },
+ ],
+ };
+ const emitted = spyOn(gitEvents, "emitGitChanged");
+ const recorded = () => emitted.mock.calls.map(([change]) => change);
+ try {
+ await expect(discardHunk("C:/workspace/service-a", hunk)).resolves.toBe(true);
+ expect(invoke).toHaveBeenLastCalledWith("git_discard_hunk", {
+ repoPath: "C:/workspace/service-a",
+ hunk,
+ });
+ expect(recorded()).toEqual([{
+ repoPath: "C:/workspace/service-a",
+ filePath: "src/App.tsx",
+ scopes: ["working-tree"],
+ source: "discard-hunk",
+ }]);
+
+ // A rejected patch reports failure without announcing a change. Route by
+ // command: repository discovery may already be cached in random order.
+ invokeSpy.mockImplementation((async (command: string, args?: Record) => {
+ if (command === "git_discard_hunk") throw new Error("patch does not apply");
+ return invoke(command, args);
+ }) as typeof tauriCore.invoke);
+ const error = spyOn(console, "error").mockImplementation(() => {});
+ try {
+ await expect(discardHunk("C:/repo", hunk)).resolves.toBe(false);
+ } finally { error.mockRestore(); }
+ expect(recorded()).toHaveLength(1);
+ } finally { emitted.mockRestore(); }
+ });
+
test("adds selected paths to the repository gitignore", async () => {
await expect(addPathsToGitignore("C:/repo", ["generated/", "local.env"])).resolves.toBe(true);
diff --git a/windows/tauri/src/features/git/api/git-status-api.ts b/windows/tauri/src/features/git/api/git-status-api.ts
index 2ca4cb5d8..b8c271b35 100644
--- a/windows/tauri/src/features/git/api/git-status-api.ts
+++ b/windows/tauri/src/features/git/api/git-status-api.ts
@@ -299,6 +299,28 @@ export const unstageHunk = async (repoPath: string, hunk: GitHunk): Promise => {
+ try {
+ const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath);
+ await tauriInvoke("git_discard_hunk", { repoPath: resolvedRepoPath, hunk });
+ emitGitChanged({
+ repoPath: resolvedRepoPath,
+ filePath: hunk.file_path,
+ scopes: ["working-tree"],
+ source: "discard-hunk",
+ });
+ return true;
+ } catch (error) {
+ console.error("Failed to discard hunk:", error);
+ return false;
+ }
+};
+
export const discardAllChanges = async (repoPath: string): Promise => {
try {
const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath);
diff --git a/windows/tauri/src/features/git/components/diff/monaco-git-diff.tsx b/windows/tauri/src/features/git/components/diff/monaco-git-diff.tsx
index a5b3f4457..8dec4fb1b 100644
--- a/windows/tauri/src/features/git/components/diff/monaco-git-diff.tsx
+++ b/windows/tauri/src/features/git/components/diff/monaco-git-diff.tsx
@@ -12,7 +12,9 @@ import { detectLanguageFromPath } from "@/features/editor/utils/language-detecti
import { useFileSystemStore } from "@/features/file-system/stores/file-system.store";
import { useTranslation } from "@/i18n/locale-provider";
import { joinPath } from "@/utils/path-helpers";
-import { stageHunk, unstageHunk } from "../../api/git-status-api";
+import { useSettingsStore } from "@/features/settings/stores/settings.store";
+import { showConfirmDialog } from "@/ui/dialog";
+import { discardHunk, stageHunk, unstageHunk } from "../../api/git-status-api";
import { createMonacoDiffHunkActions, type DiffStagingContext } from "../../utils/monaco-diff-hunk-actions";
import { monacoDiffRows } from "../../utils/monaco-diff-rows";
import type { GitDiff } from "../../types/git.types";
@@ -47,7 +49,13 @@ export default function MonacoGitDiff({ diff, viewMode = "split", showWhitespace
const updating = useRef(false);
const repoPath = staging?.repoPath;
const isStaged = staging?.isStaged ?? false;
+ const canDiscard = staging?.canDiscard === true;
const actionTitle = isStaged ? t("git.diff.unstage") : t("git.diff.stage");
+ const discardTitle = t("git.rollback");
+ // The confirmation runs from an owner created per patch; read the current
+ // translator instead of rebuilding the owner when the language changes.
+ const translate = useRef(t);
+ translate.current = t;
const { fontSize, fontFamily, lineHeight, tabSize, themeId, editorItalicComments, editorFontLigatures } = useMonacoEditorSettings();
useEffect(() => {
@@ -100,30 +108,38 @@ export default function MonacoGitDiff({ diff, viewMode = "split", showWhitespace
}, []);
useEffect(() => {
- const owner = createMonacoDiffHunkActions(diff, repoPath ? { repoPath, isStaged } : undefined,
- { stage: stageHunk, unstage: unstageHunk });
+ const owner = createMonacoDiffHunkActions(diff, repoPath ? { repoPath, isStaged, canDiscard } : undefined,
+ { stage: stageHunk, unstage: unstageHunk, discard: discardHunk,
+ confirmDiscard: async hunk => {
+ // Reuse the product-wide "confirm before discard" preference that
+ // already guards file and repository rollbacks.
+ if (!useSettingsStore.getState().settings.confirmBeforeDiscard) return true;
+ const t = translate.current;
+ return showConfirmDialog(t("git.diff.rollbackHunkConfirm", { path: hunk.file_path }), {
+ title: t("git.diff.rollbackHunk"), confirmLabel: t("git.rollback") });
+ } });
hunkActions.current = owner;
setActionFailed(false);
return () => {
owner.dispose();
if (hunkActions.current === owner) hunkActions.current = null;
};
- }, [diff, repoPath, isStaged]);
+ }, [diff, repoPath, isStaged, canDiscard]);
useEffect(() => {
let cancelled = false;
setError(undefined);
updating.current = true;
- const action = hunkActions.current?.action;
+ const titles = { stage: actionTitle, unstage: actionTitle, discard: discardTitle };
+ const actions = (hunkActions.current?.actions ?? []).map(id => ({ id, title: titles[id] }));
// Only a full-file patch may fold: Monaco's fold bands reveal hidden lines
// in place, which is meaningless for a sparse patch whose gaps are absent.
void review.current!.update({ rows, language: toMonacoLanguageId(detectLanguageFromPath(sourcePath)),
- sideBySide: viewMode === "split", collapse: fullContext, overview: !embedded,
- actions: action ? [{ id: action, title: actionTitle }] : [] })
+ sideBySide: viewMode === "split", collapse: fullContext, overview: !embedded, actions })
.then(() => { if (!cancelled) { latest.current = { rows, sourcePath }; updating.current = false; } })
.catch(error => { if (!cancelled) setError(String(error)); });
return () => { cancelled = true; };
- }, [rows, sourcePath, fullContext, viewMode, embedded, repoPath, isStaged, actionTitle]);
+ }, [rows, sourcePath, fullContext, viewMode, embedded, repoPath, isStaged, canDiscard, actionTitle, discardTitle]);
useEffect(() => {
review.current?.select({ matches: searchMatches.map(match => ({ rowID: `line-${match.lineIndex}`,
diff --git a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts
index 68ff30824..afbaea607 100644
--- a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts
+++ b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts
@@ -160,6 +160,7 @@ export function useGitDiffActions({
...(originalRelativePath ? { originalPath: originalRelativePath } : {}),
untracked,
...(staged ? { staged: true } : {}),
+ ...(!staged && file.staged ? { hasStagedChanges: true } : {}),
},
};
const loadingDiff: MultiFileDiff = {
diff --git a/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts b/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts
index 56af5ef96..029a612d6 100644
--- a/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts
+++ b/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts
@@ -196,6 +196,34 @@ describe("refreshWorkingTreeFileDiff", () => {
expect(diffLoads).toBe(0);
});
+ test("closes after the last block is rolled back and the file leaves Git status", async () => {
+ // #688: rolling back the only remaining block makes the file clean. The
+ // review must close rather than keep a patch whose blocks no longer exist.
+ const buffers = bufferPort(openedDiff());
+ const outcome = await refreshWorkingTreeFileDiff(
+ { bufferId: BUFFER_ID, fileKey: FILE_KEY },
+ { buffers: buffers.port, loadStatus: async () => status([]), loadDiff: async () => diffWithLines(0) },
+ );
+ expect(outcome).toBe("closed");
+ expect(buffers.closed).toEqual([BUFFER_ID]);
+ });
+
+ test("records staged edits so an unstaged snapshot stops offering rollback", async () => {
+ // Staging one block of a worktree-only file turns it into MM; the
+ // HEAD-to-worktree snapshot then also shows the staged block.
+ const buffers = bufferPort(openedDiff());
+ const outcome = await refreshWorkingTreeFileDiff(
+ { bufferId: BUFFER_ID, fileKey: FILE_KEY },
+ {
+ buffers: buffers.port,
+ loadStatus: async () => status([statusFile({ staged: true })]),
+ loadDiff: async () => diffWithLines(2),
+ },
+ );
+ expect(outcome).toBe("updated");
+ expect(buffers.current()?.workingTreeTargets?.[FILE_KEY]).toEqual({ ...TARGET, hasStagedChanges: true });
+ });
+
test("keeps current content when status or diff reads fail", async () => {
const opened = openedDiff();
const buffers = bufferPort(opened);
diff --git a/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts b/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts
index 88ba363b3..fcdaf7929 100644
--- a/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts
+++ b/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts
@@ -93,6 +93,9 @@ export async function refreshWorkingTreeFileDiff(
...(originalPath ? { originalPath } : {}),
untracked: statusFile.status === "untracked",
...(target.staged ? { staged: true } : {}),
+ // Staging a block from an unstaged review turns the file into MM; the
+ // review keeps its key, so discard eligibility must follow live status.
+ ...(!target.staged && statusFile.staged ? { hasStagedChanges: true } : {}),
};
const diff = await loadDiff(
nextTarget.repoPath,
diff --git a/windows/tauri/src/features/git/types/git-diff.types.ts b/windows/tauri/src/features/git/types/git-diff.types.ts
index d17aa1486..8451665c5 100644
--- a/windows/tauri/src/features/git/types/git-diff.types.ts
+++ b/windows/tauri/src/features/git/types/git-diff.types.ts
@@ -111,6 +111,12 @@ export interface WorkingTreeDiffTarget {
/** Repository-relative source path for renames. */
originalPath?: string;
untracked: boolean;
+ /**
+ * The index differs from HEAD for this path. Unstaged reviews are
+ * HEAD-to-worktree snapshots, so their blocks then include staged edits and
+ * must not be reverse-applied to the worktree.
+ */
+ hasStagedChanges?: boolean;
}
export interface MultiFileDiff {
diff --git a/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.test.ts b/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.test.ts
index 1ddf4ef85..958bcec40 100644
--- a/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.test.ts
+++ b/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.test.ts
@@ -23,6 +23,14 @@ function operations() {
unstage: mock(async (_repo: string, _hunk: GitHunk) => true),
};
}
+const discardContext = { ...context, canDiscard: true };
+function rollbackOperations() {
+ return {
+ ...operations(),
+ discard: mock(async (_repo: string, _hunk: GitHunk) => true),
+ confirmDiscard: mock(async (_hunk: GitHunk) => true),
+ };
+}
function deferred() {
let resolve!: (value: boolean) => void;
const promise = new Promise(accept => { resolve = accept; });
@@ -115,6 +123,123 @@ describe("Windows Monaco diff hunk actions", () => {
} finally { pending.resolve(false); current.dispose(); }
});
+ test("offers rollback only for refreshable unstaged reviews of worktree-only edits", () => {
+ const target = { repoPath: context.repoPath, filePath: "src/Sample.java", untracked: false };
+ const review = (targets: Record) =>
+ ({ commitHash: "working-tree", repoPath: context.repoPath, workingTreeTargets: targets });
+ expect(workingTreeStagingContext(review({ "unstaged:src/Sample.java": target }), "unstaged:src/Sample.java"))
+ .toEqual({ ...context, canDiscard: true });
+ for (const [targets, key] of [
+ // Multi-file reviews have no refresh target and would keep a stale patch.
+ [{}, "unstaged:src/Sample.java"],
+ [{ "staged:src/Sample.java": { ...target, staged: true } }, "staged:src/Sample.java"],
+ [{ "unstaged:src/Sample.java": { ...target, untracked: true } }, "unstaged:src/Sample.java"],
+ // A HEAD-to-worktree snapshot of an MM file also contains staged edits.
+ [{ "unstaged:src/Sample.java": { ...target, hasStagedChanges: true } }, "unstaged:src/Sample.java"],
+ ] as const) {
+ expect(workingTreeStagingContext(review(targets), key)?.canDiscard).toBeUndefined();
+ }
+ });
+
+ test("routes a confirmed rollback to the original hunk and owning repository", async () => {
+ const api = rollbackOperations();
+ const owner = createMonacoDiffHunkActions(diff, discardContext, api);
+ expect(owner.actions).toEqual(["stage", "discard"]);
+ expect(await owner.apply("hunk-4", "discard")).toBe("applied");
+ const hunk = { file_path: "src/Sample.java", lines: diff.lines.slice(4) };
+ expect(api.confirmDiscard).toHaveBeenCalledWith(hunk);
+ expect(api.discard).toHaveBeenCalledWith(context.repoPath, hunk);
+ expect(api.stage).not.toHaveBeenCalled();
+ // The patch is stale until the Git change event replaces it.
+ expect(await owner.apply("hunk-0", "discard")).toBe("ignored");
+ expect(await owner.apply("hunk-0", "stage")).toBe("ignored");
+ expect(api.discard).toHaveBeenCalledTimes(1);
+ owner.dispose();
+ });
+
+ test("rolls back a block re-derived from a full-context patch, not the whole file", async () => {
+ const full: GitDiff = {
+ ...diff,
+ is_full_context: true,
+ lines: [
+ { line_type: "header", content: "@@ -1,12 +1,12 @@" },
+ ...Array.from({ length: 12 }, (_, index) => index === 9
+ ? [{ line_type: "removed" as const, content: "old 10", old_line_number: 10 },
+ { line_type: "added" as const, content: "new 10", new_line_number: 10 }]
+ : [{ line_type: "context" as const, content: `line ${index + 1}`,
+ old_line_number: index + 1, new_line_number: index + 1 }]).flat(),
+ ],
+ };
+ const api = rollbackOperations();
+ const owner = createMonacoDiffHunkActions(full, discardContext, api);
+ const anchor = monacoDiffRows(full).find(row => row.actionAnchor)!;
+ expect(await owner.apply(anchor.hunkID!, "discard")).toBe("applied");
+ expect(api.discard).toHaveBeenCalledWith(context.repoPath, {
+ file_path: "src/Sample.java",
+ lines: [{ line_type: "header", content: "@@ -7,6 +7,6 @@" }, ...full.lines.slice(7, 14)],
+ });
+ owner.dispose();
+ });
+
+ test("a cancelled confirmation writes nothing and leaves the block actionable", async () => {
+ const api = rollbackOperations();
+ api.confirmDiscard.mockResolvedValueOnce(false);
+ const owner = createMonacoDiffHunkActions(diff, discardContext, api);
+ expect(await owner.apply("hunk-0", "discard")).toBe("cancelled");
+ expect(api.discard).not.toHaveBeenCalled();
+ expect(await owner.apply("hunk-0", "discard")).toBe("applied");
+ expect(api.discard).toHaveBeenCalledTimes(1);
+ owner.dispose();
+ });
+
+ test("rejects clicks while the confirmation is open and ignores answers for a replaced patch", async () => {
+ const api = rollbackOperations();
+ const answer = deferred();
+ api.confirmDiscard.mockImplementationOnce(() => answer.promise);
+ const owner = createMonacoDiffHunkActions(diff, discardContext, api);
+ const first = owner.apply("hunk-0", "discard");
+ try {
+ expect(await owner.apply("hunk-4", "discard")).toBe("ignored");
+ expect(await owner.apply("hunk-4", "stage")).toBe("ignored");
+ owner.dispose();
+ answer.resolve(true);
+ expect(await first).toBe("ignored");
+ expect(api.discard).not.toHaveBeenCalled();
+ expect(api.stage).not.toHaveBeenCalled();
+ } finally { answer.resolve(false); owner.dispose(); }
+ });
+
+ test("a failed rollback can be retried", async () => {
+ const api = rollbackOperations();
+ api.discard.mockResolvedValueOnce(false).mockRejectedValueOnce(new Error("patch does not apply"));
+ const owner = createMonacoDiffHunkActions(diff, discardContext, api);
+ expect(await owner.apply("hunk-0", "discard")).toBe("failed");
+ expect(await owner.apply("hunk-0", "discard")).toBe("failed");
+ expect(await owner.apply("hunk-0", "discard")).toBe("applied");
+ expect(api.discard).toHaveBeenCalledTimes(3);
+ owner.dispose();
+ });
+
+ test("withholds rollback from staged, truncated, new, deleted and binary reviews", async () => {
+ const api = rollbackOperations();
+ for (const disabled of [
+ createMonacoDiffHunkActions(diff, { ...discardContext, isStaged: true }, api),
+ createMonacoDiffHunkActions(diff, context, api),
+ createMonacoDiffHunkActions(diff, discardContext, { stage: api.stage, unstage: api.unstage }),
+ createMonacoDiffHunkActions({ ...diff, is_truncated: true }, discardContext, api),
+ createMonacoDiffHunkActions({ ...diff, is_new: true }, discardContext, api),
+ createMonacoDiffHunkActions({ ...diff, is_deleted: true }, discardContext, api),
+ createMonacoDiffHunkActions({ ...diff, is_renamed: true }, discardContext, api),
+ createMonacoDiffHunkActions({ ...diff, is_binary: true }, discardContext, api),
+ ]) {
+ expect(disabled.actions).not.toContain("discard");
+ expect(await disabled.apply("hunk-0", "discard")).toBe("ignored");
+ disabled.dispose();
+ }
+ expect(api.confirmDiscard).not.toHaveBeenCalled();
+ expect(api.discard).not.toHaveBeenCalled();
+ });
+
test("rejects unknown identities, wrong actions, incomplete patches and read-only reviews", async () => {
const api = operations();
const owner = createMonacoDiffHunkActions(diff, context, api);
diff --git a/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.ts b/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.ts
index 7cfa4fb54..eb9dd8191 100644
--- a/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.ts
+++ b/windows/tauri/src/features/git/utils/monaco-diff-hunk-actions.ts
@@ -5,41 +5,87 @@ import { monacoDiffHunk } from "./monaco-diff-rows";
export interface DiffStagingContext {
repoPath: string;
isStaged: boolean;
+ /**
+ * The review reloads itself after a Git change, so a discarded block
+ * disappears instead of leaving a stale patch on screen. Only unstaged
+ * reviews with a working-tree refresh target offer discard.
+ */
+ canDiscard?: boolean;
}
export function workingTreeStagingContext(
- review: Pick,
+ review: Pick,
sectionKey: string,
): DiffStagingContext | undefined {
if (review.commitHash !== "working-tree" || !review.repoPath) return;
if (!sectionKey.startsWith("staged:") && !sectionKey.startsWith("unstaged:")) return;
- return { repoPath: review.repoPath, isStaged: sectionKey.startsWith("staged:") };
+ const isStaged = sectionKey.startsWith("staged:");
+ const target = review.workingTreeTargets?.[sectionKey];
+ const canDiscard = !isStaged && !!target && !target.staged && !target.untracked
+ && !target.hasStagedChanges;
+ return { repoPath: review.repoPath, isStaged, ...(canDiscard ? { canDiscard: true } : {}) };
}
type HunkOperation = (repoPath: string, hunk: GitHunk) => Promise;
-type ActionResult = "applied" | "failed" | "ignored";
+export type HunkActionID = "stage" | "unstage" | "discard";
+type ActionResult = "applied" | "failed" | "cancelled" | "ignored";
+
+export interface MonacoDiffHunkOperations {
+ stage: HunkOperation;
+ unstage: HunkOperation;
+ /** Reverse-applies the block to the working tree. */
+ discard?: HunkOperation;
+ /** Asks the user before a destructive write; false cancels without writing. */
+ confirmDiscard?: (hunk: GitHunk) => Promise;
+}
+
+/** Discard rewrites working-tree content. New and deleted files have no
+ * block-level inverse: reversing the only hunk of an untracked file leaves an
+ * empty file behind instead of removing it. Renames come from the index and
+ * are never part of an unstaged review; they are excluded defensively. */
+function canDiscardDiff(diff: GitDiff): boolean {
+ return !diff.is_new && !diff.is_deleted && !diff.is_renamed && !diff.is_binary && !diff.is_image;
+}
/** One immutable host patch owns its actions until the next diff refresh. */
export function createMonacoDiffHunkActions(
diff: GitDiff,
context: DiffStagingContext | undefined,
- operations: { stage: HunkOperation; unstage: HunkOperation },
+ operations: MonacoDiffHunkOperations,
) {
- const action = context?.repoPath && !diff.is_truncated
+ const writable = Boolean(context?.repoPath) && !diff.is_truncated;
+ const action: "stage" | "unstage" | null = writable && context
? context.isStaged ? "unstage" : "stage" : null;
+ const discardEnabled = writable && !!context && !context.isStaged && context.canDiscard === true
+ && !!operations.discard && !!operations.confirmDiscard && canDiscardDiff(diff);
+ const actions: readonly HunkActionID[] = action
+ ? discardEnabled ? [action, "discard"] : [action] : [];
let disposed = false;
let pending = false;
let applied = false;
return {
action,
+ actions,
async apply(hunkID: string, requestedAction: string): Promise {
- if (disposed || pending || applied || !action || !context || requestedAction !== action) return "ignored";
+ if (disposed || pending || applied || !context
+ || !(actions as readonly string[]).includes(requestedAction)) return "ignored";
const hunk = monacoDiffHunk(diff, hunkID);
if (!hunk) return "ignored";
+ const requested = requestedAction as HunkActionID;
+ // Pending also covers the confirmation dialog, so a second click cannot
+ // queue another destructive write behind it.
pending = true;
try {
- const success = await operations[action](context.repoPath, hunk);
+ if (requested === "discard") {
+ const confirmed = await operations.confirmDiscard!(hunk);
+ // A refresh while the dialog was open replaced this patch; its
+ // identity no longer describes the file, so the answer is moot.
+ if (disposed) return "ignored";
+ if (!confirmed) return "cancelled";
+ }
+ const operation = requested === "discard" ? operations.discard! : operations[requested];
+ const success = await operation(context.repoPath, hunk);
if (disposed) return "ignored";
// The patch is stale after a successful mutation. Wait for the host's
// Git change event to replace it before accepting another action.
diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts
index c4a8ed9ea..d1ee83df0 100644
--- a/windows/tauri/src/i18n/locale.ts
+++ b/windows/tauri/src/i18n/locale.ts
@@ -4514,6 +4514,9 @@ const catalogs = {
"git.diff.statusRenamed": "RENAMED",
"git.diff.stage": "Stage",
"git.diff.unstage": "Unstage",
+ "git.diff.rollbackHunk": "Rollback Change Block",
+ "git.diff.rollbackHunkConfirm":
+ 'Roll back this change block in "{path}"? Its unstaged edits will be discarded. This cannot be undone.',
"git.diff.uncommitted": "Uncommitted Changes",
"welcome.openProjectOrRemote": "Open project or remote connection",
"welcome.openFolder": "Open Folder",
@@ -8871,6 +8874,8 @@ const catalogs = {
"git.diff.statusRenamed": "重命名",
"git.diff.stage": "暂存",
"git.diff.unstage": "取消暂存",
+ "git.diff.rollbackHunk": "回滚修改块",
+ "git.diff.rollbackHunkConfirm": "回滚 “{path}” 中的这个修改块吗?该块未暂存的修改将被丢弃,此操作无法撤销。",
"git.diff.uncommitted": "未提交的更改",
"welcome.openProjectOrRemote": "打开项目或远程连接",
"welcome.openFolder": "打开文件夹",