From 4d50b7f90e259896228dd0900bc3767b6fe93fff Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 1 Oct 2026 16:45:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(git):=20Windows=20Git=20Log=20=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E8=AF=A6=E6=83=85=E6=98=BE=E7=A4=BA=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=BF=A1=E6=81=AF=20(#771)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史分页只读取 `%s`(标题),Windows 详情面板虽然会渲染 `commit.description`,但该字段从未被填充,导致只能看到标题。 - Rust Core `git.commit` 额外返回 `body`(Git `%b`)。正文放在 NUL 之后,不会和 `%x1f` 分隔的头部字段冲突;同时加 `--no-show-signature` 防止签名输出混入。历史分页契约保持不变。 - 共享契约和新增 fixture `commit-lookup-response-v1.json` 记录 `body`。 - Windows 选中提交时按需读取正文(缓存 200 条),并忽略过期的响应; 复制提交信息、打开提交 Diff 时同样带上正文。 - 详情区抽成 `GitCommitDetails` 组件,补充 Rust 与 bun 回归测试,并 更新平台功能矩阵。 Co-Authored-By: Claude --- docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- rust/lithe-core/src/git/mod.rs | 87 ++++++++++++-- rust/lithe-core/src/protocol/contracts.rs | 4 + rust/lithe-core/src/tests/git.rs | 75 ++++++++++++ shared/contracts/rust-core-api.md | 10 +- .../git/commit-lookup-response-v1.json | 13 ++ shared/platform-feature-matrix.json | 6 +- .../features/git/api/git-commits-api.test.ts | 75 ++++++++++++ .../src/features/git/api/git-commits-api.ts | 64 ++++++++++ .../log/git-commit-details.test.tsx | 113 ++++++++++++++++++ .../git/components/log/git-commit-details.tsx | 67 +++++++++++ .../components/log/git-commit-inspector.tsx | 27 +---- .../components/log/git-log-tool-window.tsx | 14 ++- .../git/hooks/use-git-diff-actions.ts | 6 +- 15 files changed, 516 insertions(+), 49 deletions(-) create mode 100644 shared/fixtures/git/commit-lookup-response-v1.json create mode 100644 windows/tauri/src/features/git/components/log/git-commit-details.test.tsx create mode 100644 windows/tauri/src/features/git/components/log/git-commit-details.tsx diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 767f5eb16..c390f3f3c 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -33,7 +33,7 @@ git-status-commit,版本控制,Git,状态、暂存与提交,已实现,待验证, 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-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-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 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; windows/tauri/src/features/git/components/log/git-commit-details.tsx; windows/tauri/src/features/git/components/log/git-commit-details.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 git-multi-repository-references,版本控制,Git,多仓库引用面板:分组、配色与引用操作,已实现,待验证,已实现,待验证,Git,在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。,,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 37a0ad01b..1002ee493 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -116,7 +116,7 @@ | 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 | **提交历史与图谱**
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 | **提交历史与图谱**
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` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 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,确认路径、分支和安全检查。 | | | Git | **多仓库引用面板:分组、配色与引用操作**
git-multi-repository-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。 | | diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index df263141b..d8ad99444 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -2058,7 +2058,7 @@ fn read_commit_log( Ok((all_commits.into_iter().take(limit).collect(), has_more)) } -/// Resolves one commit and its parent metadata. +/// Resolves one commit, its parent metadata, and the message body after the subject. pub fn commit(request: GitCommitRequest) -> Result { let root = validate_root(&request.root)?; validate_revision(&request.commit)?; @@ -2067,8 +2067,13 @@ pub fn commit(request: GitCommitRequest) -> Result Result Option { + let (header, body) = output.split_once('\0')?; + let commit = parse_commit(header.trim_end_matches(['\r', '\n']))?; + Some(GitCommitLookupResponse { + commit, + // Git terminates `%b` with a line break; trailing blank lines carry no content. + body: body.trim_end().to_string(), + }) } /// Lists workspace-relative files changed by one commit. @@ -6780,13 +6792,15 @@ fn relative_or_absolute(path: &Path, root: &Path) -> String { #[cfg(test)] mod tests { use super::{ - annotation_message_from_tag_object, line_similarity, pair_diff_entries, parse_diff, - pathspec_batches, simplified_canonical_path, structured_diff_from_output, DiffEntry, - GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, + annotation_message_from_tag_object, line_similarity, pair_diff_entries, + parse_commit_lookup, parse_diff, pathspec_batches, simplified_canonical_path, + structured_diff_from_output, DiffEntry, GitCommandInvocation, GitCommandResponse, + GitProcessOutput, MAX_ALIGNMENT_CELLS, }; use crate::protocol::{ - CoreError, ErrorCode, GitCommitResponse, GitHistoryPageResponse, GitHistoryResponse, - GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, GitReferencesResponse, + CoreError, ErrorCode, GitCommitLookupResponse, GitCommitResponse, GitHistoryPageResponse, + GitHistoryResponse, GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, + GitReferencesResponse, }; use serde_json::Value; use std::path::PathBuf; @@ -7361,6 +7375,53 @@ mod tests { ); } + #[test] + fn commit_lookup_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/commit-lookup-response-v1.json" + ))) + .expect("Git commit lookup response fixture should be valid JSON"); + let response = GitCommitLookupResponse { + commit: GitCommitResponse { + hash: "0123456789abcdef0123456789abcdef01234567".into(), + short_hash: "0123456".into(), + parent_hashes: vec!["89abcdef0123456789abcdef0123456789abcdef".into()], + author_name: "Lithe Test".into(), + author_email: "test@example.invalid".into(), + date: "2026/08/30 12:00".into(), + subject: "Fix commit details".into(), + decorations: "HEAD -> feature/recent".into(), + }, + body: "Explain why the change is needed.\n\n- keep blank lines\n and indentation" + .into(), + }; + + assert_eq!( + serde_json::to_value(response).expect("Git commit lookup response should serialize"), + fixture + ); + } + + #[test] + fn commit_lookup_keeps_separator_characters_inside_the_body() { + // The body may contain the header's unit separator or CRLF line breaks; + // only the first NUL delimits the header from the free-form message. + let output = "abc\u{1f}ab\u{1f}p1 p2\u{1f}Dev\u{1f}dev@example.invalid\u{1f}2026/08/30 12:00\u{1f}Subject\u{1f}tag: v1\0Line \u{1f} one\r\n\r\nLine two\n\n"; + let lookup = parse_commit_lookup(output).expect("lookup output should parse"); + assert_eq!(lookup.commit.parent_hashes, ["p1", "p2"]); + assert_eq!(lookup.commit.subject, "Subject"); + assert_eq!(lookup.commit.decorations, "tag: v1"); + assert_eq!(lookup.body, "Line \u{1f} one\r\n\r\nLine two"); + + let subject_only = parse_commit_lookup( + "abc\u{1f}ab\u{1f}\u{1f}Dev\u{1f}dev@example.invalid\u{1f}2026/08/30 12:00\u{1f}Only\u{1f}\0", + ) + .expect("subject-only output should parse"); + assert_eq!(subject_only.body, ""); + assert!(parse_commit_lookup("missing delimiter").is_none()); + } + #[test] fn push_preview_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index dc03e484a..43c0b6bd0 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -716,6 +716,10 @@ pub struct GitPushTagResponse { /// Exact lookup result for one commit. pub struct GitCommitLookupResponse { pub commit: GitCommitResponse, + /// Message text after the subject paragraph, as Git's `%b` placeholder, + /// with line breaks preserved and trailing whitespace removed. Empty when + /// the message has only a subject. History pages omit it to stay compact. + pub body: String, } #[derive(Debug, Clone, Serialize)] diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 3ab708542..96be997bd 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -3505,6 +3505,81 @@ fn git_history_page_treats_unborn_head_as_empty_history() { assert_eq!(response["data"]["hasMore"], false); } +/// Regression for #771: the commit detail view must receive the full multi-line +/// message, including text that contains the field separator used by the header, +/// while history pages keep returning only the subject. +#[test] +fn git_commit_returns_the_multi_line_body_separately_from_history_pages() { + 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-commit-body"); + let _cleanup = RemoveOnDrop(root.clone()); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + git_text(&root, &["init", "-q"]); + git_text(&root, &["config", "user.email", "test@example.invalid"]); + git_text(&root, &["config", "user.name", "Lithe Test"]); + // Keep the trailing blank lines so the test covers the trimming contract. + git_text(&root, &["config", "commit.cleanup", "verbatim"]); + let body = "First body line\n indented \u{1f} separator\n\n中文第二段"; + let message = format!("Fix commit details\n\n{body}\n\n"); + git_text( + &root, + &["commit", "-q", "--allow-empty", "-m", message.as_str()], + ); + git_text( + &root, + &["commit", "-q", "--allow-empty", "-m", "Subject only"], + ); + let detailed_hash = git_text(&root, &["rev-parse", "HEAD~1"]); + + let lookup = |id: &str, revision: &str| -> Value { + let request = serde_json::json!({ + "id": id, + "command": "git.commit", + "payload": {"root": root, "commit": revision} + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("commit response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + response["data"].clone() + }; + + let detailed = lookup("detailed", &detailed_hash); + assert_eq!(detailed["commit"]["hash"], detailed_hash); + assert_eq!(detailed["commit"]["subject"], "Fix commit details"); + assert_eq!(detailed["body"], body); + + let subject_only = lookup("subject-only", "HEAD"); + assert_eq!(subject_only["commit"]["subject"], "Subject only"); + // The body follows the decorations column, so the header must still parse fully. + assert!(subject_only["commit"]["decorations"] + .as_str() + .is_some_and(|decorations| decorations.starts_with("HEAD -> "))); + assert_eq!(subject_only["body"], ""); + + let page_request = serde_json::json!({ + "id": "history-page-subjects", + "command": "git.historyPage", + "payload": {"root": root, "reference": "HEAD", "limit": 10} + }); + let page: Value = serde_json::from_str(&execute_json(&page_request.to_string())) + .expect("history page response should be JSON"); + assert_eq!(page["ok"], true, "{page:?}"); + let subjects = page["data"]["commits"] + .as_array() + .expect("history page should contain commits") + .iter() + .map(|commit| commit["subject"].as_str().expect("subject").to_string()) + .collect::>(); + assert_eq!(subjects, ["Subject only", "Fix commit details"]); +} + #[test] fn git_history_page_returns_disjoint_incremental_pages() { struct RemoveOnDrop(std::path::PathBuf); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index d31596308..f2d35452c 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -421,7 +421,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `git.historyPage` | Return one bounded commit page, parent hashes, decorations, and an opaque continuation cursor | | `git.historyCursorClose` | Release an unfinished incremental history cursor and its Git process | | `git.pushPreview` | Resolve a local branch push destination and the bounded commits not present on that remote base | -| `git.commit` | Return one structured commit by revision | +| `git.commit` | Return one structured commit by revision with its full message body | | `git.commitFiles` | Return files changed by one commit | | `git.comparison` | Return files changed between a reference and the working tree | | `git.stashes` | Return structured stash references and messages | @@ -1150,7 +1150,13 @@ For compatibility, a request that explicitly contains the deprecated numeric `offset`; repository size does not select between the two protocols. -`git.commit` accepts `root` and a revision, returning one `commit` object. +`git.commit` accepts `root` and a revision, returning one `commit` object with +the same fields as a history page entry and a `body` string. `body` is the +message after the subject paragraph (Git `%b`), keeps internal line breaks and +indentation, has trailing whitespace removed, and is empty for a subject-only +message. History pages carry only `subject`; a commit detail view reads the +body on demand with `git.commit`. See +`shared/fixtures/git/commit-lookup-response-v1.json`. `git.blame` accepts `root` and a workspace-relative `path`; its line numbers are one-based and author timestamps are Unix seconds. diff --git a/shared/fixtures/git/commit-lookup-response-v1.json b/shared/fixtures/git/commit-lookup-response-v1.json new file mode 100644 index 000000000..999cd7bd5 --- /dev/null +++ b/shared/fixtures/git/commit-lookup-response-v1.json @@ -0,0 +1,13 @@ +{ + "commit": { + "hash": "0123456789abcdef0123456789abcdef01234567", + "shortHash": "0123456", + "parentHashes": ["89abcdef0123456789abcdef0123456789abcdef"], + "authorName": "Lithe Test", + "authorEmail": "test@example.invalid", + "date": "2026/08/30 12:00", + "subject": "Fix commit details", + "decorations": "HEAD -> feature/recent" + }, + "body": "Explain why the change is needed.\n\n- keep blank lines\n and indentation" +} diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 08f610fe4..663208a80 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -945,13 +945,15 @@ "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/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" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。" + "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。" }, { "id": "git-rebase-stash", diff --git a/windows/tauri/src/features/git/api/git-commits-api.test.ts b/windows/tauri/src/features/git/api/git-commits-api.test.ts index f5a6d61e7..87d6d6842 100644 --- a/windows/tauri/src/features/git/api/git-commits-api.test.ts +++ b/windows/tauri/src/features/git/api/git-commits-api.test.ts @@ -6,8 +6,15 @@ let gitWriteError: Error | null = null; let gitReadError: unknown = null; const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); +let commitLookupResult: unknown = null; +let commitLookupError: unknown = null; + const invoke = mock(async (command: string, _args?: unknown): Promise => { if (command === "git_discover_repo") return "C:/repo"; + if (command === "git.commit") { + if (commitLookupError) throw commitLookupError; + return commitLookupResult; + } if (command === "git.write") { if (gitWriteError) throw gitWriteError; return gitWriteResult; @@ -24,11 +31,13 @@ mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { cherryPickCommit, commitSelectedChanges, + getCommitDescription, getGitHistoryPage, getGitReferences, getGitReferencesAtRoot, resetToCommit, revertCommit, + withCommitDescription, } = await import("./git-commits-api"); beforeEach(() => { @@ -37,6 +46,72 @@ beforeEach(() => { gitWriteResult = { output: "", exitCode: 0 }; gitWriteError = null; gitReadError = null; + commitLookupResult = null; + commitLookupError = null; +}); + +describe("Git commit message details", () => { + const listedCommit = { + hash: "detail-subject-only", + shortHash: "detail", + parentHashes: [], + message: "Fix commit details", + author: "Developer", + date: "2026/08/16 10:00", + decorations: "", + }; + + // Regression for #771: history rows only carry the subject, so the full + // message must come from the shared single-commit lookup. + test("reads the body through git.commit and reuses it for the same commit", async () => { + commitLookupResult = { + commit: { hash: "detail-cached", subject: "Fix commit details" }, + body: "First line\n\n indented second paragraph", + }; + + expect(await getCommitDescription("C:/repo", "detail-cached")).toBe( + "First line\n\n indented second paragraph", + ); + expect(await getCommitDescription("C:/repo", "detail-cached")).toBe( + "First line\n\n indented second paragraph", + ); + + const lookups = invoke.mock.calls.filter(([command]) => command === "git.commit"); + expect(lookups).toEqual([["git.commit", { repoPath: "C:/repo", commit: "detail-cached" }]]); + }); + + test("treats a subject-only commit as an empty description", async () => { + commitLookupResult = { commit: { hash: listedCommit.hash }, body: "" }; + + expect(await withCommitDescription("C:/repo", listedCommit)).toEqual({ + ...listedCommit, + description: "", + }); + }); + + test("keeps the subject-only commit and retries later when the lookup fails", async () => { + const consoleError = spyOn(console, "error").mockImplementation(() => {}); + try { + commitLookupError = new Error("commit lookup failed"); + const commit = { ...listedCommit, hash: "detail-retry" }; + + expect(await withCommitDescription("C:/repo", commit)).toBe(commit); + expect(consoleError).toHaveBeenCalledWith("Failed to get commit details:", commitLookupError); + + commitLookupError = null; + commitLookupResult = { commit: { hash: "detail-retry" }, body: "Recovered body" }; + expect(await getCommitDescription("C:/repo", "detail-retry")).toBe("Recovered body"); + } finally { + consoleError.mockRestore(); + } + }); + + test("does not re-read a commit whose description is already known", async () => { + const commit = { ...listedCommit, hash: "detail-known", description: "Known body" }; + + expect(await withCommitDescription("C:/repo", commit)).toBe(commit); + expect(invoke.mock.calls.some(([command]) => command === "git.commit")).toBe(false); + }); }); describe("Git commit history reads", () => { diff --git a/windows/tauri/src/features/git/api/git-commits-api.ts b/windows/tauri/src/features/git/api/git-commits-api.ts index 68214f704..40102edc0 100644 --- a/windows/tauri/src/features/git/api/git-commits-api.ts +++ b/windows/tauri/src/features/git/api/git-commits-api.ts @@ -255,6 +255,70 @@ export const getCommitFiles = async ( } }; +interface GitCommitLookupResult { + commit?: { hash?: string }; + body?: string; +} + +/** + * History pages carry only the subject so paging stays compact; the message + * body is read on demand when one commit is inspected, copied, or diffed. + * A commit object is immutable, so a body never goes stale for its hash. + */ +const COMMIT_DESCRIPTION_CACHE_LIMIT = 200; +const commitDescriptionCache = new Map(); + +const rememberCommitDescription = (key: string, description: string) => { + commitDescriptionCache.delete(key); + commitDescriptionCache.set(key, description); + if (commitDescriptionCache.size > COMMIT_DESCRIPTION_CACHE_LIMIT) { + const oldest = commitDescriptionCache.keys().next().value; + if (oldest !== undefined) commitDescriptionCache.delete(oldest); + } +}; + +/** Returns the commit message body after the subject, or null when it cannot be read. */ +export const getCommitDescription = async ( + repoPath: string, + commitHash: string, +): Promise => { + try { + const resolvedRepoPath = await resolveRepositoryPath(repoPath); + if (!resolvedRepoPath) return null; + const cacheKey = JSON.stringify([resolvedRepoPath, commitHash]); + const cached = commitDescriptionCache.get(cacheKey); + if (cached !== undefined) { + rememberCommitDescription(cacheKey, cached); + return cached; + } + + const result = await runGitRead(resolvedRepoPath, `commit-details:${commitHash}`, () => + tauriInvoke("git.commit", { + repoPath: resolvedRepoPath, + commit: commitHash, + }), + ); + const description = typeof result?.body === "string" ? result.body : ""; + rememberCommitDescription(cacheKey, description); + return description; + } catch (error) { + if (!isNotGitRepositoryError(error)) { + console.error("Failed to get commit details:", error); + } + return null; + } +}; + +/** Fills `description` from the full message; keeps the subject-only commit on failure. */ +export const withCommitDescription = async ( + repoPath: string, + commit: GitCommit, +): Promise => { + if (commit.description !== undefined) return commit; + const description = await getCommitDescription(repoPath, commit.hash); + return description === null ? commit : { ...commit, description }; +}; + export const resetToCommit = ( repoPath: string, revision: string, diff --git a/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx b/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx new file mode 100644 index 000000000..feec5fd29 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import type { GitCommit } from "../../types/git.types"; + +const commitsApi = await import("../../api/git-commits-api"); +const { GitCommitDetails } = await import("./git-commit-details"); + +const commit = (hash: string, message: string): GitCommit => ({ + hash, + shortHash: hash.slice(0, 7), + parentHashes: [], + message, + author: "Developer", + email: "developer@example.invalid", + date: "2026/08/16 10:00", + decorations: "", +}); + +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let originalActEnvironment: boolean | undefined; +let restoreDom: () => void; +let container: HTMLDivElement; +let root: Root; +const spies: Array<{ mockRestore: () => void }> = []; +const descriptionResolvers = new Map void>(); + +beforeEach(() => { + restoreDom = installHappyDom(); + originalActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT; + actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + descriptionResolvers.clear(); + spies.push( + // Each lookup stays pending until the test releases it, so the order in + // which answers arrive is explicit. + spyOn(commitsApi, "getCommitDescription").mockImplementation( + (_repoPath: string, hash: string) => + new Promise((resolve) => descriptionResolvers.set(hash, resolve)), + ), + ); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + for (const spy of spies.splice(0)) spy.mockRestore(); + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + container.remove(); + if (originalActEnvironment === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT; + else actGlobal.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment; + restoreDom(); +}); + +const renderDetails = async (selected: GitCommit) => { + await act(async () => { + root.render( + + + , + ); + }); +}; + +const resolveDescription = async (hash: string, description: string | null) => { + const resolve = descriptionResolvers.get(hash); + if (!resolve) throw new Error(`No pending description lookup for ${hash}`); + await act(async () => { + resolve(description); + await Promise.resolve(); + }); +}; + +describe("Git commit details message", () => { + // Regression for #771: the details pane showed only the subject line. + test("shows the full multi-line message body for the selected commit", async () => { + await renderDetails(commit("aaaaaaa1", "Fix commit details")); + expect(container.textContent).toContain("Fix commit details"); + + await resolveDescription("aaaaaaa1", "First body line\n\nSecond paragraph"); + + const body = [...container.querySelectorAll("div")].find( + (element) => element.textContent === "First body line\n\nSecond paragraph", + ); + expect(body).toBeDefined(); + expect(body?.className).toContain("whitespace-pre-wrap"); + }); + + test("ignores a late body for a commit that is no longer selected", async () => { + await renderDetails(commit("aaaaaaa1", "First subject")); + await renderDetails(commit("bbbbbbb2", "Second subject")); + + await resolveDescription("aaaaaaa1", "Stale body from the first commit"); + expect(container.textContent).not.toContain("Stale body from the first commit"); + + await resolveDescription("bbbbbbb2", "Body of the second commit"); + expect(container.textContent).toContain("Second subject"); + expect(container.textContent).toContain("Body of the second commit"); + }); + + test("keeps the subject when the body cannot be read", async () => { + await renderDetails(commit("ccccccc3", "Subject survives")); + await resolveDescription("ccccccc3", null); + + expect(container.textContent).toContain("Subject survives"); + expect(container.textContent).toContain("ccccccc3"); + }); +}); diff --git a/windows/tauri/src/features/git/components/log/git-commit-details.tsx b/windows/tauri/src/features/git/components/log/git-commit-details.tsx new file mode 100644 index 000000000..872e28658 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-commit-details.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { getCommitDescription } from "../../api/git-commits-api"; +import type { GitCommit } from "../../types/git.types"; + +/** + * History rows carry only the subject. Reads the rest of the message for the + * inspected commit and ignores answers for a commit that is no longer shown. + */ +function useCommitDescription(repoPath: string | null, commit: GitCommit | null): string { + const hash = commit?.hash ?? null; + const knownDescription = commit?.description; + const [loaded, setLoaded] = useState<{ key: string; description: string } | null>(null); + const key = repoPath && hash ? JSON.stringify([repoPath, hash]) : null; + + useEffect(() => { + if (!repoPath || !hash || knownDescription !== undefined) return; + let current = true; + void getCommitDescription(repoPath, hash).then((description) => { + if (current && description !== null) { + setLoaded({ key: JSON.stringify([repoPath, hash]), description }); + } + }); + return () => { + current = false; + }; + }, [repoPath, hash, knownDescription]); + + if (knownDescription !== undefined) return knownDescription; + return loaded && loaded.key === key ? loaded.description : ""; +} + +/** Full message and metadata of the commit shown in the Git Log inspector. */ +export function GitCommitDetails({ + repoPath, + commit, +}: { + repoPath: string | null; + commit: GitCommit | null; +}) { + const { t } = useTranslation(); + const description = useCommitDescription(repoPath, commit); + + if (!commit) { + return ( +
+ {t("git.log.commitDetails")} +
+ ); + } + + return ( +
+
{commit.message}
+ {description ? ( +
{description}
+ ) : null} +
+ {commit.shortHash} · {commit.author} + {commit.email ? ` <${commit.email}>` : ""} +
+
{commit.date}
+ {commit.decorations ?
{commit.decorations}
: null} +
{commit.hash}
+
+ ); +} diff --git a/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx b/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx index ef8760b07..d0ff89ba9 100644 --- a/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx +++ b/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx @@ -14,6 +14,7 @@ import { resolveGitCommitSelectionDiff, type GitCommitSelectionDiff, } from "../../utils/git-commit-selection-diff"; +import { GitCommitDetails } from "./git-commit-details"; import { GitCommitFileTree } from "./git-commit-file-tree"; type FilesLoadState = "idle" | "loading" | "ready" | "failed"; @@ -163,31 +164,7 @@ export function GitCommitInspector({
- {commit ? ( -
-
{commit.message}
- {commit.description ? ( -
- {commit.description} -
- ) : null} -
- {commit.shortHash} · {commit.author} - {commit.email ? ` <${commit.email}>` : ""} -
-
{commit.date}
- {commit.decorations ? ( -
{commit.decorations}
- ) : null} -
- {commit.hash} -
-
- ) : ( -
- {t("git.log.commitDetails")} -
- )} +
diff --git a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx index 5f50d16c1..b81c471e4 100644 --- a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx +++ b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx @@ -32,6 +32,7 @@ import { rebaseOntoBranch, type IntegrationOutcome, } from "../../api/git-integration-api"; +import { withCommitDescription } from "../../api/git-commits-api"; import { deleteRemoteBranch, fetchChanges } from "../../api/git-remotes-api"; import { normalizeRepositoryPath } from "../../api/git-repo-api"; import { showGitRebaseDialog } from "../../services/git-rebase-dialog-service"; @@ -708,10 +709,15 @@ export function GitLogToolWindow() { onCopyHash={(commit) => void copyCommitText(commit.hash, t("git.log.commitHash"))} onCopyShortHash={(commit) => void copyCommitText(commit.shortHash, commit.shortHash)} onCopyMessage={(commit) => - void copyCommitText( - [commit.message, commit.description].filter(Boolean).join("\n\n"), - t("git.log.commitMessage"), - ) + void (async () => { + const detailed = repoPath + ? await withCommitDescription(repoPath, commit) + : commit; + await copyCommitText( + [detailed.message, detailed.description].filter(Boolean).join("\n\n"), + t("git.log.commitMessage"), + ); + })() } onEditMessage={(commit) => void editMessage(commit)} onUndo={(commit) => void undoCommit(commit)} 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 8dd191ff9..c005b3342 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 @@ -3,6 +3,7 @@ import { activateMainEditorPane } from "@/features/editor/stores/buffer-pane-syn import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useTranslation } from "@/i18n/locale-provider"; import { showAlertDialog } from "@/ui/dialog"; +import { withCommitDescription } from "../api/git-commits-api"; import { getCommitDiff, getFullContextFileDiff, @@ -309,7 +310,10 @@ export function useGitDiffActions({ return; } - const commit = commitByHash.get(commitHash); + const listedCommit = commitByHash.get(commitHash); + const commit = listedCommit + ? await withCommitDescription(activeRepoPath, listedCommit) + : undefined; const buffer = createCommitDiffBuffer({ repoPath: activeRepoPath, commitHash, From a658cd90778a6973b732a255ed37d242941f58d3 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 1 Oct 2026 16:54:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(git):=20Windows=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E8=AF=BB=E5=8F=96=E6=A0=A1=E9=AA=8C=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=20fixture=20(#771)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 消费者测试直接读取 commit-lookup-response-v1.json,确保两端 对 git.commit 的 body 字段使用同一份契约样例。 Co-Authored-By: Claude --- .../features/git/api/git-commits-api.test.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/windows/tauri/src/features/git/api/git-commits-api.test.ts b/windows/tauri/src/features/git/api/git-commits-api.test.ts index 87d6d6842..0c1668ab1 100644 --- a/windows/tauri/src/features/git/api/git-commits-api.test.ts +++ b/windows/tauri/src/features/git/api/git-commits-api.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { readFileSync } from "node:fs"; import * as gitEvents from "../events/git-events"; let gitWriteResult = { output: "", exitCode: 0 }; @@ -64,17 +65,17 @@ describe("Git commit message details", () => { // Regression for #771: history rows only carry the subject, so the full // message must come from the shared single-commit lookup. test("reads the body through git.commit and reuses it for the same commit", async () => { - commitLookupResult = { - commit: { hash: "detail-cached", subject: "Fix commit details" }, - body: "First line\n\n indented second paragraph", - }; - - expect(await getCommitDescription("C:/repo", "detail-cached")).toBe( - "First line\n\n indented second paragraph", - ); - expect(await getCommitDescription("C:/repo", "detail-cached")).toBe( - "First line\n\n indented second paragraph", - ); + // The shared fixture is the Rust Core response shape for `git.commit`. + const fixture = JSON.parse( + readFileSync( + new URL("../../../../../../shared/fixtures/git/commit-lookup-response-v1.json", import.meta.url), + "utf8", + ), + ) as { body: string }; + commitLookupResult = fixture; + + expect(await getCommitDescription("C:/repo", "detail-cached")).toBe(fixture.body); + expect(await getCommitDescription("C:/repo", "detail-cached")).toBe(fixture.body); const lookups = invoke.mock.calls.filter(([command]) => command === "git.commit"); expect(lookups).toEqual([["git.commit", { repoPath: "C:/repo", commit: "detail-cached" }]]); From 49de4c6c276b539db8bfcefc522e287674db64ee Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 1 Oct 2026 09:43:00 +0000 Subject: [PATCH 3/3] fix(git): discard stale commit message diff requests (#771) Guard both asynchronous reads against replaced requests, repository changes and unmount. Preserve the current loading state, settle test continuations, and run the commit API, details and diff ownership regressions in Windows CI. --- .github/workflows/ci-windows.yml | 4 + docs/development/platform-parity-matrix.csv | 2 +- docs/development/platform-parity-matrix.md | 2 +- shared/platform-feature-matrix.json | 5 +- .../log/git-commit-details.test.tsx | 2 + .../git/hooks/use-git-diff-actions.test.tsx | 154 ++++++++++++++++++ .../git/hooks/use-git-diff-actions.ts | 20 ++- 7 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 3f1ec085a..1814cd872 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -254,6 +254,10 @@ jobs: shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-paths.json -- src/features/git/api/git-repo-api.test.ts src/utils/path-helpers.test.ts + - name: Test Git commit messages and asynchronous diff ownership + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-commit-messages.json -- src/features/git/api/git-commits-api.test.ts src/features/git/components/log/git-commit-details.test.tsx src/features/git/hooks/use-git-diff-actions.test.tsx + - name: Test Git query recovery and isolation shell: pwsh timeout-minutes: 5 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index c390f3f3c..03f421b3f 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -33,7 +33,7 @@ git-status-commit,版本控制,Git,状态、暂存与提交,已实现,待验证, 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-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 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; windows/tauri/src/features/git/components/log/git-commit-details.tsx; windows/tauri/src/features/git/components/log/git-commit-details.test.tsx +git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。,,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 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 git-multi-repository-references,版本控制,Git,多仓库引用面板:分组、配色与引用操作,已实现,待验证,已实现,待验证,Git,在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。,,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 1002ee493..65b0cedc9 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -116,7 +116,7 @@ | 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 | **提交历史与图谱**
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` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 | | +| 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` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 | | | 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,确认路径、分支和安全检查。 | | | Git | **多仓库引用面板:分组、配色与引用操作**
git-multi-repository-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。 | | diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 663208a80..da98106f9 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -947,13 +947,14 @@ "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/components/log/git-commit-details.test.tsx", + "windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。" + "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。" }, { "id": "git-rebase-stash", diff --git a/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx b/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx index feec5fd29..fde8e19bf 100644 --- a/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx +++ b/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx @@ -49,6 +49,8 @@ afterEach(async () => { for (const spy of spies.splice(0)) spy.mockRestore(); await act(async () => { root.unmount(); + for (const resolve of descriptionResolvers.values()) resolve(null); + descriptionResolvers.clear(); await Promise.resolve(); }); container.remove(); diff --git a/windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx b/windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx new file mode 100644 index 000000000..e8419b43f --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import * as dialogs from "@/ui/dialog"; +import * as paneSync from "@/features/editor/stores/buffer-pane-sync"; +import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import * as commitsApi from "../api/git-commits-api"; +import * as diffApi from "../api/git-diff-api"; +import type { GitCommit, GitDiff } from "../types/git.types"; +import { useGitDiffActions } from "./use-git-diff-actions"; + +const commit = (hash: string): GitCommit => ({ + hash, + shortHash: hash, + parentHashes: [], + message: hash, + author: "Developer", + date: "2026/09/01", + decorations: "", +}); +const commits = new Map(["first", "second"].map((hash) => [hash, commit(hash)])); +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let originalAct: boolean | undefined; +let restoreDom: () => void; +let container: HTMLDivElement; +let root: Root; +let actions: ReturnType; +let openBuffer: ReturnType; +const spies: Array<{ mockRestore(): void }> = []; +const pending = new Map void>(); +const requests: Promise[] = []; + +function Harness({ repo }: { repo: string }) { + actions = useGitDiffActions({ + activeRepoPath: repo, + commitByHash: commits, + gitFileByPath: new Map(), + workingTreeDiffEntriesByScope: { all: [], staged: [], unstaged: [] }, + }); + return null; +} +async function render(repo = "C:/repo-a") { + await act(async () => { + root.render( + + + , + ); + }); +} +async function start(hash: string) { + await act(async () => { + requests.push(actions.viewCommitDiff(hash)); + await Promise.resolve(); + }); + expect(pending.has(hash)).toBe(true); +} +async function finish(hash: string) { + const release = pending.get(hash); + if (!release) throw new Error(`Missing pending message for ${hash}`); + await act(async () => { + release(); + await Promise.resolve(); + }); + pending.delete(hash); +} + +beforeEach(() => { + restoreDom = installHappyDom(); + originalAct = actGlobal.IS_REACT_ACT_ENVIRONMENT; + actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + openBuffer = spyOn(useBufferStore.getState().actions, "openBuffer").mockReturnValue( + "diff-buffer", + ); + spies.push( + openBuffer, + spyOn(dialogs, "showAlertDialog").mockResolvedValue(undefined), + spyOn(paneSync, "activateMainEditorPane").mockReturnValue(null), + spyOn(diffApi, "getCommitDiff").mockResolvedValue([ + { file_path: "file.txt", lines: [], is_new: false, is_deleted: false, is_renamed: false }, + ] satisfies GitDiff[]), + spyOn(commitsApi, "withCommitDescription").mockImplementation( + (_repo, selected) => + new Promise((resolve) => + pending.set(selected.hash, () => + resolve({ ...selected, description: `Body ${selected.hash}` }), + ), + ), + ), + ); +}); +afterEach(async () => { + try { + await act(async () => { + root.unmount(); + for (const release of pending.values()) release(); + await Promise.all(requests); + }); + } finally { + pending.clear(); + requests.length = 0; + for (const spy of spies.splice(0)) spy.mockRestore(); + container.remove(); + restoreDom(); + if (originalAct === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT; + else actGlobal.IS_REACT_ACT_ENVIRONMENT = originalAct; + } +}); + +test("opens the selected commit diff with its loaded message", async () => { + await render(); + await start("first"); + await finish("first"); + expect(openBuffer).toHaveBeenCalledTimes(1); + expect(openBuffer.mock.calls[0]?.[7]).toMatchObject({ + commitDescription: "Body first", + repoPath: "C:/repo-a", + }); + expect(actions.isLoadingCommitDiff).toBe(false); +}); +test("late message cannot open an old diff or clear the newer loading state", async () => { + await render(); + await start("first"); + await start("second"); + await finish("first"); + expect(openBuffer).not.toHaveBeenCalled(); + expect(actions.isLoadingCommitDiff).toBe(true); + await finish("second"); + expect(openBuffer).toHaveBeenCalledTimes(1); + expect(openBuffer.mock.calls[0]?.[7]).toMatchObject({ commitDescription: "Body second" }); +}); +test("switching repositories invalidates a pending message even when returning to the original", async () => { + await render(); + await start("first"); + await render("C:/repo-b"); + await render(); + await finish("first"); + expect(openBuffer).not.toHaveBeenCalled(); + expect(actions.isLoadingCommitDiff).toBe(false); +}); +test("unmount prevents a pending message from opening an editor", async () => { + await render(); + await start("first"); + await act(async () => { + root.render(null); + }); + await finish("first"); + expect(openBuffer).not.toHaveBeenCalled(); +}); 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 c005b3342..2f020d0e3 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 @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { activateMainEditorPane } from "@/features/editor/stores/buffer-pane-sync"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useTranslation } from "@/i18n/locale-provider"; @@ -93,6 +93,14 @@ export function useGitDiffActions({ ? latestFileDiffRequestRef.current : createRequestGeneration(); latestFileDiffRequestRef.current = latestFileDiffRequest; + const commitDiffRequests = useRef(createRequestGeneration()).current; + useEffect(() => { + setIsLoadingCommitDiff(false); + // Invalidate on repository changes (including A -> B -> A) and unmount. + return () => { + commitDiffRequests.begin(); + }; + }, [activeRepoPath, commitDiffRequests]); const activeRepoPathRef = useRef(activeRepoPath); activeRepoPathRef.current = activeRepoPath; @@ -297,9 +305,13 @@ export function useGitDiffActions({ async (commitHash: string, filePath?: string) => { if (!activeRepoPath) return; + const request = commitDiffRequests.begin(); + const isCurrent = () => + commitDiffRequests.isCurrent(request) && activeRepoPathRef.current === activeRepoPath; setIsLoadingCommitDiff(true); try { const diffs = await getCommitDiff(activeRepoPath, commitHash); + if (!isCurrent()) return; if (!diffs?.length) { await showAlertDialog( filePath @@ -314,6 +326,7 @@ export function useGitDiffActions({ const commit = listedCommit ? await withCommitDescription(activeRepoPath, listedCommit) : undefined; + if (!isCurrent()) return; const buffer = createCommitDiffBuffer({ repoPath: activeRepoPath, commitHash, @@ -323,6 +336,7 @@ export function useGitDiffActions({ }); openDiffBuffer(buffer.virtualPath, buffer.displayName, buffer.diffData); } catch (error) { + if (!isCurrent()) return; console.error("Error getting commit diff:", error); await showAlertDialog( t("git.diff.getCommitDiffFailed", { @@ -332,10 +346,10 @@ export function useGitDiffActions({ t("git.diff.title"), ); } finally { - setIsLoadingCommitDiff(false); + if (isCurrent()) setIsLoadingCommitDiff(false); } }, - [activeRepoPath, commitByHash, t], + [activeRepoPath, commitByHash, commitDiffRequests, t], ); const viewCommitRangeDiff = useCallback(