From 5ff24e84b68fa70280faac4b40e8ab63751cbe79 Mon Sep 17 00:00:00 2001 From: waani Date: Fri, 25 Sep 2026 15:51:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(windows):=20Git=20Log=20=E5=A4=9A?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E4=BD=93=E9=AA=8C=E4=B8=8E=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多仓库工作区(如含多个独立仓库、或主仓库 + 多个 worktree)里的 Git 体验,围绕引用面板做了一组改进: - 仓库配色:多仓库时每个仓库一个稳定颜色,整组着色(浅底 + 左侧色带 + 文件夹/仓库名同色);单仓库不着色。 - 引用面板:隐藏 `refs/remotes/*/HEAD`(远程默认分支的符号别名,不是独立分支);移除顶部 `HEAD (Current Branch)` 行。 - 交互:点击已选中的引用不再重复加载;跨仓库选中引用只加载一次(把待选引用传给历史控制器,避免先切仓库加载默认分支、再加载目标分支两次);各仓库引用改为懒加载(展开分组才读取),读取串行以避免 common-dir 写租约冲突;读取失败显示错误并可重试。 - 自动发现:打开 Git Log 时触发工作区仓库发现;没有活动仓库时回退到发现到的第一个仓库,不再误判为"非 Git 仓库"。 - 分支图颜色按分支名稳定(移植 IDEA 的 name-hash 思路),不再随拓扑变化跳色。 - 右键操作补齐:提交菜单新增 `Revert Commit`、`Copy Short Hash`;引用菜单新增 `Copy Branch Name`。 - 项目树:git 仓库根目录右键新增 `Git` 子菜单(Open Git Log / Fetch / Pull… / Push… / New Branch…);下拉组件支持嵌套子菜单。 - Pull 弹窗重做:可选择要拉取的远程分支(默认当前上游)+ 策略(默认 Merge、默认折叠);指定分支走 `pullRemoteReference`,上游沿用原 workflow 并保留冲突/脏工作区/取消保护。 - 修复 blame 请求字段:之前发送 `rootPath`,平台只把 `repoPath` 映射为 core 的 `root`,导致 `missing field 'root'`。 - 测试基建:修正 api 测试里对 platform 模块的部分 mock 造成的跨文件污染,`src/features/git` 全量测试恢复全绿。 验证:`tsc --noEmit`;`bun test src/features/git`(339);`file-explorer/command-palette/ui/i18n`(47);`verify-windows-boundaries.ps1`;`verify-agent-notes.mjs`;`verify-test-stability`。 --- ...windows-git-log-multi-repository-groups.md | 56 ++- ...6-09-25-windows-git-graph-branch-colors.md | 68 ++++ docs/development/platform-parity-matrix.csv | 1 + docs/development/platform-parity-matrix.md | 9 +- shared/platform-feature-matrix.json | 22 ++ .../components/command-palette.tsx | 5 +- .../command-palette/constants/git-actions.tsx | 34 +- .../hooks/use-file-explorer-context-menu.tsx | 156 +++++++- .../lib/file-context-menu-git-items.test.ts | 74 ++++ .../lib/file-context-menu-git-items.ts | 90 +++++ .../features/git/api/git-blame-api.test.ts | 6 +- .../features/git/api/git-branches-api.test.ts | 3 +- .../features/git/api/git-commits-api.test.ts | 8 +- .../src/features/git/api/git-commits-api.ts | 6 + .../git/api/git-history-rewrite-api.test.ts | 3 +- .../git/api/git-integration-api.test.ts | 3 +- .../features/git/api/git-patch-api.test.ts | 3 +- .../src/features/git/api/git-push-api.test.ts | 3 +- .../features/git/api/git-rebase-api.test.ts | 3 +- .../features/git/api/git-remotes-api.test.ts | 3 +- .../src/features/git/api/git-remotes-api.ts | 31 +- .../src/features/git/api/git-repo-api.test.ts | 12 +- .../features/git/api/git-setup-api.test.ts | 3 +- .../git/api/git-worktrees-api.test.ts | 3 +- .../git/components/git-pull-dialog.test.tsx | 177 +++++++++ .../git/components/git-pull-dialog.tsx | 353 ++++++++++++++++++ .../components/git-pull-strategy-dialog.tsx | 94 ----- .../git/components/log/git-commit-table.tsx | 16 + .../git/components/log/git-graph-row.tsx | 6 +- .../components/log/git-log-tool-window.tsx | 29 +- ...it-reference-tree-lazy-references.test.tsx | 112 ++++++ .../log/git-reference-tree.test.tsx | 19 + .../git/components/log/git-reference-tree.tsx | 114 ++++-- .../git/hooks/git-pull-workflow.test.ts | 86 ++++- .../features/git/hooks/git-pull-workflow.ts | 32 +- .../git/hooks/use-git-history-mutations.ts | 25 +- ...se-git-log-controller.integration.test.tsx | 128 ++++++- .../git/hooks/use-git-log-controller.ts | 20 +- .../git/hooks/use-git-pull-workflow.ts | 38 +- .../use-git-workspace-references.test.tsx | 111 +++++- .../git/hooks/use-git-workspace-references.ts | 142 ++++--- .../git/services/git-pull-dialog-service.ts | 48 +++ .../features/git/utils/git-graph-colors.ts | 25 ++ .../git/utils/git-graph-layout.test.ts | 63 +++- .../features/git/utils/git-graph-layout.ts | 57 ++- .../git/utils/git-log-refresh.test.ts | 66 ++++ .../src/features/git/utils/git-log-refresh.ts | 36 +- .../git/utils/git-pull-selection.test.ts | 52 +++ .../features/git/utils/git-pull-selection.ts | 31 ++ .../git/utils/git-reference-tree.test.ts | 33 ++ .../features/git/utils/git-reference-tree.ts | 18 + .../git/utils/git-repository-colors.test.ts | 64 ++++ .../git/utils/git-repository-colors.ts | 52 +++ .../git/utils/git-repository-root.test.ts | 35 ++ .../features/git/utils/git-repository-root.ts | 20 + windows/tauri/src/i18n/locale.ts | 65 +++- windows/tauri/src/ui/dropdown.test.tsx | 35 ++ windows/tauri/src/ui/dropdown.tsx | 139 ++++++- windows/tauri/src/workbench-app.tsx | 4 +- 59 files changed, 2607 insertions(+), 343 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-25-windows-git-graph-branch-colors.md create mode 100644 windows/tauri/src/features/file-explorer/lib/file-context-menu-git-items.test.ts create mode 100644 windows/tauri/src/features/file-explorer/lib/file-context-menu-git-items.ts create mode 100644 windows/tauri/src/features/git/components/git-pull-dialog.test.tsx create mode 100644 windows/tauri/src/features/git/components/git-pull-dialog.tsx delete mode 100644 windows/tauri/src/features/git/components/git-pull-strategy-dialog.tsx create mode 100644 windows/tauri/src/features/git/components/log/git-reference-tree-lazy-references.test.tsx create mode 100644 windows/tauri/src/features/git/services/git-pull-dialog-service.ts create mode 100644 windows/tauri/src/features/git/utils/git-graph-colors.ts create mode 100644 windows/tauri/src/features/git/utils/git-pull-selection.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-pull-selection.ts create mode 100644 windows/tauri/src/features/git/utils/git-repository-colors.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-repository-colors.ts create mode 100644 windows/tauri/src/features/git/utils/git-repository-root.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-repository-root.ts diff --git a/.agents/notes/implemented/feature/2026-09-24-windows-git-log-multi-repository-groups.md b/.agents/notes/implemented/feature/2026-09-24-windows-git-log-multi-repository-groups.md index 19d7b53a8..b4f2f3e15 100644 --- a/.agents/notes/implemented/feature/2026-09-24-windows-git-log-multi-repository-groups.md +++ b/.agents/notes/implemented/feature/2026-09-24-windows-git-log-multi-repository-groups.md @@ -6,13 +6,19 @@ 在 Windows 版里,当一个工作区包含多个 Git 仓库时,Git Log 的引用树按仓库分组: 顶层每个仓库一个可折叠节点(仓库名 + 引用数),节点内部才是 Local / Remote / Tags。 -分支数据由前端遍历工作区已发现的仓库分别读取再聚合,Rust Core 的引用与历史命令 -仍是单仓库接口,`GitReference` 只在浏览器侧多带一个可选的 `repositoryPath`。 +分支数据由前端按仓库分别读取再聚合,Rust Core 的引用与历史命令仍是单仓库接口, +`GitReference` 只在浏览器侧多带一个可选的 `repositoryPath`。 点击其它仓库的分支会把活动仓库切换过去,再加载该分支的历史。 -聚合读取必须**串行**:解析仓库路径会执行一条持有"仓库级写租约"的 Git 命令,而该 -租约按 Git 公共目录(common dir)加锁。一个仓库的多个链接工作树共享同一个公共目录, +仓库引用是**按需加载**的:打开 Git Log 时只读活动仓库;其它仓库要等它在引用树里 +可见且展开时才读,读到后缓存,直到该仓库发生 Git 变更才刷新。这样打开大型多仓库 +工作区不再一次性发起 N 次 Git 读。 + +读取必须**串行**:解析仓库路径会执行一条持有"仓库级写租约"的 Git 命令,而该租约 +按 Git 公共目录(common dir)加锁。一个仓库的多个链接工作树共享同一个公共目录, 并发解析会互相报 "another Git write operation is running",导致工作树取不到引用而显示 0 条。 +按需加载不改变这一点:所有加载请求(活动仓库首读、展开时的按需读、变更刷新) +都排进同一条串行队列。 ## 问题 @@ -48,9 +54,24 @@ Windows 前端完成:新 hook `windows/tauri/src/features/git/hooks/use-git-wo 工作区里的链接工作树共享主仓库的 Git 公共目录。解析仓库路径(前端 `resolveRepositoryPath` → 原生 `git_discover_repo` → Core `git.command` 执行 `rev-parse --show-toplevel`)会持有按公共目录加锁的写租约;若并发解析同一公共目录 -下的多个工作树,除第一个外都会失败。因此 hook 按 `availableRepoPaths` **顺序**加载, -首轮和变更刷新都串行。工作树本身是合法仓库(Core 的发现明确返回 `.git` 文件形式的工作树), -修复方向是让读取不互相冲突,而不是把工作树从仓库列表里剔除。 +下的多个工作树,除第一个外都会失败。因此 `useGitWorkspaceReferences` 把所有读取 +请求排进一条 Promise 串行队列(`loadChainRef`),活动仓库首读、展开时的按需读、 +变更刷新都从同一条队列出队,任何时刻只有一个 Git 读在飞。工作树本身是合法仓库 +(Core 的发现明确返回 `.git` 文件形式的工作树),修复方向是让读取不互相冲突, +而不是把工作树从仓库列表里剔除。 + +### 只有活动仓库预先加载,其余按展开加载 + +首版实现一打开 Git Log 就遍历 `availableRepoPaths` 把所有仓库读一遍,仓库一多就是 +N 次串行 Git 读。现在 hook 只主动读**活动仓库**,其它仓库通过返回的 +`ensureRepository(repositoryPath)` 按需触发:引用树在某个仓库分组可见且未折叠时 +调用它,展开折叠的分组也会触发。已请求过的仓库记录在 `requestedRepositoryKeysRef` +里,重复请求直接返回,形成缓存;仓库从工作区移除时清掉该记录并取消它自己的在飞操作。 +Git 变更刷新只作用于已请求过的仓库。活动仓库改变或列表变化时,活动仓库会被重新 +确保加载。 + +活动仓库的引用同时由 `useGitLogController` 提供并被引用树直接使用,hook 侧对活动 +仓库的读取主要用于保持"活动仓库总是已加载"这一不变式和刷新语义一致。 ### 点击其它仓库的分支要切换活动仓库 @@ -70,21 +91,28 @@ pending 引用 ref,在 `repoPath` 变化后的 effect 里调用 `selectReferen - **让 Core 的仓库发现跳过 `.git` 文件(工作树)**:被否。Core 现有集成测试 `workspace_repositories_discovers_multiple_child_repositories` 明确要求发现工作树标记, 契约文档也写明 `.git` 目录和 `.git` 文件都算仓库标记;剔除工作树会破坏该既定行为。 +- **打开时一次性加载所有仓库(初版实现)**:被否。仓库越多首屏要串行等待越多, + 而大多数仓库分组是折叠的、用户不会去看;改为只预读活动仓库,其余按展开加载, + 代价是展开时多一次读。 ## 后果 -- 多仓库工作区现在能一次看到所有仓库的分支,并知道每条分支属于哪个仓库;工作树 - 作为独立仓库照常出现,且能看到与主仓库共享的分支。 +- 多仓库工作区能按仓库看到分支,并知道每条分支属于哪个仓库;工作树作为独立仓库 + 照常出现,且能看到与主仓库共享的分支。 - 单仓库用户不受影响:引用树不出现仓库层,行为与此前一致。 -- 各仓库引用顺序读取,仓库越多总耗时越长;换来的是同一公共目录下不互相抢租约。 -- 只在 `repositoryPaths.length > 1` 时走仓库分组与多仓库读取。 +- 打开 Git Log 只读活动仓库,其余仓库在首次展开时读一次并缓存;仓库越多, + 首屏越快,代价是展开新仓库时有一次可见延迟(先显示 0 条再填充)。 +- 各仓库引用仍顺序读取,换来的是同一公共目录下不互相抢租约。 +- 只在 `repositoryPaths.length > 1` 时走仓库分组与多仓库按需读取。 - macOS 端尚未做对应的引用树分组,行为与 Windows 暂不一致。 ## 验证 -- Windows 前端:`tsc --noEmit`;`bun test src/features/git`,含新增的 - `git-reference-tree.test.tsx`(单仓库保持扁平、多仓库按仓库分组)与 - `use-git-workspace-references.test.tsx`。 +- Windows 前端:`tsc --noEmit`;`bun test src/features/git`,含 + `git-reference-tree.test.tsx`(单仓库保持扁平、多仓库按仓库分组)、 + `git-reference-tree-lazy-references.test.tsx`(可见的展开分组触发加载、折叠分组 + 展开后才加载)与 `use-git-workspace-references.test.tsx`(只加载活动仓库、 + 按需加载、只刷新已加载仓库、错误重试)。 - Rust 未改动(Core 的仓库发现和引用契约保持原样),`cargo test --manifest-path rust/lithe-core/Cargo.toml` 无需针对本改动重跑。 - 手工:在工作区 `D:/workspace/work-code/op` 打开 Git Log,确认仓库分组、工作树 diff --git a/.agents/notes/implemented/feature/2026-09-25-windows-git-graph-branch-colors.md b/.agents/notes/implemented/feature/2026-09-25-windows-git-graph-branch-colors.md new file mode 100644 index 000000000..3719b5148 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-25-windows-git-graph-branch-colors.md @@ -0,0 +1,68 @@ +# Agent 笔记:Windows Git 提交图按分支名固定颜色 + +状态:已实现 + +关联:Windows 侧对齐 macOS 的 IDEA 风格图着色,见 +[`2026-09-13-macos-git-graph-intellij-layout.md`](2026-09-13-macos-git-graph-intellij-layout.md)。 + +## 先说结论 + +Windows Git 提交图不再按"第几条泳道"轮换颜色,而是用泳道所代表分支的引用名 +(Java `String.hashCode`)决定颜色。分支换到别的泳道、历史分页或拓扑变化时, +同一条分支保持同一种颜色;没有引用名的泳道用一个确定性的兜底序号。 + +## 问题 + +`windows/tauri/src/features/git/utils/git-graph-layout.ts` 原先把颜色按泳道槽位分配: +每新占一个槽位就 `colorIndex++`。泳道编号会随合并顺序、分页和可见提交集合变化, +于是同一条分支的颜色会跳变,用户看到的图与 macOS/IDEA 不一致。 + +## 决策 + +- 颜色身份来自引用名,不来自泳道序号。每条提交解析其装饰(`parseGitDecorations`), + 按 IDEA 的优先级选出"主引用":`origin/main`/`origin/master` → 其他远程分支 → + 本地 `main`/`master` → 其他本地分支 → tag → HEAD。 +- 用主引用名的 Java `String.hashCode`(按 UTF-16 code unit 计算,`git-graph-colors.ts` + 的 `javaStringHashCode`)对现有 6 色调色板取模,得到 `colorIndex` + (`graphColorIndexForName`)。复用 `git-graph-row.tsx` 原有的调色板与线宽, + 不引入新颜色或色相算法。 +- 颜色在**泳道建立时**确定:非首个父提交占新槽位时用该提交的主引用名上色; + 首个父提交延续当前泳道颜色(与 macOS 的图头片段沿用同一颜色一致)。 +- 没有引用名的提交(合并的第二父、快照外的父提交等)用自增兜底序号 + (`fallbackColorIndex`),只保证同输入同输出,不承诺跨拓扑稳定。 +- 调色板、`graphColor` 与哈希函数集中到 + `windows/tauri/src/features/git/utils/git-graph-colors.ts`,供布局与行渲染共用。 + +## 考虑过的备选方案 + +- **沿用 macOS 的整数 RGB → hue 方案(`GitGraphColor.swift`)**:能产生更多互不 + 相同的颜色,但会改变 Windows 现有的 6 色调色板与视觉语言,还需要跨平台同步 + 主题覆盖值。本次只要求颜色稳定,因此保留既有调色板,只改颜色身份来源。 +- **按提交 hash 取模**:实现最简单,但会让相邻的无关分支碰巧同色,历史一变化 + 颜色仍会跳。因此改为按引用名。 +- **未引用泳道也按某个假名哈希**:不如自增序号直观,也无法保证首帧稳定, + 故采用确定性兜底序号。 + +## 后果 + +- 同一条命名分支在分页、筛选、拓扑变化后颜色不变;颜色只取决于引用名。 +- 6 色调色板下不同分支仍可能撞色(这是既有视觉约束,非回归)。 +- `GitGraphEdge.colorIndex` / `GitGraphRow.incomingLaneColors` 的语义从"泳道序号" + 变为"调色板槽位",取值 0–5 或兜底序号;未引用的兜底序号与命名分支的槽位 + 可能重叠。 +- 单仓库、单分支等简单历史行为不变(颜色仍是调色板第 0 色时可能的等价结果)。 + +## 验证 + +- Windows 前端:`tsc --noEmit`;`bun test src/features/git`,其中 + `git-graph-layout.test.ts` 覆盖"同一分支名在不同拓扑落到不同泳道时颜色一致"、 + "未引用泳道确定性"、"Java `String.hashCode` 取值"与"主引用优先级选择"。 + +## 适用范围 + +- `windows/tauri/src/features/git/utils/git-graph-colors.ts` +- `windows/tauri/src/features/git/utils/git-graph-layout.ts` +- `windows/tauri/src/features/git/components/log/git-graph-row.tsx` + +不改变 Rust Core、JSON 契约或 macOS 实现;Windows 的布局仍是泳道槽位算法, +不是 macOS 的仓库级永久图。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index eb4d1ad5e..32c4425ae 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -16,6 +16,7 @@ git-diff-review,版本控制,Git,Diff 与变更审查,已实现,待验证,已实 git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git 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 github-prs,协作,GitHub,Pull Request 列表与详情,已实现,待验证,已实现,待验证,GitHub,使用测试仓库验证 PR 列表、筛选、详情、分支比较和浏览器跳转。,,macos/Sources/Lithe/Views/GitHub; macos/Sources/Lithe/Services/GitHub,windows/tauri/src/features/github; shared/contracts/application-boundary.md github-reviews-comments,协作,GitHub,Review 与评论,已实现,待验证,已实现,待验证,GitHub,创建、查看和回复 Review/评论,确认权限错误和网络失败不会丢失草稿。,,macos/Sources/Lithe/Views/GitHub,windows/tauri/src/features/github ai-commit-generation,AI,AI 提交信息,Provider 配置与提交信息生成,已实现,待验证,已实现,待验证,Git / AI,用相同 diff、规则和 Provider 配置比较请求计划、取消、错误和生成文本。,,macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift; macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/git/services/ai-commit-service.ts; shared/contracts/ai-commit.md diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 95bb45575..ce123d87c 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-24 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:81 -- macOS:实现:✅ 69 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 72 待验证,— 9 不适用 -- Windows:实现:✅ 75 已实现,🟡 4 部分实现,❌ 2 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 79 待验证,— 2 不适用 +- 功能项:82 +- macOS:实现:✅ 70 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 73 待验证,— 9 不适用 +- Windows:实现:✅ 76 已实现,🟡 4 部分实现,❌ 2 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 80 待验证,— 2 不适用 ## 实现状态定义 @@ -76,7 +76,7 @@
-版本控制 · 6 个能力点 +版本控制 · 7 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | @@ -86,6 +86,7 @@ | Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致。 | | | 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 f30667690..371385b93 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -442,6 +442,28 @@ "owner": "Git", "verification": "列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。" }, + { + "id": "git-multi-repository-references", + "area": "版本控制", + "group": "Git", + "capability": "多仓库引用面板:分组、配色与引用操作", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Git" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/git" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Git", + "verification": "在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。" + }, { "id": "github-prs", "area": "协作", diff --git a/windows/tauri/src/features/command-palette/components/command-palette.tsx b/windows/tauri/src/features/command-palette/components/command-palette.tsx index b982f044a..fd042bcc0 100644 --- a/windows/tauri/src/features/command-palette/components/command-palette.tsx +++ b/windows/tauri/src/features/command-palette/components/command-palette.tsx @@ -12,8 +12,9 @@ import { useFileSystemStore } from "@/features/file-system/stores/file-system.st import { LocalHistoryCommandContent } from "@/features/local-history/components/local-history-command"; import { OutlineCommandContent } from "@/features/outline/components/outline-command"; import { commitChanges } from "@/features/git/api/git-commits-api"; -import { fetchChanges, pullChanges } from "@/features/git/api/git-remotes-api"; +import { fetchChanges } from "@/features/git/api/git-remotes-api"; import { showGitPushDialog } from "@/features/git/services/git-push-dialog-service"; +import { showGitPullDialog } from "@/features/git/services/git-pull-dialog-service"; import { discardAllChanges, stageAllFiles, @@ -318,7 +319,7 @@ const CommandPaletteContent = ({ commandPaletteInitialView }: CommandPaletteCont unstageAllFiles, commitChanges, showGitPushDialog, - pullChanges, + showGitPullDialog, fetchChanges, discardAllChanges, }, diff --git a/windows/tauri/src/features/command-palette/constants/git-actions.tsx b/windows/tauri/src/features/command-palette/constants/git-actions.tsx index 38eb9f10a..0e88b453f 100644 --- a/windows/tauri/src/features/command-palette/constants/git-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/git-actions.tsx @@ -12,7 +12,7 @@ import { ArrowClockwiseIcon as RefreshCw, } from "@/ui/icons"; import type { GitRemoteActionResult } from "@/features/git/api/git-remotes-api"; -import { getGitPullResultPresentation } from "@/features/git/utils/git-pull-result-presentation"; +import type { GitPullDialogResult } from "@/features/git/services/git-pull-dialog-service"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { createTranslator } from "@/i18n/locale"; import { showConfirmDialog, showPromptDialog } from "@/ui/dialog"; @@ -31,7 +31,7 @@ interface GitActionsParams { unstageAllFiles: (path: string) => Promise; commitChanges: (path: string, message: string) => Promise; showGitPushDialog: (path: string) => Promise; - pullChanges: (path: string) => Promise; + showGitPullDialog: (path: string) => Promise; fetchChanges: (path: string) => Promise; discardAllChanges: (path: string) => Promise; }; @@ -369,39 +369,15 @@ export const createGitActions = (params: GitActionsParams): Action[] => { description: "Pull changes from remote", icon: , category: "Git", - action: async () => { + action: () => { if (!repoPath) { showToast({ message: t("git.noRepositoryOpen"), type: "error" }); onClose(); return; } - try { - showToast({ message: t("git.pullingChanges"), type: "info" }); - const result = await gitOperations.pullChanges(repoPath); - if (result.success) { - showToast({ message: t("git.pulledChanges"), type: "success" }); - } else { - const presentation = result.pullResult - ? getGitPullResultPresentation(result.pullResult, t) - : null; - const requiresStrategy = result.pullResult?.status === "cancelled"; - showToast({ - message: - presentation?.message || - (requiresStrategy ? t("git.pullResult.strategyRequired") : result.error) || - t("git.pullResult.pullFailed", { - error: t("git.pullResult.gitRejectedPull"), - }), - type: - presentation?.tone === "info" || presentation?.tone === "warning" - ? "info" - : "error", - }); - } - } catch (error) { - showToast({ message: t("git.operationError", { error: String(error) }), type: "error" }); - } onClose(); + // The shared Pull dialog owns branch, strategy, and result reporting. + void gitOperations.showGitPullDialog(repoPath); }, }, { diff --git a/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx b/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx index 8f1773f2f..8a92edfbf 100644 --- a/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx +++ b/windows/tauri/src/features/file-explorer/hooks/use-file-explorer-context-menu.tsx @@ -9,9 +9,12 @@ import { FileTextIcon as FileText, FolderOpenIcon as FolderOpen, FolderPlusIcon as FolderPlus, + GitBranchIcon as GitBranch, + GitGraphIcon as GitGraph, ImageIcon, InfoIcon as Info, LinkIcon as Link, + DownloadIcon as Download, ArrowClockwiseIcon as RefreshCw, ScissorsIcon as Scissors, MagnifyingGlassIcon as Search, @@ -36,10 +39,20 @@ import { useFileClipboardStore } from "@/features/file-explorer/stores/file-expl import { useFileTreeStore } from "@/features/file-explorer/stores/file-explorer-tree.store"; import { pasteIntoExplorerDirectory } from "@/features/file-explorer/lib/paste-into-explorer-directory"; import { JavaClipboardPasteError } from "@/features/file-explorer/lib/paste-java-class-from-clipboard"; +import { buildGitRepositoryContextMenuItems } from "@/features/file-explorer/lib/file-context-menu-git-items"; +import { createAndCheckoutBranch } from "@/features/git/api/git-branches-api"; +import { normalizeRepositoryPath } from "@/features/git/api/git-repo-api"; +import { fetchChanges } from "@/features/git/api/git-remotes-api"; +import { emitGitChanged } from "@/features/git/events/git-events"; +import { showGitPushDialog } from "@/features/git/services/git-push-dialog-service"; +import { showGitPullDialog } from "@/features/git/services/git-pull-dialog-service"; +import { useRepositoryStore } from "@/features/git/stores/git-repository.store"; +import { isGitRepositoryRoot } from "@/features/git/utils/git-repository-root"; +import { useUIState } from "@/features/window/stores/ui-state.store"; import type { ContextMenuState } from "@/features/file-system/types/app.types"; import { Button } from "@/ui/button"; import { Dropdown, type MenuItem } from "@/ui/dropdown"; -import Dialog from "@/ui/dialog"; +import Dialog, { showPromptDialog } from "@/ui/dialog"; import { toast } from "sonner"; import { useTranslation } from "@/i18n/locale-provider"; import { getBaseName, getDirName, getRelativePath, joinPath } from "@/utils/path-helpers"; @@ -122,7 +135,110 @@ export function useFileExplorerContextMenu({ null, ); const [propertiesDialog, setPropertiesDialog] = useState(null); + const [isGitOperationRunning, setIsGitOperationRunning] = useState(false); const clipboardActions = useFileClipboardStore.getState().actions; + const availableRepoPaths = useRepositoryStore.use.availableRepoPaths(); + const selectRepository = useRepositoryStore.use.actions().selectRepository; + + const openGitLogForRepository = useCallback( + (path: string) => { + selectRepository(path); + const uiState = useUIState.getState(); + uiState.setBottomPaneActiveTab("gitLog"); + uiState.setIsBottomPaneVisible(true); + }, + [selectRepository], + ); + + const fetchRepository = useCallback( + (path: string) => { + selectRepository(path); + void (async () => { + setIsGitOperationRunning(true); + const progressToast = toast.info(t("git.fetchingChanges"), { duration: 0 }); + try { + const result = await fetchChanges(path); + toast.dismiss(progressToast); + if (result.success) toast.success(t("git.changesFetched")); + else toast.error(result.error || t("git.fetchFailed")); + } catch (error) { + toast.dismiss(progressToast); + toast.error(error instanceof Error ? error.message : t("git.fetchFailed")); + } finally { + setIsGitOperationRunning(false); + } + })(); + }, + [selectRepository, t], + ); + + const pullRepository = useCallback( + (path: string) => { + selectRepository(path); + void (async () => { + setIsGitOperationRunning(true); + try { + const repoPath = normalizeRepositoryPath(path); + // The shared Pull dialog is keyed by the requested repository, so this + // action and the Git panels use the same workflow and result reporting. + await showGitPullDialog(repoPath, { + refresh: async () => { + emitGitChanged({ + repoPath, + scopes: ["working-tree", "history", "refs", "remotes"], + source: "pull-finished", + }); + }, + }); + } finally { + setIsGitOperationRunning(false); + } + })(); + }, + [selectRepository], + ); + + const pushRepository = useCallback( + (path: string) => { + selectRepository(path); + void (async () => { + setIsGitOperationRunning(true); + try { + await showGitPushDialog(path); + } finally { + setIsGitOperationRunning(false); + } + })(); + }, + [selectRepository], + ); + + const createRepositoryBranch = useCallback( + (path: string) => { + selectRepository(path); + void (async () => { + const name = await showPromptDialog(t("git.log.branchNamePrompt"), { + title: t("git.contextMenu.newBranch"), + confirmLabel: t("git.newBranch"), + }); + const branchName = name?.trim(); + if (!branchName) return; + + setIsGitOperationRunning(true); + try { + await createAndCheckoutBranch(path, branchName, "HEAD"); + toast.success(t("git.log.branchCreated", { name: branchName })); + } catch (error) { + toast.error( + error instanceof Error ? error.message : t("git.log.branchCreateFailed"), + ); + } finally { + setIsGitOperationRunning(false); + } + })(); + }, + [selectRepository, t], + ); const createEnvTemplateFile = useCallback( async (sourcePath: string, targetFileName: string, options?: { overwrite?: boolean }) => { @@ -271,6 +387,37 @@ export function useFileExplorerContextMenu({ }, ); + if (isGitRepositoryRoot(contextMenu.path, availableRepoPaths)) { + items.push( + ...buildGitRepositoryContextMenuItems({ + labels: { + submenu: t("git.contextMenu.submenu"), + openGitLog: t("git.contextMenu.openGitLog"), + fetch: t("git.contextMenu.fetch"), + pull: t("git.contextMenu.pull"), + push: t("git.contextMenu.push"), + newBranch: t("git.contextMenu.newBranch"), + }, + actions: { + openGitLog: () => openGitLogForRepository(contextMenu.path), + fetch: () => fetchRepository(contextMenu.path), + pull: () => pullRepository(contextMenu.path), + push: () => pushRepository(contextMenu.path), + newBranch: () => createRepositoryBranch(contextMenu.path), + }, + icons: { + submenu: , + openGitLog: , + fetch: , + pull: , + push: , + newBranch: , + }, + disabled: isGitOperationRunning, + }), + ); + } + if (onGenerateImage) { items.push({ id: "generate-image", @@ -503,10 +650,15 @@ export function useFileExplorerContextMenu({ return items; }, [ + availableRepoPaths, canRemoveWorkspaceRootPath, clipboardActions, contextMenu, createEnvTemplateFile, + createRepositoryBranch, + fetchRepository, + isGitOperationRunning, + openGitLogForRepository, onCreateNewFolderInDirectory, onCreateNewFileInDirectory, onDeleteRequested, @@ -522,6 +674,8 @@ export function useFileExplorerContextMenu({ onStartInlineEditing, onUploadFile, isWorkspaceRootPath, + pullRepository, + pushRepository, rootFolderPath, t, ]); diff --git a/windows/tauri/src/features/file-explorer/lib/file-context-menu-git-items.test.ts b/windows/tauri/src/features/file-explorer/lib/file-context-menu-git-items.test.ts new file mode 100644 index 000000000..670c3f239 --- /dev/null +++ b/windows/tauri/src/features/file-explorer/lib/file-context-menu-git-items.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { buildGitRepositoryContextMenuItems } from "./file-context-menu-git-items"; + +const labels = { + submenu: "Git", + openGitLog: "Open Git Log", + fetch: "Fetch", + pull: "Pull…", + push: "Push…", + newBranch: "New Branch…", +}; + +const noop = () => {}; + +describe("buildGitRepositoryContextMenuItems", () => { + test("builds a single Git submenu with the expected actions", () => { + const items = buildGitRepositoryContextMenuItems({ + labels, + actions: { + openGitLog: noop, + fetch: noop, + pull: noop, + push: noop, + newBranch: noop, + }, + }); + + expect(items).toHaveLength(1); + expect(items[0].id).toBe("git"); + expect(items[0].label).toBe("Git"); + expect(items[0].children?.map((item) => item.id)).toEqual([ + "git-open-log", + "git-fetch", + "git-pull", + "git-push", + "git-new-branch", + ]); + expect(items[0].children?.map((item) => item.label)).toEqual([ + "Open Git Log", + "Fetch", + "Pull…", + "Push…", + "New Branch…", + ]); + }); + + test("disables the submenu and every action while an operation runs", () => { + const items = buildGitRepositoryContextMenuItems({ + labels, + actions: { openGitLog: noop, fetch: noop, pull: noop, push: noop, newBranch: noop }, + disabled: true, + }); + + expect(items[0].disabled).toBe(true); + expect(items[0].children?.every((item) => item.disabled)).toBe(true); + }); + + test("routes each action to the matching callback", () => { + const calls: string[] = []; + const items = buildGitRepositoryContextMenuItems({ + labels, + actions: { + openGitLog: () => calls.push("openGitLog"), + fetch: () => calls.push("fetch"), + pull: () => calls.push("pull"), + push: () => calls.push("push"), + newBranch: () => calls.push("newBranch"), + }, + }); + + for (const item of items[0].children ?? []) item.onClick(); + expect(calls).toEqual(["openGitLog", "fetch", "pull", "push", "newBranch"]); + }); +}); diff --git a/windows/tauri/src/features/file-explorer/lib/file-context-menu-git-items.ts b/windows/tauri/src/features/file-explorer/lib/file-context-menu-git-items.ts new file mode 100644 index 000000000..69e0cfa88 --- /dev/null +++ b/windows/tauri/src/features/file-explorer/lib/file-context-menu-git-items.ts @@ -0,0 +1,90 @@ +import type { ReactNode } from "react"; +import type { MenuItem } from "@/ui/dropdown"; + +export interface GitContextMenuLabels { + submenu: string; + openGitLog: string; + fetch: string; + pull: string; + push: string; + newBranch: string; +} + +export interface GitContextMenuActions { + openGitLog: () => void; + fetch: () => void; + pull: () => void; + push: () => void; + newBranch: () => void; +} + +export interface GitContextMenuIcons { + submenu?: ReactNode; + openGitLog?: ReactNode; + fetch?: ReactNode; + pull?: ReactNode; + push?: ReactNode; + newBranch?: ReactNode; +} + +export interface GitContextMenuOptions { + labels: GitContextMenuLabels; + actions: GitContextMenuActions; + icons?: GitContextMenuIcons; + disabled?: boolean; +} + +/** The `Git` submenu shown for a repository-root directory in the project tree. */ +export function buildGitRepositoryContextMenuItems({ + labels, + actions, + icons, + disabled = false, +}: GitContextMenuOptions): MenuItem[] { + return [ + { + id: "git", + label: labels.submenu, + icon: icons?.submenu, + disabled, + onClick: () => {}, + children: [ + { + id: "git-open-log", + label: labels.openGitLog, + icon: icons?.openGitLog, + disabled, + onClick: actions.openGitLog, + }, + { + id: "git-fetch", + label: labels.fetch, + icon: icons?.fetch, + disabled, + onClick: actions.fetch, + }, + { + id: "git-pull", + label: labels.pull, + icon: icons?.pull, + disabled, + onClick: actions.pull, + }, + { + id: "git-push", + label: labels.push, + icon: icons?.push, + disabled, + onClick: actions.push, + }, + { + id: "git-new-branch", + label: labels.newBranch, + icon: icons?.newBranch, + disabled, + onClick: actions.newBranch, + }, + ], + }, + ]; +} diff --git a/windows/tauri/src/features/git/api/git-blame-api.test.ts b/windows/tauri/src/features/git/api/git-blame-api.test.ts index da77280a6..f82ec7674 100644 --- a/windows/tauri/src/features/git/api/git-blame-api.test.ts +++ b/windows/tauri/src/features/git/api/git-blame-api.test.ts @@ -6,7 +6,11 @@ const invoke = mock(async (command: string, _args?: unknown): Promise = throw new Error(`Unexpected command: ${command}`); }); -mock.module("@/platform/tauri-core", () => ({ invoke })); +// Spread the real module so the override stays scoped to `invoke`; a partial +// mock would strip the sibling exports (Channel, …) from this shared module +// registry for every other test file. +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { getResolvedGitBlame } = await import("./git-blame-api"); const { clearRepositoryDiscoveryCache } = await import("./git-repo-api"); diff --git a/windows/tauri/src/features/git/api/git-branches-api.test.ts b/windows/tauri/src/features/git/api/git-branches-api.test.ts index febf32905..065f088f7 100644 --- a/windows/tauri/src/features/git/api/git-branches-api.test.ts +++ b/windows/tauri/src/features/git/api/git-branches-api.test.ts @@ -7,7 +7,8 @@ const invoke = mock(async (command: string): Promise => let gitWriteResult: { output?: string; exitCode?: number } | null = null; const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); -mock.module("@/platform/tauri-core", () => ({ invoke })); +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { checkoutGitReference, 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 e2b54ba10..f5a6d61e7 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 @@ -18,7 +18,8 @@ const invoke = mock(async (command: string, _args?: unknown): Promise = return null; }); -mock.module("@/platform/tauri-core", () => ({ invoke })); +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { cherryPickCommit, @@ -27,6 +28,7 @@ const { getGitReferences, getGitReferencesAtRoot, resetToCommit, + revertCommit, } = await import("./git-commits-api"); beforeEach(() => { @@ -83,15 +85,17 @@ describe("Git commit history reads", () => { }); describe("Git commit history mutations", () => { - test("sends typed reset, cherry-pick, and selected commit requests", async () => { + test("sends typed reset, cherry-pick, revert, and selected commit requests", async () => { await resetToCommit("C:/repo", "a1", "mixed"); await cherryPickCommit("C:/repo", "d4"); + await revertCommit("C:/repo", "e5"); await commitSelectedChanges("C:/repo", "selected", ["new.txt", "changed.txt"]); const writes = invoke.mock.calls.filter(([command]) => command === "git.write"); expect(writes).toEqual([ ["git.write", { repoPath: "C:/repo", operation: "reset", revision: "a1", mode: "--mixed" }], ["git.write", { repoPath: "C:/repo", operation: "cherryPick", revision: "d4" }], + ["git.write", { repoPath: "C:/repo", operation: "revert", revision: "e5" }], [ "git.write", { 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 368e3b104..68214f704 100644 --- a/windows/tauri/src/features/git/api/git-commits-api.ts +++ b/windows/tauri/src/features/git/api/git-commits-api.ts @@ -271,3 +271,9 @@ export const cherryPickCommit = (repoPath: string, revision: string): Promise undefined); + +export const revertCommit = (repoPath: string, revision: string): Promise => + runHistoryMutation(repoPath, "revert-commit", { + operation: "revert", + revision, + }).then(() => undefined); diff --git a/windows/tauri/src/features/git/api/git-history-rewrite-api.test.ts b/windows/tauri/src/features/git/api/git-history-rewrite-api.test.ts index b0af7f27f..c9f829d2a 100644 --- a/windows/tauri/src/features/git/api/git-history-rewrite-api.test.ts +++ b/windows/tauri/src/features/git/api/git-history-rewrite-api.test.ts @@ -15,7 +15,8 @@ const invoke = mock(async (command: string, _args?: unknown): Promise = if (command === "git.write") return result; return null; }); -mock.module("@/platform/tauri-core", () => ({ invoke })); +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { executeGitHistoryRewrite, getGitHistoryRewritePreview } = await import("./git-history-rewrite-api"); diff --git a/windows/tauri/src/features/git/api/git-integration-api.test.ts b/windows/tauri/src/features/git/api/git-integration-api.test.ts index 23ab10c65..3f755f104 100644 --- a/windows/tauri/src/features/git/api/git-integration-api.test.ts +++ b/windows/tauri/src/features/git/api/git-integration-api.test.ts @@ -5,7 +5,8 @@ import type { GitOperationState } from "../types/git.types"; const invoke = mock(async (_command: string, _args?: unknown): Promise => null); const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); -mock.module("@/platform/tauri-core", () => ({ invoke })); +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { checkoutAndRebase, diff --git a/windows/tauri/src/features/git/api/git-patch-api.test.ts b/windows/tauri/src/features/git/api/git-patch-api.test.ts index 8b2ffa687..8aa042b67 100644 --- a/windows/tauri/src/features/git/api/git-patch-api.test.ts +++ b/windows/tauri/src/features/git/api/git-patch-api.test.ts @@ -11,7 +11,8 @@ const invoke = mock(async (command: string, _args?: unknown): Promise = } return { patch: "", files: [], byteLength: 0 }; }); -mock.module("@/platform/tauri-core", () => ({ invoke })); +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { applyGitPatch, exportGitPatch } = await import("./git-patch-api"); beforeEach(() => { diff --git a/windows/tauri/src/features/git/api/git-push-api.test.ts b/windows/tauri/src/features/git/api/git-push-api.test.ts index 56a6cf913..0c9e0ec48 100644 --- a/windows/tauri/src/features/git/api/git-push-api.test.ts +++ b/windows/tauri/src/features/git/api/git-push-api.test.ts @@ -31,7 +31,8 @@ const invoke = mock(async (command: string): Promise => { }); const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); -mock.module("@/platform/tauri-core", () => ({ invoke })); +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { executeGitPush, getGitPushPreview } = await import("./git-push-api"); diff --git a/windows/tauri/src/features/git/api/git-rebase-api.test.ts b/windows/tauri/src/features/git/api/git-rebase-api.test.ts index a42f361e4..12ba3d977 100644 --- a/windows/tauri/src/features/git/api/git-rebase-api.test.ts +++ b/windows/tauri/src/features/git/api/git-rebase-api.test.ts @@ -11,7 +11,8 @@ const invoke = mock( ? "C:/repo" : { command: { exitCode: 0, warnings: [] }, session: fixture.sessionResponse }, ); -mock.module("@/platform/tauri-core", () => ({ invoke })); +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { controlGitRebase, startGitRebase } = await import("./git-rebase-api"); beforeEach(() => { invoke.mockClear(); diff --git a/windows/tauri/src/features/git/api/git-remotes-api.test.ts b/windows/tauri/src/features/git/api/git-remotes-api.test.ts index 8c6d1acc7..d145a8fa0 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.test.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.test.ts @@ -5,7 +5,8 @@ import type { GitPullPreflight, GitReference } from "../types/git.types"; const invoke = mock(async (_command: string, _args?: unknown): Promise => null); const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); -mock.module("@/platform/tauri-core", () => ({ invoke })); +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { deleteRemoteBranch, diff --git a/windows/tauri/src/features/git/api/git-remotes-api.ts b/windows/tauri/src/features/git/api/git-remotes-api.ts index 271789859..1260edb45 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.ts @@ -11,7 +11,7 @@ import { GitPullWorkflow } from "../hooks/git-pull-workflow"; import { runGitRead } from "../runtime/git-read-coordinator"; import { getBranches } from "./git-branches-api"; import { getGitHistory } from "./git-commits-api"; -import { getOperationState } from "./git-integration-api"; +import { getOperationState, pullRemoteReference } from "./git-integration-api"; import { executeGitPush } from "./git-push-api"; import { toCoreGitReference } from "./git-reference-payload"; import { getGitStatus } from "./git-status-api"; @@ -163,6 +163,30 @@ export const fetchChanges = async (repoPath: string, fetchOptions?: GitFetchOpti } }; +/** + * Pulls an explicit remote branch into the current branch. The Core integration + * preflight inside `pullRemoteReference` owns dirty-worktree blocking here, so a + * blocked outcome is surfaced as a failed pull for the shared workflow to report. + */ +const pullReferenceIntoCurrent = async ( + repoPath: string, + strategy: PullStrategy, + reference: GitReference, +): Promise => { + const outcome = await pullRemoteReference( + repoPath, + reference, + strategy === "rebase" ? "rebase" : "merge", + ); + if (outcome.status === "clean") return { success: true }; + if (outcome.status === "blocked") { + return { success: false, error: outcome.blockingPaths.join(", ") }; + } + if (outcome.status === "conflicts") return { success: false, error: "conflict" }; + if (outcome.status === "stopped") return { success: false, error: "stopped" }; + return { success: false, error: outcome.message }; +}; + const pullWorkflows = new Map(); /** Returns the shared Pull coordinator for one repository. */ @@ -173,7 +197,10 @@ export const getGitPullWorkflow = (repoPath: string): GitPullWorkflow => { const workflow = new GitPullWorkflow({ fetch: fetchChanges, preflight: getPullPreflight, - pull: executePullChanges, + pull: (repoPath, strategy, reference) => + reference + ? pullReferenceIntoCurrent(repoPath, strategy, reference) + : executePullChanges(repoPath, strategy), operationState: getOperationState, }); pullWorkflows.set(repoPath, workflow); diff --git a/windows/tauri/src/features/git/api/git-repo-api.test.ts b/windows/tauri/src/features/git/api/git-repo-api.test.ts index 6eee53d44..30cba621c 100644 --- a/windows/tauri/src/features/git/api/git-repo-api.test.ts +++ b/windows/tauri/src/features/git/api/git-repo-api.test.ts @@ -3,8 +3,16 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; const invoke = mock(async (_command: string, _args?: unknown): Promise => null); const readDirectory = mock(async (_path: string): Promise => []); -mock.module("@/platform/tauri-core", () => ({ invoke })); -mock.module("@/features/file-system/controllers/platform", () => ({ readDirectory })); +// Spread the real modules so each override stays scoped to a single export; +// a partial mock would strip the sibling exports (Channel, createDirectory, …) +// for every other test sharing this module registry. +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); +const platformModule = await import("@/features/file-system/controllers/platform"); +mock.module("@/features/file-system/controllers/platform", () => ({ + ...platformModule, + readDirectory, +})); const { clearRepositoryDiscoveryCache, diff --git a/windows/tauri/src/features/git/api/git-setup-api.test.ts b/windows/tauri/src/features/git/api/git-setup-api.test.ts index 050f9cfac..094f885be 100644 --- a/windows/tauri/src/features/git/api/git-setup-api.test.ts +++ b/windows/tauri/src/features/git/api/git-setup-api.test.ts @@ -8,7 +8,8 @@ const invoke = mock(async (_command: string, _payload?: unknown) => { if (rejected) throw new Error("Configuration is locked"); return fixture.unborn; }); -mock.module("@/platform/tauri-core", () => ({ invoke })); +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { getGitRepositorySetup, initializeGitRepository, configureGitIdentity } = await import("./git-setup-api"); beforeEach(() => { diff --git a/windows/tauri/src/features/git/api/git-worktrees-api.test.ts b/windows/tauri/src/features/git/api/git-worktrees-api.test.ts index b06a903fe..d50445768 100644 --- a/windows/tauri/src/features/git/api/git-worktrees-api.test.ts +++ b/windows/tauri/src/features/git/api/git-worktrees-api.test.ts @@ -6,7 +6,8 @@ const invoke = mock( ); const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); -mock.module("@/platform/tauri-core", () => ({ invoke })); +const tauriCoreModule = await import("@/platform/tauri-core"); +mock.module("@/platform/tauri-core", () => ({ ...tauriCoreModule, invoke })); const { addWorktreeFromReference, createWorktree, removeWorktree } = await import("./git-worktrees-api"); diff --git a/windows/tauri/src/features/git/components/git-pull-dialog.test.tsx b/windows/tauri/src/features/git/components/git-pull-dialog.test.tsx new file mode 100644 index 000000000..a9158cda2 --- /dev/null +++ b/windows/tauri/src/features/git/components/git-pull-dialog.test.tsx @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { act, type ReactNode } 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 { GitPullPreflight, GitPullResult, GitReference } from "../types/git.types"; +import type { GitPullWorkflowOptions } from "../hooks/git-pull-workflow"; + +let restoreDom: () => void; +let originalCustomEvent: typeof globalThis.CustomEvent; +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let originalActEnvironment: boolean | undefined; +const spies: Array<{ mockRestore: () => void }> = []; + +const preflight: GitPullPreflight = { + upstream: "origin/main", + ahead: 1, + behind: 2, + diverged: true, + hasLocalChanges: false, +}; + +const reference = (shortName: string): GitReference => ({ + fullName: `refs/remotes/${shortName}`, + shortName, + kind: "remote", + peelsToCommit: true, + isCurrent: false, +}); + +const remotesApi = await import("../api/git-remotes-api"); +const commitsApi = await import("../api/git-commits-api"); + +const run = mock( + async (_repoPath: string, _options: GitPullWorkflowOptions): Promise => ({ + status: "pulled", + strategy: "merge", + }), +); + +let container: HTMLDivElement; +let root: Root; + +const findButton = (label: string): HTMLButtonElement => { + const button = [...document.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + if (!button) throw new Error(`Button "${label}" not found`); + return button; +}; + +beforeEach(() => { + restoreDom = installHappyDom(); + originalCustomEvent = globalThis.CustomEvent; + originalActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT; + Object.defineProperty(globalThis, "CustomEvent", { + configurable: true, + writable: true, + value: window.CustomEvent, + }); + actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + run.mockClear(); + spies.push( + spyOn(remotesApi, "getPullPreflight").mockResolvedValue(preflight), + spyOn(commitsApi, "getGitReferencesAtRoot").mockResolvedValue({ + references: [reference("origin/main"), reference("origin/release")], + recentReferences: [], + }), + spyOn(remotesApi, "getGitPullWorkflow").mockImplementation( + () => ({ run } as unknown as ReturnType), + ), + ); + 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(); + // Flush React's scheduler before the happy-dom globals are torn down. + await Promise.resolve(); + }); + if (originalActEnvironment === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT; + else actGlobal.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment; + container.remove(); + if (originalCustomEvent) { + Object.defineProperty(globalThis, "CustomEvent", { + configurable: true, + writable: true, + value: originalCustomEvent, + }); + } else { + Reflect.deleteProperty(globalThis, "CustomEvent"); + } + restoreDom(); +}); + +const renderHost = async (): Promise => { + const { GitPullDialogHost } = await import("./git-pull-dialog"); + await act(async () => { + root.render( + + + as ReactNode, + ); + }); +}; + +const openDialog = async (repoPath: string) => { + const { showGitPullDialog } = await import("../services/git-pull-dialog-service"); + let promise: Promise | null = null; + await act(async () => { + promise = showGitPullDialog(repoPath); + // Let the dialog load preflight and references before assertions. + await Promise.resolve(); + await Promise.resolve(); + }); + return () => promise as Promise<{ status: string }>; +}; + +describe("Pull dialog", () => { + test("preselects the upstream and shows the collapsed merge default", async () => { + await renderHost(); + await openDialog("C:/repo"); + + expect(document.body.textContent).toContain("Pull from"); + expect(document.body.textContent).toContain("origin/main"); + expect(document.body.textContent).toContain("Strategy: Merge"); + // The advanced choices stay collapsed until the user expands the section. + expect(document.body.textContent).not.toContain("Fast-forward only"); + expect(document.body.textContent).not.toContain("Rebase"); + expect(document.body.textContent).toContain("Local ahead"); + expect(document.body.textContent).toContain("Remote ahead"); + }); + + test("reveals the strategy choices only after expanding the section", async () => { + await renderHost(); + await openDialog("C:/repo"); + + await act(async () => { + findButton("Strategy: Merge").click(); + }); + + expect(document.body.textContent).toContain("Fast-forward only"); + expect(document.body.textContent).toContain("Rebase"); + }); + + test("pulls the configured upstream through the shared workflow with the chosen strategy", async () => { + await renderHost(); + const result = await openDialog("C:/repo"); + + await act(async () => { + findButton("Pull").click(); + await Promise.resolve(); + }); + + expect(run).toHaveBeenCalledWith("C:/repo", { + refresh: expect.any(Function), + strategy: "merge", + }); + await expect(result()).resolves.toEqual({ status: "pulled" }); + }); + + test("resolves as cancelled when the user dismisses the dialog", async () => { + await renderHost(); + const result = await openDialog("C:/repo"); + + await act(async () => { + findButton("Cancel").click(); + }); + + await expect(result()).resolves.toEqual({ status: "cancelled" }); + expect(run).not.toHaveBeenCalled(); + }); +}); diff --git a/windows/tauri/src/features/git/components/git-pull-dialog.tsx b/windows/tauri/src/features/git/components/git-pull-dialog.tsx new file mode 100644 index 000000000..d5725a811 --- /dev/null +++ b/windows/tauri/src/features/git/components/git-pull-dialog.tsx @@ -0,0 +1,353 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/ui/dialog"; +import Badge from "@/ui/badge"; +import { RadioGroup, RadioGroupItem } from "@/ui/radio-group"; +import Select from "@/ui/select"; +import { Spinner } from "@/ui/spinner"; +import { CaretDownIcon, CaretRightIcon, WarningCircleIcon as WarningCircle } from "@/ui/icons"; +import { cn } from "@/utils/cn"; +import { getGitReferencesAtRoot } from "../api/git-commits-api"; +import { getGitPullWorkflow, getPullPreflight } from "../api/git-remotes-api"; +import { + attachGitPullDialogHost, + type GitPullDialogRequest, + type GitPullDialogResult, +} from "../services/git-pull-dialog-service"; +import type { GitPullPreflight, GitPullResult, GitReference, PullStrategy } from "../types/git.types"; +import { getGitPullResultPresentation } from "../utils/git-pull-result-presentation"; +import { + canUseFastForward, + defaultPullReference, + defaultPullStrategy, + isUpstreamPullTarget, +} from "../utils/git-pull-selection"; + +type LoadState = "loading" | "ready" | "failed"; + +const toDialogStatus = (result: GitPullResult): GitPullDialogResult["status"] => { + switch (result.status) { + case "pulled": + return "pulled"; + case "blocked": + return "blocked"; + case "conflict": + return "conflict"; + case "failed": + return "failed"; + default: + return "cancelled"; + } +}; + +const Str = (value: unknown): string => (typeof value === "string" ? value : String(value)); + +function GitPullDialog({ + request, + onComplete, +}: { + request: GitPullDialogRequest; + onComplete: (result: GitPullDialogResult) => void; +}) { + const { t } = useTranslation(); + const [loadState, setLoadState] = useState("loading"); + const [loadError, setLoadError] = useState(null); + const [preflight, setPreflight] = useState(null); + const [references, setReferences] = useState([]); + const [selectedFullName, setSelectedFullName] = useState(null); + const [strategy, setStrategy] = useState("merge"); + const [isStrategyExpanded, setIsStrategyExpanded] = useState(false); + const [isPulling, setIsPulling] = useState(false); + + const load = useCallback(async () => { + setLoadState("loading"); + setLoadError(null); + try { + const [nextPreflight, snapshot] = await Promise.all([ + getPullPreflight(request.repoPath), + getGitReferencesAtRoot(request.repoPath, `pull-dialog-${request.id}`).catch(() => null), + ]); + const branches = [ + ...new Map( + (snapshot?.references ?? []) + .filter((reference) => reference.kind === "remote") + .map((reference) => [reference.fullName, reference] as const), + ).values(), + ].sort((left, right) => left.shortName.localeCompare(right.shortName)); + + setPreflight(nextPreflight); + setReferences(branches); + const initial = defaultPullReference(branches, nextPreflight); + setSelectedFullName(initial?.fullName ?? null); + setStrategy(defaultPullStrategy()); + setLoadState("ready"); + } catch (error) { + setLoadError(error instanceof Error ? error.message : String(error)); + setLoadState("failed"); + } + }, [request]); + + useEffect(() => { + void load(); + }, [load]); + + const selectedReference = useMemo( + () => references.find((reference) => reference.fullName === selectedFullName) ?? null, + [references, selectedFullName], + ); + const isUpstream = !!preflight && isUpstreamPullTarget(selectedReference, preflight); + const fastForwardValid = !!preflight && canUseFastForward(preflight, selectedReference); + + const branchOptions = useMemo( + () => + references.map((reference) => ({ + value: reference.fullName, + label: reference.shortName, + accessory: + !!preflight && isUpstreamPullTarget(reference, preflight) ? ( + + {t("git.pullDialog.upstreamBadge")} + + ) : undefined, + })), + [references, preflight, t], + ); + + const ahead = isUpstream ? (preflight?.ahead ?? 0) : (selectedReference?.ahead ?? null); + const behind = isUpstream ? (preflight?.behind ?? 0) : (selectedReference?.behind ?? null); + const hasAheadBehind = ahead !== null && behind !== null; + + const strategyOptions = useMemo< + Array<{ value: PullStrategy; label: string; description: string; disabled: boolean }> + >( + () => [ + { + value: "ffOnly", + label: t("git.pullDialog.strategy.ffOnly"), + description: t("git.pullDialog.strategy.ffOnlyHint"), + disabled: !fastForwardValid, + }, + { + value: "merge", + label: t("git.merge"), + description: t("git.pullDialog.strategy.mergeHint"), + disabled: false, + }, + { + value: "rebase", + label: t("git.rebase"), + description: t("git.pullDialog.strategy.rebaseHint"), + disabled: false, + }, + ], + [fastForwardValid, t], + ); + + const selectedStrategyLabel = + strategyOptions.find((option) => option.value === strategy)?.label ?? ""; + + const chooseReference = (fullName: string) => { + setSelectedFullName(fullName); + const nextReference = references.find((reference) => reference.fullName === fullName) ?? null; + if (preflight && strategy === "ffOnly" && !canUseFastForward(preflight, nextReference)) { + setStrategy("merge"); + } + }; + + const submit = async () => { + if (isPulling || !selectedReference || !preflight) return; + setIsPulling(true); + try { + const workflow = getGitPullWorkflow(request.repoPath); + const result = await workflow.run(request.repoPath, { + refresh: request.refresh ?? (async () => {}), + strategy, + ...(isUpstream ? {} : { reference: selectedReference }), + }); + const presentation = getGitPullResultPresentation(result, t); + if (presentation) toast[presentation.tone](presentation.message); + onComplete({ status: toDialogStatus(result) }); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("git.operationFailed")); + onComplete({ status: "failed" }); + } + }; + + return ( + { + if (!open && !isPulling) onComplete({ status: "cancelled" }); + }} + > + + + {t("git.pullDialog.title")} + + {t("git.pullDialog.description")} + + + + {loadState === "loading" ? ( +
+ + {t("git.pullDialog.loading")} +
+ ) : loadState === "failed" ? ( +
+ + {loadError || t("git.pullDialog.loadFailed")} +
+ ) : ( +
+
+ + {branchOptions.length > 0 ? ( +