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": "打开文件夹",