From 40723bdf4730a061b0b40ce8c4550a38db87e129 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 1 Oct 2026 12:40:01 +0800 Subject: [PATCH 1/2] fix(windows): expand omitted lines in working-tree file diffs (#557) Single-file working-tree diffs were rendered from a sparse Git patch with Monaco folding disabled, so there was nothing to expand and the fold controls disappeared. - Request the whole file as context for single-file reviews and mark the result as full context; only those reviews enable folding. - Split full-context patches back into Git-equivalent 3-line hunks so hunk staging keeps its granularity. - Place hunk action bands by an explicit row anchor and per-side line count, keeping both sides aligned. - Re-attach the diff models when the text is replaced so the first real text after the empty mount folds instead of opening fully revealed. Co-Authored-By: Claude --- ...26-09-15-macos-monaco-feasibility-probe.md | 17 +++ docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- frontend/editor/src/diff-review.ts | 27 ++++- .../Monaco/workbench.integration.ts | 39 +++++++ rust/lithe-core/src/tests/git.rs | 61 +++++++++++ shared/platform-feature-matrix.json | 2 +- windows/tauri/src-tauri/src/platform.rs | 27 +++++ .../src/features/git/api/git-diff-api.ts | 51 ++++++++- .../features/git/api/git-status-api.test.ts | 32 +++++- .../git/components/diff/monaco-git-diff.tsx | 7 +- .../git/hooks/use-git-diff-actions.ts | 7 +- .../git/services/working-tree-diff-refresh.ts | 6 +- .../tauri/src/features/git/types/git.types.ts | 5 + .../git/utils/monaco-diff-rows.test.ts | 84 ++++++++++++++- .../features/git/utils/monaco-diff-rows.ts | 102 +++++++++++++++++- .../sidebar/utils/open-sidebar-resource.ts | 4 +- 17 files changed, 449 insertions(+), 26 deletions(-) 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 7ba3c5ee0..2e0a94c33 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 @@ -412,6 +412,23 @@ working-tree review 的显式身份,不回退到可能已经切换的工作区 错误通过现有本地化提示呈现。纯逻辑回归覆盖稀疏分块、两种操作及这些生命周期边界, 并纳入 Windows Git 随机顺序 CI;真实 WebView2 的按钮与 Git 状态联动仍需宿主验收。 +Windows 单文件工作区 Diff 还要能展开省略的未改动代码(#557)。稀疏补丁里根本 +没有这些行,所以“展开”无从谈起;之前强行关闭折叠,结果连展开按钮都不显示。 +现在单文件打开和刷新时请求整份文件作为上下文(`contextLines` 取 Git 接受的 +最大值 2147483647),结果标记为完整上下文(`is_full_context`),只有这种补丁 +才开启 Monaco 的未改动区域折叠。多文件、历史和提交比较仍用稀疏补丁且不折叠, +因为它们缺少的行同样无法展示。 + +整份文件只有一个 `@@`,直接拿来暂存会把整个文件一次写进索引。因此 Windows +适配器按 Git 默认的 3 行上下文重新切出分块,自己生成 `@@` 范围;单测用同一次 +修改的真实 `git diff` 输出逐块比对。完整上下文没有 `@@` 行,渲染器改用行上的 +`actionAnchor` 标记放置按钮,并按每一侧实际显示的行数计算位置,左右两侧保持对齐。 + +Monaco 会把旧的折叠状态带到新内容上:挂载时的空模型没有未改动区域,第一份 +真实文本的所有区域都会被当作“已展开”。渲染器在文本被替换时重新挂接模型, +让折叠状态从头计算;文本相同的刷新不重挂,读者已展开的区域保留。 +真实 WebKit 回归覆盖首次折叠、单个按钮和搜索揭示,去掉重挂会失败。 + 预览借用主编辑器时还保存查找栏的查询、选项和显隐状态。关闭预览必须恢复 主编辑器原先的查找状态,不能把替换预览的查询栏留在原文件中。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index e208969af..f7900a421 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -31,7 +31,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 状态中时关闭。,,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 保持打开且每个修改块仍可单独暂存。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Views/Diff,windows/tauri/src/features/git; windows/tauri/src/features/viewer git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。,,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 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 80d8ef0d0..9291c7c2d 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -114,7 +114,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 状态中时关闭。 | | +| 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 | **提交历史与图谱**
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` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 | | | 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/frontend/editor/src/diff-review.ts b/frontend/editor/src/diff-review.ts index 24d39249d..9ff1b198a 100644 --- a/frontend/editor/src/diff-review.ts +++ b/frontend/editor/src/diff-review.ts @@ -10,6 +10,9 @@ export interface ReviewRow { right: string | null; kind: "context" | "changed" | "addition" | "removal" | "information"; hunkID?: string | null; + /** Renders the hunk's action band above this row. Full-file reviews have no + * `@@` information rows, so the host marks each hunk's first change instead. */ + actionAnchor?: boolean; } export interface ReviewAction { id: string; title: string } export interface DiffReviewInput { @@ -136,8 +139,14 @@ export function mountDiffReview(container: HTMLElement, leftRows = old.rows; rightRows = next.rows; if (original.getLanguageId() !== input.language) monaco.editor.setModelLanguage(original, input.language); if (modified.getLanguageId() !== input.language) monaco.editor.setModelLanguage(modified, input.language); + const replaced = original.getValue() !== old.text || modified.getValue() !== next.text; if (original.getValue() !== old.text) original.setValue(old.text); if (modified.getValue() !== next.text) modified.setValue(next.text); + // Monaco carries fold state across content changes: regions that did not + // exist before, such as every region of the first real text after the + // empty mount, open fully revealed. Re-attaching the models starts a + // fresh diff view model whose unchanged regions fold, like a new review. + if (replaced) { editor.setModel(null); editor.setModel({ original, modified }); } left.updateOptions({ lineNumbers: line => String(leftRows[line - 1]?.oldLine ?? "") }); right.updateOptions({ lineNumbers: line => String(rightRows[line - 1]?.newLine ?? "") }); editor.updateOptions({ renderSideBySide: input.sideBySide ?? true, @@ -146,11 +155,19 @@ export function mountDiffReview(container: HTMLElement, container.classList.toggle("no-word-highlights", input.highlightWords === false); const actions = input.actions ?? []; if (actions.length) { - for (const [view, rows] of [[left, leftRows], [right, rightRows]] as const) { + for (const [view, side] of [[left, "left"], [right, "right"]] as const) { const ids: string[] = []; view.changeViewZones(accessor => { - rows.forEach((row, index) => { - if (row.kind !== "information" || !row.hunkID) return; + let projected = 0; + for (const row of input.rows) { + const visible = row[side] !== null; + // An `@@` row owns the band below it. An anchor row may be absent + // from this side (an addition on the left), so its band goes after + // the rows this side has already shown, keeping both sides level. + const afterLineNumber = row.hunkID && row.actionAnchor ? projected + : row.hunkID && row.kind === "information" && visible ? projected + 1 : null; + if (visible) projected++; + if (afterLineNumber === null) continue; const node = document.createElement("div"); node.className = "lithe-review-actions"; if (view === right) for (const action of actions) { @@ -159,8 +176,8 @@ export function mountDiffReview(container: HTMLElement, button.onclick = () => { if (!disposed && request === version) onAction(row.hunkID!, action.id); }; node.append(button); } - ids.push(accessor.addZone({ afterLineNumber: index + 1, heightInPx: 26, domNode: node })); - }); + ids.push(accessor.addZone({ afterLineNumber, heightInPx: 26, domNode: node })); + } }); zones.push({ view, ids }); } diff --git a/macos/Experiments/Monaco/workbench.integration.ts b/macos/Experiments/Monaco/workbench.integration.ts index c56e14927..b265218c2 100644 --- a/macos/Experiments/Monaco/workbench.integration.ts +++ b/macos/Experiments/Monaco/workbench.integration.ts @@ -115,6 +115,45 @@ async function verify() { subscription?.dispose(); review.dispose(); container.remove(); } }); + // #557: the first text after the empty mount used to open with every + // unchanged region revealed, so a full-file review showed nothing to expand. + await check("first full-file diff folds unchanged regions and anchors hunk actions", async () => { + const container = document.createElement("div"); + container.style.cssText = "position:absolute;inset:0;height:400px;width:1000px"; + document.body.append(container); + const review = mountDiffReview(container); + const rows: ReviewRow[] = Array.from({ length: 120 }, (_, index) => ({ + id: `full-${index}`, oldLine: index + 1, newLine: index + 1, + left: `value ${index}`, right: index === 4 ? "changed" : `value ${index}`, + kind: index === 4 ? "changed" : "context", hunkID: index < 8 ? "hunk-4" : null, + ...(index === 4 ? { actionAnchor: true } : {}), + })); + let subscription: { dispose(): void } | undefined; + let deadline: ReturnType | undefined; + try { + const computed = new Promise((resolve, reject) => { + deadline = setTimeout(() => reject(new Error("Full-file diff computation exceeded 5 seconds")), 5000); + subscription = review.editor.onDidUpdateDiff(() => { + if (review.editor.getLineChanges()?.length) resolve(); + }); + }); + await Promise.all([review.update({ rows, language: "plaintext", collapse: true, + actions: [{ id: "stage", title: "Stage" }] }), computed]); + const next = review.editor.getModifiedEditor(); + // Content height, unlike the viewport, shrinks only when lines are folded. + const lineHeight = next.getOption(monacoEditor.EditorOption.lineHeight); + assert(next.getContentHeight() < lineHeight * 40, + "unchanged region opened revealed instead of folded"); + assert(container.querySelectorAll(".lithe-review-actions button").length === 1, + "full-file hunk did not render exactly one action band"); + review.select({ revealID: "full-60", searchIDs: ["full-60"] }); + assert(next.getVisibleRanges().some(range => range.startLineNumber <= 61 && range.endLineNumber >= 61), + "folded source row could not be revealed"); + } finally { + if (deadline !== undefined) clearTimeout(deadline); + subscription?.dispose(); review.dispose(); container.remove(); + } + }); await check("inline diff navigation scrolls to a removed source row", async () => { const container = document.createElement("div"); container.style.cssText = "position:absolute;inset:0;height:400px;width:1000px"; diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 31075dfec..3ab708542 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -2994,6 +2994,67 @@ fn git_diff_and_apply_round_trip_a_patch() { fs::remove_dir_all(root).expect("temporary workspace should be removable"); } +/// Windows single-file reviews ask for Git's largest context so the renderer +/// can reveal folded unchanged lines (#557). The snapshot diff must accept that +/// value and return the complete file as one hunk. +#[test] +fn git_snapshot_diff_returns_the_whole_file_for_maximum_context() { + let root = temporary_root("git-full-context-diff"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + let original = (1..=200) + .map(|line| format!("line {line}\n")) + .collect::(); + fs::write(root.join("long.txt"), &original).expect("file should be writable"); + assert!(run(&["add", "long.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + fs::write( + root.join("long.txt"), + original + .replace("line 10\n", "line ten\n") + .replace("line 190\n", "line one-ninety\n"), + ) + .expect("file should be writable"); + + let request = serde_json::json!({ + "id": "full-context-diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["long.txt"], + "worktreeSnapshot": true, + "contextLines": i32::MAX + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("diff request should encode"), + )) + .expect("diff response should be JSON"); + assert_eq!(response["ok"], true, "{response}"); + let patch = response["data"]["patch"] + .as_str() + .expect("diff output should be text"); + assert!(patch.contains("@@ -1,200 +1,200 @@"), "{patch}"); + assert!( + patch.contains("\n line 100\n"), + "middle context missing: {patch}" + ); + assert_eq!(response["data"]["hunks"].as_array().unwrap().len(), 1); + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + #[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 3cd1e2042..fe9531829 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -898,7 +898,7 @@ "verificationStatus": "pending" }, "owner": "Git", - "verification": "验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。" + "verification": "验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 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 f0dd8ffa5..6535b8f99 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -1328,6 +1328,33 @@ mod tests { ); } + #[test] + fn preserves_full_file_context_for_single_file_review() { + // #557: the review reveals folded lines locally, so Core must receive + // the whole-file context request unchanged. + let (command, payload) = translate( + "git_diff_file", + json!({ + "repoPath": "C:/work", + "filePath": "src/main.rs", + "worktreeSnapshot": true, + "contextLines": 2_147_483_647u32 + }), + ) + .unwrap(); + + assert_eq!(command, "git.diff"); + assert_eq!( + payload, + json!({ + "root": "C:/work", + "pathspecs": ["src/main.rs"], + "worktreeSnapshot": true, + "contextLines": 2_147_483_647u32 + }) + ); + } + #[test] fn translates_untracked_diff_file_pathspec() { let (command, payload) = translate( diff --git a/windows/tauri/src/features/git/api/git-diff-api.ts b/windows/tauri/src/features/git/api/git-diff-api.ts index 6e1561c7a..aa2bac9c6 100644 --- a/windows/tauri/src/features/git/api/git-diff-api.ts +++ b/windows/tauri/src/features/git/api/git-diff-api.ts @@ -238,11 +238,22 @@ export const getFileDiff = async ( } }; +/** + * Git's largest accepted `--unified` value. A single-file review asks for the + * whole file as context so omitted regions can be revealed without inventing + * source text; Git emits one hunk per file at this size. + */ +const FULL_FILE_CONTEXT_LINES = 2_147_483_647; + +const markFullContext = (diff: GitDiff | null): GitDiff | null => + diff ? { ...diff, is_full_context: true } : diff; + export const getWorkingTreePathDiff = async ( repoPath: string, filePath: string, _untracked = false, originalPath?: string, + fullContext = false, ): Promise => { try { const resolved = await resolveRepositoryForFile(repoPath, filePath); @@ -251,17 +262,19 @@ export const getWorkingTreePathDiff = async ( const filePaths = [ ...new Set([originalPath, resolved.filePath].filter(Boolean) as string[]), ]; - return await runGitRead( + const diff = await runGitRead( resolved.repoPath, - `working-tree-path-diff:${resolved.filePath}`, + `working-tree-path-diff:${fullContext ? "full:" : ""}${resolved.filePath}`, () => tauriInvoke("git_diff_file", { repoPath: resolved.repoPath, filePath: resolved.filePath, ...(filePaths.length > 1 ? { filePaths } : {}), worktreeSnapshot: true, + ...(fullContext ? { contextLines: FULL_FILE_CONTEXT_LINES } : {}), }), ); + return fullContext ? markFullContext(diff) : diff; } catch (error) { if (!isNotGitRepositoryError(error) && !isNoDiffFoundError(error)) { console.error("Failed to get working-tree path diff:", error); @@ -270,6 +283,40 @@ export const getWorkingTreePathDiff = async ( } }; +/** + * Index or worktree diff of one file with the whole file as context. It is not + * cached with `getFileDiff` results because gutter and AI consumers expect the + * regular sparse patch for the same key. + */ +export const getFullContextFileDiff = async ( + repoPath: string, + filePath: string, + staged = false, +): Promise => { + try { + const resolved = await resolveRepositoryForFile(repoPath, filePath); + if (!resolved) return null; + + const diff = await runGitRead( + resolved.repoPath, + `full-context-diff:${staged ? "staged" : "unstaged"}:${resolved.filePath}`, + () => + tauriInvoke("git_diff_file", { + repoPath: resolved.repoPath, + filePath: resolved.filePath, + staged, + contextLines: FULL_FILE_CONTEXT_LINES, + }), + ); + return markFullContext(diff); + } catch (error) { + if (!isNotGitRepositoryError(error) && !isNoDiffFoundError(error)) { + console.error("Failed to get full-context file diff:", error); + } + return null; + } +}; + export const getUntrackedFileDiff = async ( repoPath: string, filePath: string, 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 bcb49ac8e..efef895f1 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 @@ -43,7 +43,7 @@ const { getWorkspaceRootGitStatus, getGitStatus, } = await import("./git-status-api"); -const { getWorkingTreePathDiff } = await import("./git-diff-api"); +const { getFullContextFileDiff, getWorkingTreePathDiff } = await import("./git-diff-api"); beforeEach(() => { invokeSpy = spyOn(tauriCore, "invoke").mockImplementation(invoke as typeof tauriCore.invoke); @@ -173,6 +173,36 @@ describe("Git status review diffs", () => { worktreeSnapshot: true, }); }); + + // #557: a single-file review needs every source line so folded unchanged + // regions can be expanded; the result is marked for the renderer. + test("requests the whole file as context for a single-file review", async () => { + invoke.mockImplementationOnce(async () => ({ file_path: "src/App.tsx", lines: [] })); + await expect( + getWorkingTreePathDiff("C:/repo", "src/App.tsx", false, undefined, true), + ).resolves.toMatchObject({ is_full_context: true }); + + expect(invoke).toHaveBeenLastCalledWith("git_diff_file", { + repoPath: "C:/repo", + filePath: "src/App.tsx", + worktreeSnapshot: true, + contextLines: 2_147_483_647, + }); + }); + + test("requests a full-context staged diff without the sparse cache", async () => { + invoke.mockImplementationOnce(async () => ({ file_path: "src/App.tsx", lines: [] })); + await expect(getFullContextFileDiff("C:/repo", "src/App.tsx", true)).resolves.toMatchObject({ + is_full_context: true, + }); + + expect(invoke).toHaveBeenLastCalledWith("git_diff_file", { + repoPath: "C:/repo", + filePath: "src/App.tsx", + staged: true, + contextLines: 2_147_483_647, + }); + }); }); function deferred() { 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 e38cdfa1f..a5b3f4457 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 @@ -42,6 +42,7 @@ export default function MonacoGitDiff({ diff, viewMode = "split", showWhitespace const [height, setHeight] = useState(MIN_REVIEW_HEIGHT); const rows = useMemo(() => monacoDiffRows(diff), [diff]); const sourcePath = diff.new_path || diff.old_path || diff.file_path; + const fullContext = diff.is_full_context === true; const latest = useRef({ rows, sourcePath }); const updating = useRef(false); const repoPath = staging?.repoPath; @@ -114,13 +115,15 @@ export default function MonacoGitDiff({ diff, viewMode = "split", showWhitespace setError(undefined); updating.current = true; const action = hunkActions.current?.action; + // 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: false, overview: !embedded, + sideBySide: viewMode === "split", collapse: fullContext, overview: !embedded, actions: action ? [{ id: action, title: actionTitle }] : [] }) .then(() => { if (!cancelled) { latest.current = { rows, sourcePath }; updating.current = false; } }) .catch(error => { if (!cancelled) setError(String(error)); }); return () => { cancelled = true; }; - }, [rows, sourcePath, viewMode, embedded, repoPath, isStaged, actionTitle]); + }, [rows, sourcePath, fullContext, viewMode, embedded, repoPath, isStaged, actionTitle]); 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 a043264bd..8dd191ff9 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 @@ -5,7 +5,7 @@ import { useTranslation } from "@/i18n/locale-provider"; import { showAlertDialog } from "@/ui/dialog"; import { getCommitDiff, - getFileDiff, + getFullContextFileDiff, getRefDiff, getStashDiff, getTypedReferenceDiff, @@ -165,12 +165,13 @@ export function useGitDiffActions({ const bufferId = openDiffBuffer("diff://working-tree/all-files", title, loadingDiff); void (async () => { const diff = staged - ? await getFileDiff(fileRepoPath, relativePath, true) + ? await getFullContextFileDiff(fileRepoPath, relativePath, true) : await getWorkingTreePathDiff( fileRepoPath, relativePath, untracked, originalRelativePath, + true, ); if ( !latestFileDiffRequest.isCurrent(requestId) || @@ -196,7 +197,7 @@ export function useGitDiffActions({ return; } - const diff = await getFileDiff(activeRepoPath, actualFilePath, staged); + const diff = await getFullContextFileDiff(activeRepoPath, actualFilePath, staged); if ( !latestFileDiffRequest.isCurrent(requestId) || activeRepoPathRef.current !== activeRepoPath 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 438caf999..88ba363b3 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 @@ -1,6 +1,6 @@ import equal from "fast-deep-equal"; import { normalizePath } from "@/utils/path-helpers"; -import { getFileDiff, getWorkingTreePathDiff } from "../api/git-diff-api"; +import { getFullContextFileDiff, getWorkingTreePathDiff } from "../api/git-diff-api"; import { getGitStatus } from "../api/git-status-api"; import type { MultiFileDiff, WorkingTreeDiffTarget } from "../types/git-diff.types"; import type { GitDiff, GitFile, GitStatus } from "../types/git.types"; @@ -65,8 +65,8 @@ export async function refreshWorkingTreeFileDiff( loadStatus = getGitStatus, loadDiff = (root, path, untracked, originalPath, staged) => staged - ? getFileDiff(root, path, true) - : getWorkingTreePathDiff(root, path, untracked, originalPath), + ? getFullContextFileDiff(root, path, true) + : getWorkingTreePathDiff(root, path, untracked, originalPath, true), }: WorkingTreeDiffRefreshDependencies, ): Promise { const startingDiff = buffers.read(bufferId); diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index d173d81cc..7237fadb1 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -134,6 +134,11 @@ export interface GitDiff { additions?: number; deletions?: number; is_truncated?: boolean; + /** + * Every line of the file is present as patch context, so a review can fold + * and reveal unchanged regions locally instead of showing sparse hunks. + */ + is_full_context?: boolean; split_hunks?: GitDiffSplitRow[][]; } diff --git a/windows/tauri/src/features/git/utils/monaco-diff-rows.test.ts b/windows/tauri/src/features/git/utils/monaco-diff-rows.test.ts index 18bf2931a..ba491cc06 100644 --- a/windows/tauri/src/features/git/utils/monaco-diff-rows.test.ts +++ b/windows/tauri/src/features/git/utils/monaco-diff-rows.test.ts @@ -2,7 +2,33 @@ import type { ReviewRow } from "@lithe/editor/diff-review"; import { describe, expect, test } from "bun:test"; import fixture from "../../../../../../shared/fixtures/editor/diff-review-v1.json"; import type { GitDiff } from "../types/git.types"; -import { monacoDiffRows } from "./monaco-diff-rows"; +import { parseRawDiffContent } from "./git-diff-parser"; +import { monacoDiffHunk, monacoDiffRows } from "./monaco-diff-rows"; + +const filePatch = (hunks: string) => + `diff --git a/f.txt b/f.txt\n--- a/f.txt\n+++ b/f.txt\n${hunks}`; +const parse = (hunks: string) => parseRawDiffContent(filePatch(hunks), "f.txt") as GitDiff; +const fileLines = (from: number, to: number) => + Array.from({ length: to - from + 1 }, (_, index) => ` line ${from + index}`); + +// Both patches are real `git diff` output for one edit of a 40-line file: +// `--unified=2147483647` for the review, and Git's default `--unified=3`. +const fullContext: GitDiff = { + ...parse([ + "@@ -1,40 +1,40 @@", + " line 1", "-line 2", "+line 2 changed", ...fileLines(3, 12), "+inserted after 12", + ...fileLines(13, 18), "-line 19", ...fileLines(20, 37), "-line 38", "+line 37 changed", + ...fileLines(39, 40), "", + ].join("\n")), + is_full_context: true, +}; +const gitDefaultHunks = parse([ + "@@ -1,5 +1,5 @@", " line 1", "-line 2", "+line 2 changed", ...fileLines(3, 5), + "@@ -10,13 +10,13 @@ line 9", ...fileLines(10, 12), "+inserted after 12", ...fileLines(13, 18), + "-line 19", ...fileLines(20, 22), + "@@ -35,6 +35,6 @@ line 34", ...fileLines(35, 37), "-line 38", "+line 37 changed", + ...fileLines(39, 40), "", +].join("\n")); describe("shared Monaco diff rows", () => { test("matches the cross-platform sparse patch fixture", () => { @@ -18,3 +44,59 @@ describe("shared Monaco diff rows", () => { expect(rows.map(row => row.id)).toEqual(["line-0", "line-1"]); }); }); + +describe("full-context Monaco diff rows", () => { + // Regression for #557: the review holds every source line, so Monaco can + // reveal folded regions instead of showing a sparse patch with nothing to expand. + test("project the whole file without the full-file header row", () => { + const rows = monacoDiffRows(fullContext); + expect(rows.some(row => row.kind === "information")).toBe(false); + expect(rows.filter(row => row.left !== null).map(row => row.oldLine)) + .toEqual(Array.from({ length: 40 }, (_, index) => index + 1)); + expect(rows.filter(row => row.right !== null).map(row => row.newLine)) + .toEqual(Array.from({ length: 40 }, (_, index) => index + 1)); + // Identity still addresses the host line array, so search results keep working. + expect(rows[0].id).toBe("line-1"); + }); + + test("anchor one action band per hunk and leave distant context unowned", () => { + const rows = monacoDiffRows(fullContext); + const anchors = rows.filter(row => row.actionAnchor); + expect(anchors.map(row => row.hunkID)).toEqual(["hunk-2", "hunk-14", "hunk-40"]); + expect(rows.find(row => row.oldLine === 30)?.hunkID).toBeNull(); + }); + + test("stage the same hunks Git reports at its default context", () => { + const anchors = monacoDiffRows(fullContext).filter(row => row.actionAnchor); + const derived = anchors.map(row => monacoDiffHunk(fullContext, row.hunkID!)); + const expected = gitDefaultHunks.lines.flatMap((line, index) => + line.line_type === "header" ? [monacoDiffHunk(gitDefaultHunks, `hunk-${index}`)] : []); + // Git appends the enclosing-line hint to headers; `git apply` ignores it. + const withoutHint = (hunk: ReturnType) => hunk && { + ...hunk, + lines: hunk.lines.map(line => line.line_type === "header" + ? { line_type: "header" as const, content: line.content.replace(/ @@.*$/, " @@") } + : line), + }; + expect(derived).toEqual(expected.map(withoutHint)); + }); + + test("name the preceding line for a hunk with an empty side", () => { + const added = parse("@@ -0,0 +1,2 @@\n+first\n+second\n"); + const hunk = monacoDiffHunk({ ...added, is_full_context: true }, "hunk-1"); + expect(hunk?.lines[0]).toEqual({ line_type: "header", content: "@@ -0,0 +1,2 @@" }); + }); + + test("keep sparse-patch hunks when the patch is not marked full-context", () => { + expect(monacoDiffRows(gitDefaultHunks).filter(row => row.kind === "information")).toHaveLength(3); + expect(monacoDiffHunk({ ...fullContext, is_full_context: false }, "hunk-0")?.lines) + .toHaveLength(fullContext.lines.length); + }); + + test("reject identities that do not start a derived hunk", () => { + for (const id of ["hunk-0", "hunk-3", "hunk-30", "line-2"]) { + expect(monacoDiffHunk(fullContext, id)).toBeNull(); + } + expect(monacoDiffHunk({ ...fullContext, is_truncated: true }, "hunk-2")).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/git/utils/monaco-diff-rows.ts b/windows/tauri/src/features/git/utils/monaco-diff-rows.ts index 0ba71453a..d4d3bdb16 100644 --- a/windows/tauri/src/features/git/utils/monaco-diff-rows.ts +++ b/windows/tauri/src/features/git/utils/monaco-diff-rows.ts @@ -1,10 +1,94 @@ import type { ReviewRow } from "@lithe/editor/diff-review"; -import type { GitDiff, GitHunk } from "../types/git.types"; +import type { GitDiff, GitDiffLine, GitHunk } from "../types/git.types"; import { parseDiffHunkRange } from "./git-diff-helpers"; +/** Context kept around each change when a full-file patch is split back into + * stageable hunks. Matches Git's default `--unified=3`. */ +const FULL_CONTEXT_HUNK_CONTEXT_LINES = 3; + +/** A stageable range of a full-context patch, as indices into `diff.lines`. */ +interface FullContextHunk { + id: string; + /** First changed line; the hunk's action band is rendered above it. */ + anchor: number; + start: number; + /** Exclusive end index. */ + end: number; +} + +const isChange = (line: GitDiffLine) => line.line_type === "added" || line.line_type === "removed"; + +/** Splits a full-context patch the way Git would at three context lines, so + * staging granularity does not grow to the whole file. Returns null for sparse + * patches, which keep the host's own hunks. */ +function fullContextHunks(diff: GitDiff): FullContextHunk[] | null { + const { lines } = diff; + if (!diff.is_full_context || lines[0]?.line_type !== "header" + || lines.some((line, index) => index > 0 && line.line_type === "header")) return null; + const context = FULL_CONTEXT_HUNK_CONTEXT_LINES; + const hunks: FullContextHunk[] = []; + for (let index = 1; index < lines.length; index++) { + if (!isChange(lines[index])) continue; + let last = index; + // Like Git, changes whose surrounding context would touch share one hunk. + for (let next = index + 1; next < lines.length && next - last - 1 <= 2 * context; next++) { + if (isChange(lines[next])) last = next; + } + hunks.push({ + id: `hunk-${index}`, + anchor: index, + start: Math.max(1, index - context), + end: Math.min(lines.length, last + context + 1), + }); + index = last; + } + return hunks; +} + +function hunkHeader(lines: GitDiffLine[], start: number, end: number): string { + const range = (key: "old_line_number" | "new_line_number") => { + const numbers = lines.slice(start, end).flatMap(line => line[key] ?? []); + if (numbers.length > 0) return `${numbers[0]},${numbers.length}`; + // Git names the line before an empty range, or 0 at the start of the file. + for (let index = start - 1; index >= 0; index--) { + const previous = lines[index][key]; + if (previous !== undefined) return `${previous},0`; + } + return "0,0"; + }; + return `@@ -${range("old_line_number")} +${range("new_line_number")} @@`; +} + +const rowKind = (line: GitDiffLine): ReviewRow["kind"] => + line.line_type === "header" ? "information" + : line.line_type === "added" ? "addition" + : line.line_type === "removed" ? "removal" : "context"; + /** Preserve patch order and identity. Monaco aligns the projections, while * search results continue to address the original Git line array. */ export function monacoDiffRows(diff: GitDiff): ReviewRow[] { + const derived = fullContextHunks(diff); + if (derived) { + // The single full-file header carries no information worth a row, and its + // range would stage the whole file; derived hunks own the actions instead. + const owners = new Map(); + for (const hunk of derived) for (let index = hunk.start; index < hunk.end; index++) owners.set(index, hunk); + return diff.lines.flatMap((line, index) => { + if (line.line_type === "header") return []; + const hunk = owners.get(index); + return [{ + id: `line-${index}`, + oldLine: line.old_line_number ?? null, + newLine: line.new_line_number ?? null, + left: line.line_type === "added" ? null : line.content, + right: line.line_type === "removed" ? null : line.content, + kind: rowKind(line), + hunkID: hunk?.id ?? null, + ...(hunk?.anchor === index ? { actionAnchor: true } : {}), + }]; + }); + } + let hunkID: string | null = null; return diff.lines.map((line, index) => { if (line.line_type === "header") hunkID = `hunk-${index}`; @@ -14,9 +98,7 @@ export function monacoDiffRows(diff: GitDiff): ReviewRow[] { newLine: line.new_line_number ?? null, left: line.line_type === "added" ? null : line.content, right: line.line_type === "removed" ? null : line.content, - kind: line.line_type === "header" ? "information" - : line.line_type === "added" ? "addition" - : line.line_type === "removed" ? "removal" : "context", + kind: rowKind(line), hunkID, }; }); @@ -25,6 +107,18 @@ export function monacoDiffRows(diff: GitDiff): ReviewRow[] { /** Resolve the host's patch identity, never Monaco's aligned display ranges. */ export function monacoDiffHunk(diff: GitDiff, hunkID: string): GitHunk | null { if (diff.is_truncated || !/^hunk-(0|[1-9]\d*)$/.test(hunkID)) return null; + const derived = fullContextHunks(diff); + if (derived) { + const hunk = derived.find(candidate => candidate.id === hunkID); + if (!hunk) return null; + return { + file_path: diff.file_path, + lines: [ + { line_type: "header", content: hunkHeader(diff.lines, hunk.start, hunk.end) }, + ...diff.lines.slice(hunk.start, hunk.end), + ], + }; + } const start = Number(hunkID.slice(5)); const header = diff.lines[start]; if (header?.line_type !== "header" || !parseDiffHunkRange(header.content)) return null; diff --git a/windows/tauri/src/features/sidebar/utils/open-sidebar-resource.ts b/windows/tauri/src/features/sidebar/utils/open-sidebar-resource.ts index d8fb667b2..dd2eeb430 100644 --- a/windows/tauri/src/features/sidebar/utils/open-sidebar-resource.ts +++ b/windows/tauri/src/features/sidebar/utils/open-sidebar-resource.ts @@ -1,6 +1,6 @@ import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; -import { getFileDiff } from "@/features/git/api/git-diff-api"; +import { getFullContextFileDiff } from "@/features/git/api/git-diff-api"; import { openGitWorktreeWorkspace } from "@/features/git/utils/git-worktree-open"; import { openCommitDiffBuffer } from "@/features/git/utils/open-commit-diff-buffer"; import { createSingleFileWorkingTreeDiff } from "@/features/git/utils/working-tree-multi-diff"; @@ -34,7 +34,7 @@ const openWorkingTreeDiffBuffer = async ( return useBufferStore.getState().activeBufferId; } - const diff = await getFileDiff(resource.repoPath, actualFilePath, resource.staged); + const diff = await getFullContextFileDiff(resource.repoPath, actualFilePath, resource.staged); if (!diff || (diff.lines.length === 0 && diff.is_image !== true)) { await useFileSystemStore .getState() From aeb54b8da8ef5011f22c8da4363f4f4a940c1deb Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 1 Oct 2026 13:37:25 +0800 Subject: [PATCH 2/2] test(windows): route full-context diff responses by command The new full-context API tests queued their response with mockImplementationOnce, which assumed git_diff_file was the next invoke. Under CI's randomized order, repository discovery is sometimes not yet cached, so git_discover_repo consumed the queued response and the diff returned null (seed=1). Answer git_diff_file by command instead. Co-Authored-By: Claude --- .../tauri/src/features/git/api/git-status-api.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 efef895f1..81b28ca8e 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 @@ -5,9 +5,13 @@ let unavailableRepo: string | null = null; let statusFailure: Error | null = null; let interceptWrite: ((args: Record) => Promise) | undefined; +// Keyed by command: repository discovery may or may not run first, depending +// on what earlier tests in a randomized order have already cached. +let fileDiffResponse: unknown = null; const invoke = mock(async (command: string, args?: Record): Promise => { if (command === "git.write" && interceptWrite) return interceptWrite(args ?? {}); + if (command === "git_diff_file") return fileDiffResponse; if (command === "git_discover_repo") { const path = String(args?.path ?? ""); return path.startsWith("C:/workspace/") ? path : "C:/repo"; @@ -49,6 +53,7 @@ beforeEach(() => { invokeSpy = spyOn(tauriCore, "invoke").mockImplementation(invoke as typeof tauriCore.invoke); invoke.mockClear(); interceptWrite = undefined; + fileDiffResponse = null; unavailableRepo = null; statusFailure = null; }); @@ -177,7 +182,7 @@ describe("Git status review diffs", () => { // #557: a single-file review needs every source line so folded unchanged // regions can be expanded; the result is marked for the renderer. test("requests the whole file as context for a single-file review", async () => { - invoke.mockImplementationOnce(async () => ({ file_path: "src/App.tsx", lines: [] })); + fileDiffResponse = { file_path: "src/App.tsx", lines: [] }; await expect( getWorkingTreePathDiff("C:/repo", "src/App.tsx", false, undefined, true), ).resolves.toMatchObject({ is_full_context: true }); @@ -191,7 +196,7 @@ describe("Git status review diffs", () => { }); test("requests a full-context staged diff without the sparse cache", async () => { - invoke.mockImplementationOnce(async () => ({ file_path: "src/App.tsx", lines: [] })); + fileDiffResponse = { file_path: "src/App.tsx", lines: [] }; await expect(getFullContextFileDiff("C:/repo", "src/App.tsx", true)).resolves.toMatchObject({ is_full_context: true, });