diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 34e2b1806..553b4d40f 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 10d45397c..0ca39b7c4 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 值一致。Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts; windows/tauri/src/features/git/hooks/use-commit-file-preview.ts; windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx +git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts; windows/tauri/src/features/git/components/log/git-commit-details.tsx; windows/tauri/src/features/git/components/log/git-commit-details.test.tsx; windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx; windows/tauri/src/features/git/hooks/use-commit-file-preview.ts; windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx git-rebase-stash,版本控制,Git,Rebase 与 Stash,已实现,待验证,已实现,待验证,Git,执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-worktrees,版本控制,Git,Worktree 管理,已实现,待验证,已实现,待验证,Git,列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git 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 adf6e4105..e241de98d 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/hooks/use-commit-file-preview.ts`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 | | +| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts`、`windows/tauri/src/features/git/components/log/git-commit-details.tsx`、`windows/tauri/src/features/git/components/log/git-commit-details.test.tsx`、`windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.ts`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 | | | Git | **Rebase 与 Stash**
git-rebase-stash | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。 | | | Git | **Worktree 管理**
git-worktrees | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 | | | 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 d00d9c518..b8c72da32 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -2062,7 +2062,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)?; @@ -2071,8 +2071,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. @@ -6807,12 +6819,14 @@ fn relative_or_absolute(path: &Path, root: &Path) -> String { mod tests { use super::{ annotation_message_from_tag_object, line_similarity, pair_diff_entries, parse_commit, - parse_diff, pathspec_batches, simplified_canonical_path, structured_diff_from_output, - DiffEntry, GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, + 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; @@ -7408,6 +7422,54 @@ 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(), + date_utc_offset_minutes: Some(480), + 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 55f834617..c7f7bcd26 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -721,6 +721,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 c60491319..1297342b1 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -433,7 +433,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `git.historyPage` | Return one bounded commit page, parent hashes, decorations, author dates with their UTC offset (`dateUtcOffsetMinutes`, east positive, `null` when unknown), 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 | @@ -1162,7 +1162,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..ddb22ca38 --- /dev/null +++ b/shared/fixtures/git/commit-lookup-response-v1.json @@ -0,0 +1,14 @@ +{ + "commit": { + "hash": "0123456789abcdef0123456789abcdef01234567", + "shortHash": "0123456", + "parentHashes": ["89abcdef0123456789abcdef0123456789abcdef"], + "authorName": "Lithe Test", + "authorEmail": "test@example.invalid", + "date": "2026/08/30 12:00", + "dateUtcOffsetMinutes": 480, + "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 c03b15b77..49e156519 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -946,6 +946,9 @@ "windows/tauri/src/features/git/utils/git-graph-layout.ts", "windows/tauri/src/features/git/utils/git-graph-colors.ts", "windows/tauri/src/features/git/utils/git-graph-layout.test.ts", + "windows/tauri/src/features/git/components/log/git-commit-details.tsx", + "windows/tauri/src/features/git/components/log/git-commit-details.test.tsx", + "windows/tauri/src/features/git/hooks/use-git-diff-actions.test.tsx", "windows/tauri/src/features/git/hooks/use-commit-file-preview.ts", "windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx" ], @@ -953,7 +956,7 @@ "verificationStatus": "pending" }, "owner": "Git", - "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。" + "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。" }, { "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..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 }; @@ -6,8 +7,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 +32,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 +47,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 () => { + // 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" }]]); + }); + + 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..fde8e19bf --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-commit-details.test.tsx @@ -0,0 +1,115 @@ +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(); + for (const resolve of descriptionResolvers.values()) resolve(null); + descriptionResolvers.clear(); + 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 fb1e2bccc..ee39475db 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, getFirstCommitFilePath } from "./git-commit-file-tree"; type FilesLoadState = "idle" | "loading" | "ready" | "failed"; @@ -187,31 +188,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 736e11ba8..030ff87e9 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"; @@ -716,10 +717,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.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 835c88e9d..68ff30824 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,8 +1,9 @@ -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"; import { showAlertDialog } from "@/ui/dialog"; +import { withCommitDescription } from "../api/git-commits-api"; import { getCommitDiff, getFullContextFileDiff, @@ -96,6 +97,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; const previewCommitFileDiff = useCommitFilePreview( @@ -305,9 +314,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 @@ -318,7 +331,11 @@ export function useGitDiffActions({ return; } - const commit = commitByHash.get(commitHash); + const listedCommit = commitByHash.get(commitHash); + const commit = listedCommit + ? await withCommitDescription(activeRepoPath, listedCommit) + : undefined; + if (!isCurrent()) return; const buffer = createCommitDiffBuffer({ repoPath: activeRepoPath, commitHash, @@ -328,6 +345,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", { @@ -337,10 +355,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(