diff --git a/.agents/notes/implemented/feature/2026-09-24-macos-git-log-multi-repository-groups.md b/.agents/notes/implemented/feature/2026-09-24-macos-git-log-multi-repository-groups.md index 7c0eeca55..57df59ff5 100644 --- a/.agents/notes/implemented/feature/2026-09-24-macos-git-log-multi-repository-groups.md +++ b/.agents/notes/implemented/feature/2026-09-24-macos-git-log-multi-repository-groups.md @@ -15,6 +15,12 @@ Local / Remote / Tags。只有一个仓库时,渲染和以前完全一样, 只有“活动仓库”那一个分组可以执行分支写操作。其它仓库的分组是**只读**的:可以点选 某一行把它切成活动仓库,但右键不再弹出任何菜单,避免在 B 仓库的行上误操作 A 仓库。 +同一块引用面板还给每个仓库配一个颜色:工作区里有多个仓库时,仓库节点显示一个色点、 +该仓库下的每条引用行左侧有一条细色条;只有一个仓库时完全不出现颜色,和改动前一致。 +这次同时把引用行右键菜单补齐到 IntelliJ IDEA 的常见项:`Copy Branch Name`,以及 +“Tracking Branch”子菜单(列出远程分支来设置上游、带一个清除项);提交右键的“重置到 +此处”也补齐了 soft / mixed / hard 三档。 + ## 问题 工作区下可以有多个并列仓库,其中主仓库还常带一批链接工作树(位于主仓库目录内的 @@ -104,6 +110,69 @@ rename / delete)都只针对一个活动仓库,闭包直接从活动 `gitRep 与标题、闭包和本地化解耦,因此“只读仓库不提供任何条目”这条规则可以在没有 SwiftUI 宿主的情况下直接单元测试。视图只负责把每个条目映射成真实的 `LitheContextMenuItem`。 +### 每个仓库一个颜色,定位用“完整仓库列表”而不是“可见仓库列表” + +多仓库时,仓库节点带一个 8 色色点(`GitRepositoryColor`,定义在 +`macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift`),该仓库下的每条引用行左侧 +有一条 2.5px 色条,方便一眼看出某条引用属于哪个仓库。 + +颜色按仓库在 `feature.availableRepositoryRoots` 里的**位置**取 +`palette[index % 8]`,按标准化路径匹配,而不是按“当前可见的仓库”重新编号。原因是 +`availableRepositoryRoots` 是**工作树过滤之前**的完整顺序列表:如果按可见集合编号, +隐藏工作树时剩余仓库的颜色会整体前移、颜色跟着行漂移,用户会以为引用换了仓库。正确做法 +是把完整列表传给 `GitRepositoryColor.index(for:in:)`;错误做法是用 +`GitRepositoryHierarchy.visibleRepositoryRoots(...)` 的结果去定色。 + +颜色只在可见仓库数 `> 1` 时出现:仓库节点判断 `GitRepositoryColor.isVisible(for:)` +(即完整列表 `count > 1`),单仓库布局把 `repositoryColorIndex` 传 `nil`,因此单仓库 +工作区的渲染和改动前完全一样,没有色点也没有色条。 + +`GitReferenceRowView`(`GitLogView.swift`)因此新增 `repositoryColorIndex: Int?` 入参, +并且**必须纳入它的自定义 `==`**:只有把色号纳入相等比较,隐藏 / 显示工作树导 +致某行的仓库色号变化时,那一行才会重建。存的是 `Int?` 色号而不是解析后的 `Color`, +是因为 `Color` 在这个部署目标(macOS 13)上不是稳定可比较的值,色号是纯数据、比较可靠。 + +### 引用行菜单补齐 Copy Branch Name 与 Tracking Branch + +`GitReferenceRowMenu.entries(...)` 新增两个动作:本地分支和远程分支都加 `copyBranchName` +(标签不加),本地分支加 `trackingBranch`。视图侧:`copyBranchName` 直接把 +`reference.shortName` 写进 `NSPasteboard`;`trackingBranch` 展开成一个子菜单,数据来自 +活动仓库的远程引用(`feature.gitReferences.filter { $0.kind == .remote }`),已配置上游时 +先显示当前上游和一个“Stop Tracking Branch”清除项,没有远程分支时显示禁用的占位项。 + +写操作复用 Rust Core 已经支持的 `setUpstream` / `unsetUpstream`(见 +`rust/lithe-core/src/git/mod.rs`,Windows 端 `git-branches-api.ts` 已在用),macOS 侧只补 +接线:`GitOperations` 协议 + `RustGitOperations` 实现(`setUpstream(branch:to:)` 把上游 +引用和分支名传给 Core),`GitService`、`GitFeatureModel.setUpstream` / +`unsetUpstream` 依次透传。**没有改动 Rust Core**,也没有新增 Core 命令。 + +### 引用行的刷新比较必须包含远程分支 + +`GitReferenceRowView` 以 `.equatable()` 渲染,只有 `==` 判不相等的行才会重建。Tracking Branch +子菜单读的是活动仓库的远程分支列表,所以这份列表必须参与相等判断:否则一次只增删远程分支的 +`refs` 刷新(本地分支、选中和执行状态都没变)不会让行失效,子菜单会继续用建行时捕获的旧列表 +——远程分支已删还列着,或明明有远程分支却仍显示 “No Remote Branches”。 + +原来 `remoteBranches` 放在 `GitReferenceRowActions` 里,而 `==` 刻意忽略这个结构(它的闭包每次 +body 都会重建,比较它会让所有行每帧都重建)。现在把它从 `actions` 移出,成为 +`GitReferenceRowView` 自己的值属性;同时把整条相等契约抽成 `GitReferenceRowRenderKey` +(定义在 `GitReferenceRows.swift`),`==` 只比较这个 key。**新增任何被行或菜单读取的字段,都要 +加进 key,否则改了对 UI 没有影响。** + +非活动仓库分组的行是只读的、只触发选中,菜单不出现,所以它们传空列表,避免无谓的行重建。 + +### 提交右键的重置支持 soft / mixed / hard + +`GitFeatureModel.resetCurrentBranch(to:)` 以前写死 `--mixed`,现在加 +`mode: GitResetMode = .mixed` 参数(`GitResetMode` 定义在 `GitModels.swift`,`argument` +返回 `--soft` / `--mixed` / `--hard`)。默认值保持 `.mixed`,老调用点行为不变。 + +提交右键原来是一个“Reset Current Branch to Here…”项,现在改成同名的**子菜单**, +列出 soft / mixed / hard 三项,对应 Windows 的 `resetToCommit(repoPath, revision, mode)`。 +三档都走既有的确认对话框;hard 的确认按钮标记为 `destructive`(`GitCommitOperationKind` +新增 `isDestructive`),并把确认文案改成“丢弃工作区更改”,确保硬重置需要用户明确确认。 +`GitGraphRowActions.onReset` 的签名相应变成 `(GitCommit, GitResetMode) -> Void`。 + ## 考虑过的备选方案 - **在 Core 增加“聚合所有仓库引用”的新命令**:被否。把呈现层的仓库身份引入稳定契约,还要 @@ -119,6 +188,13 @@ rename / delete)都只针对一个活动仓库,闭包直接从活动 `gitRep “先隐式切仓库”会让一次点击产生用户没预期的活动仓库变更,失败时更难回滚;只读更简单也更安全。 - **给写操作闭包传入目标 `repositoryRoot`**:被否。写操作链路上游(对话框、待处理请求)都以活动 仓库为上下文,逐个改签名会把仓库身份扩散到调用链各处;在菜单层直接不提供更集中。 +- **按“当前可见仓库列表”的序号给仓库上色**:被否。隐藏工作树会让剩余仓库的色号整体前移, + 同一行的颜色会漂移;必须用 `availableRepositoryRoots`(过滤前完整列表)定位。 +- **在 Core 新增一个 macOS 专用的设置上游命令**:被否。Core 的 `setUpstream` / `unsetUpstream` + 已经存在且被 Windows 使用,macOS 只需接线。 +- **在引用行里保存解析后的 `Color` 而不是色号**:被否。部署目标是 macOS 13,`SwiftUI.Color` + 在那里不是可靠可比较的值,放进 `GitReferenceRowView.==` 会导致刷新判断不稳定;存 `Int?` + 色号更可靠,也顺带让单仓库的“无颜色”状态可表达。 ## 后果 @@ -130,6 +206,10 @@ rename / delete)都只针对一个活动仓库,闭包直接从活动 `gitRep - 只在可见仓库数 `> 1` 时走分组渲染;开关关闭时被隐藏的是工作树仓库,活动仓库始终保留。 - 引用面板的 Local / Remote / Tags 展开状态在所有仓库间共享(与 Windows 一致),同名分组 (如 `feature`)的折叠状态在不同仓库间也是共享的——这是对 Windows 行为的对齐,非缺陷。 +- 多仓库时每条引用行多一条 2.5px 色条、每个仓库节点多一个色点;单仓库工作区没有这些视觉元素。 + 颜色取自固定 8 色,超过 8 个仓库时按位置回绕,因此两个相距 8 个位置的仓库会同色。 +- 引用行菜单新增 Copy Branch Name(本地 / 远程)和 Tracking Branch 子菜单;提交右键的重置 + 从单项变成 soft / mixed / hard 子菜单。这些项都复用现有写操作,不增加新的 Core 命令。 ## 验证 @@ -146,25 +226,49 @@ rename / delete)都只针对一个活动仓库,闭包直接从活动 `gitRep 旧刷新返回后不能覆盖新页)。 - `macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift` 的 `Git reference row menu` 套件 校验菜单策略:只读行返回空条目,活动行的本地 / 远程 / 标签菜单项与启用状态符合预期, - 分支操作进行中时写操作项被禁用。 + 分支操作进行中时写操作项被禁用,并新增 Copy Branch Name(本地 / 远程有、标签无)与 + Tracking Branch(仅本地有)两条策略断言。 +- `macos/Tests/LitheTests/GitReferenceRowRenderKeyTests.swift` 校验行的刷新契约:只增 / 只删 + 远程分支时 `GitReferenceRowRenderKey` 必须判不相等(保证子菜单重建),远程分支列表不变时 + 判相等(保证不白重建),行自身字段变化时仍然判不相等。 +- `macos/Tests/LitheTests/GitRepositoryColorTests.swift` 校验配色:8 色互不相同且不透明, + N 个仓库拿到互不相同的色号,隐藏中间仓库时后续仓库色号不变(证明按完整仓库列表定色), + 超过 8 个仓库回绕,单仓库不启用颜色,未知仓库回退到 0 号色。 +- `macos/Tests/LitheTests/ContextMenuCoverageTests.swift` 更新提交右键断言:重置现在是子菜单, + 可执行 mixed / hard 两项并回调到 `onReset(commit, mode)`。 +- `macos/Tests/LitheTests/AppLocalizationTests.swift` 的英 / 中对照校验扩展到新增的 + Copy Branch Name、Tracking Branch、Stop Tracking Branch、No Remote Branches 与 + soft / mixed / hard 三档重置的标题和确认文案。 +- 上游写操作复用 Core 已有的 `setUpstream` / `unsetUpstream`(`rust/lithe-core` 内已有测试 + `git_write` 覆盖),macOS 侧只做接线,不新增 Rust 改动。 - git 控件本地化词条(含新增的两个开关文案)纳入 `macos/Tests/LitheTests/AppLocalizationTests.swift` 的英 / 中对照校验。 - 完整 macOS 编译与测试需要在 macOS 上运行:`./scripts/test-macos.sh`。 - `./scripts/verify-service-boundaries.sh`。 - **限制**:本次改动没有 macOS 工具链可用,Swift 代码和 UI 均未在本机编译或手工验证; - 上述 Swift 测试由 macOS CI 执行。UI(仓库分组、工作树开关、跨仓库选中)未手工验证。 + 上述 Swift 测试由 macOS CI 执行。UI(仓库分组、工作树开关、跨仓库选中、仓库配色、新增 + 菜单项)未手工验证。 ## 适用范围 - `macos/Sources/LitheGitModule/Models/GitModels.swift` - `macos/Sources/LitheGitModule/Application/GitFeatureModel.swift` +- `macos/Sources/LitheGitModule/Services/GitService.swift` +- `macos/Sources/Lithe/Core/Rust/RustGitOperations.swift` - `macos/Sources/Lithe/Views/Git/GitLogView.swift` - `macos/Sources/Lithe/Views/Git/GitReferenceRows.swift` +- `macos/Sources/Lithe/Views/Git/GitGraphView.swift` +- `macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift` - `macos/Resources/en.lproj/Localizable.strings` - `macos/Resources/zh-Hans.lproj/Localizable.strings` - `macos/Tests/LitheGitModuleTests/GitModuleTests.swift` -- `macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift` +- `macos/Tests/LitheTests/GitReferenceRowRenderKeyTests.swift` +- `macos/Tests/LitheTests/GitRepositoryColorTests.swift` +- `macos/Tests/LitheTests/ContextMenuCoverageTests.swift` - `macos/Tests/LitheTests/AppLocalizationTests.swift` +上游写操作复用 `rust/lithe-core/src/git/mod.rs` 已有的 `setUpstream` / `unsetUpstream`, +本笔记不改变这些命令的入参或契约。 + 不改变 `git.references`、`git.historyPage`、`workspace.repositories` 的 JSON 契约;不改提交图, 也不改顶部 `BranchSwitcherPopover`(仍为单仓库)。 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 38add1855..b60a21b45 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 4c28d1650..69ea0a859 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/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 911a08301..8ba06481f 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -501,10 +501,14 @@ "Reset current branch?" = "Reset current branch?"; "Cherry-pick" = "Cherry-pick"; "Revert" = "Revert"; +"Reset (Soft)" = "Reset (Soft)"; "Reset (Mixed)" = "Reset (Mixed)"; +"Reset (Hard)" = "Reset (Hard)"; "Apply %@ to the current branch." = "Apply %@ to the current branch."; "Create a new commit that reverses %@." = "Create a new commit that reverses %@."; +"Move the current branch to %@ and keep changes staged." = "Move the current branch to %@ and keep changes staged."; "Move the current branch to %@ and keep changes unstaged." = "Move the current branch to %@ and keep changes unstaged."; +"Move the current branch to %@ and discard all working-tree changes." = "Move the current branch to %@ and discard all working-tree changes."; "Delete branch?" = "Delete branch?"; "Merge branch?" = "Merge branch?"; "Rebase branch?" = "Rebase branch?"; @@ -680,6 +684,7 @@ "Compare current branch with working tree" = "Compare current branch with working tree"; "Continue" = "Continue"; "Copy" = "Copy"; +"Copy Branch Name" = "Copy Branch Name"; "Copy Path / Reference" = "Copy Path / Reference"; "Copy Relative Path" = "Copy Relative Path"; "Delete Branch" = "Delete Branch"; @@ -702,6 +707,7 @@ "Hide Git tool window" = "Hide Git tool window"; "Hide commit decorations" = "Hide commit decorations"; "Highlight words" = "Highlight words"; +"Hard Reset (Discard Changes)" = "Hard Reset (Discard Changes)"; "Later" = "Later"; "Lithe Shelves" = "Lithe Shelves"; "Loading branches…" = "Loading branches…"; @@ -715,6 +721,7 @@ "Me" = "Me"; "Merge" = "Merge"; "Merge into Current Branch" = "Merge into Current Branch"; +"Mixed Reset (Keep Changes Unstaged)" = "Mixed Reset (Keep Changes Unstaged)"; "New Branch" = "New Branch"; "New Branch from '%@'…" = "New Branch from '%@'…"; "New Branch…" = "New Branch…"; @@ -724,6 +731,7 @@ "No differences" = "No differences"; "No matching branches" = "No matching branches"; "No matching users" = "No matching users"; +"No Remote Branches" = "No Remote Branches"; "No saved changes" = "No saved changes"; "No textual diff available" = "No textual diff available"; "OK" = "OK"; @@ -772,6 +780,7 @@ "Smart Checkout (Shelve)" = "Smart Checkout (Shelve)"; "Smart Checkout shelves your changes in Lithe, switches branch, then restores them. Force Checkout switches and discards them." = "Smart Checkout shelves your changes in Lithe, switches branch, then restores them. Force Checkout switches and discards them."; "Smart Checkout stashes your changes, switches branch, then restores them. Force Checkout switches and discards them." = "Smart Checkout stashes your changes, switches branch, then restores them. Force Checkout switches and discards them."; +"Soft Reset (Keep Changes Staged)" = "Soft Reset (Keep Changes Staged)"; "Stage File" = "Stage File"; "Stage all changes" = "Stage all changes"; "Stage file" = "Stage file"; @@ -780,6 +789,7 @@ "Stash or shelf changes here to switch branches safely." = "Stash or shelf changes here to switch branches safely."; "Stashing sets these changes aside, runs the operation, then restores them. If conflicts stop the operation, the changes stay stashed until you finish it." = "Stashing sets these changes aside, runs the operation, then restores them. If conflicts stop the operation, the changes stay stashed until you finish it."; "Step %lld of %lld" = "Step %lld of %lld"; +"Stop Tracking Branch" = "Stop Tracking Branch"; "Tag or revision" = "Tag or revision"; "Tags" = "Tags"; "The repository will be opened in detached HEAD state." = "The repository will be opened in detached HEAD state."; @@ -788,6 +798,7 @@ "This sends the selected local branch to its configured remote." = "This sends the selected local branch to its configured remote."; "Title" = "Title"; "Toggle preview" = "Toggle preview"; +"Tracking Branch" = "Tracking Branch"; "Uncommitted changes block this cherry-pick" = "Uncommitted changes block this cherry-pick"; "Uncommitted changes block this merge" = "Uncommitted changes block this merge"; "Uncommitted changes block this rebase" = "Uncommitted changes block this rebase"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 905b7d4c4..c8dfec1e9 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -363,6 +363,7 @@ "Local changes would be overwritten" = "本地更改将被覆盖"; "Your changes to these files conflict with '%@':" = "你对这些文件的更改与“%@”冲突:"; "Smart Checkout stashes your changes, switches branch, then restores them. Force Checkout switches and discards them." = "智能检出会先贮藏你的更改,切换分支后再恢复。强制检出会直接切换并丢弃这些更改。"; +"Soft Reset (Keep Changes Staged)" = "软重置(保留暂存更改)"; "Smart Checkout" = "智能检出"; "Force Checkout" = "强制检出"; "Merge in progress" = "正在合并"; @@ -374,6 +375,7 @@ "Continue Cherry-pick" = "继续拣选"; "Continue Revert" = "继续还原"; "Step %lld of %lld" = "第 %lld 步,共 %lld 步"; +"Stop Tracking Branch" = "停止跟踪分支"; "Resolve %lld conflicted file(s), stage them, then continue." = "请解决 %lld 个冲突文件并暂存,然后继续。"; "All conflicts resolved. Continue to finish, or abort to undo." = "冲突已全部解决。继续以完成操作,或中止以撤销。"; "Skip Commit" = "跳过该提交"; @@ -397,6 +399,7 @@ "Stashing sets these changes aside, runs the operation, then restores them. If conflicts stop the operation, the changes stay stashed until you finish it." = "贮藏会先把这些更改放到一边,执行操作后再恢复。如果操作因冲突中断,这些更改会一直保留在贮藏中,直到你完成该操作。"; "Stash and Continue" = "贮藏并继续"; "Merge into Current Branch" = "合并到当前分支"; +"Mixed Reset (Keep Changes Unstaged)" = "混合重置(保留未暂存更改)"; "Delete Branch" = "删除分支"; "No commits match this view" = "没有提交匹配当前视图"; "Commit details" = "提交详情"; @@ -719,6 +722,7 @@ "Previous difference" = "上一个差异"; "Next difference" = "下一个差异"; "Highlight words" = "高亮单词"; +"Hard Reset (Discard Changes)" = "硬重置(丢弃更改)"; "Parent version" = "父版本"; "Commit version" = "提交版本"; "This removes the stash from Git and cannot be undone." = "这会从 Git 中删除 Stash,且无法撤销。"; @@ -809,6 +813,7 @@ "Me" = "我"; "Search users" = "搜索用户"; "No matching users" = "没有匹配的用户"; +"No Remote Branches" = "没有远程分支"; "Create" = "创建"; "Create from '%@'." = "从“%@”创建。"; "Checkout branch after creation" = "创建后检出分支"; @@ -845,6 +850,7 @@ "Compare changes" = "比较更改"; "Show file history" = "显示文件历史"; "Toggle preview" = "切换预览"; +"Tracking Branch" = "跟踪的分支"; "New Branch from '%@'…" = "从“%@”新建分支…"; "Show Diff with Working Tree" = "显示与工作区的 Diff"; "Update" = "更新"; @@ -1113,6 +1119,7 @@ "Jump to latest" = "跳转到最新位置"; "Settings…" = "设置…"; "Copy" = "复制"; +"Copy Branch Name" = "复制分支名称"; "Host" = "主机"; "JDWP port" = "JDWP 端口"; "Local JDK Home (optional)" = "本地 JDK 主目录(可选)"; @@ -1713,10 +1720,14 @@ "Reset current branch?" = "重置当前分支?"; "Cherry-pick" = "拣选"; "Revert" = "反向提交"; +"Reset (Soft)" = "软重置"; "Reset (Mixed)" = "混合重置"; +"Reset (Hard)" = "硬重置"; "Apply %@ to the current branch." = "将提交 %@ 应用到当前分支。"; "Create a new commit that reverses %@." = "创建一个新提交,撤销提交 %@ 的更改。"; +"Move the current branch to %@ and keep changes staged." = "将当前分支移动到 %@,并将更改保留为已暂存状态。"; "Move the current branch to %@ and keep changes unstaged." = "将当前分支移动到 %@,并将更改保留为未暂存状态。"; +"Move the current branch to %@ and discard all working-tree changes." = "将当前分支移动到 %@,并丢弃所有工作区更改。"; "Delete branch?" = "删除分支?"; "Merge branch?" = "合并分支?"; "Rebase branch?" = "变基分支?"; diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index e08ac8e5a..0555655e3 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -258,6 +258,14 @@ struct RustGitOperations: GitOperations, Sendable { write(at: rootURL, operation: "renameBranch", gitReference: reference, name: name) } + func setUpstream(branch: GitReference, to upstream: GitReference, at rootURL: URL) -> GitProcessResult? { + write(at: rootURL, operation: "setUpstream", gitReference: upstream, name: branch.shortName) + } + + func unsetUpstream(branch: GitReference, at rootURL: URL) -> GitProcessResult? { + write(at: rootURL, operation: "unsetUpstream", name: branch.shortName) + } + func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "deleteBranch", gitReference: reference) } diff --git a/macos/Sources/Lithe/Views/Git/GitGraphView.swift b/macos/Sources/Lithe/Views/Git/GitGraphView.swift index 7e2de850e..1794ae8c8 100644 --- a/macos/Sources/Lithe/Views/Git/GitGraphView.swift +++ b/macos/Sources/Lithe/Views/Git/GitGraphView.swift @@ -10,7 +10,7 @@ struct GitGraphRowActions { let onSelect: (GitCommit) -> Void let onCherryPick: (GitCommit) -> Void let onRevert: (GitCommit) -> Void - let onReset: (GitCommit) -> Void + let onReset: (GitCommit, GitResetMode) -> Void let onCreateTag: (GitCommit) -> Void var onSelectWithModifiers: ((GitCommit, NSEvent.ModifierFlags) -> Void)? = nil var onContextSelect: ((GitCommit) -> Void)? = nil @@ -37,7 +37,11 @@ struct GitGraphRowActions { .action("New Tag…") { onCreateTag(commit) }, .action("Cherry-pick Commit…") { onCherryPick(commit) }, .action("Revert Commit…") { onRevert(commit) }, - .action("Reset Current Branch to Here…") { onReset(commit) } + .submenu("Reset Current Branch to Here…", items: [ + .action("Soft Reset (Keep Changes Staged)") { onReset(commit, .soft) }, + .action("Mixed Reset (Keep Changes Unstaged)") { onReset(commit, .mixed) }, + .action("Hard Reset (Discard Changes)", role: .destructive) { onReset(commit, .hard) } + ]) ] + (additionalContextMenuItems?(commit) ?? []) } diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index d51f462a8..910aed0cc 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -205,7 +205,10 @@ struct GitLogView: View { titleVisibility: .visible ) { if let operation = pendingCommitOperation { - Button(LocalizedStringKey(operation.kind.actionTitle)) { + Button( + LocalizedStringKey(operation.kind.actionTitle), + role: operation.kind.isDestructive ? .destructive : nil + ) { pendingCommitOperation = nil Task { switch operation.kind { @@ -213,8 +216,8 @@ struct GitLogView: View { await feature.cherryPick(operation.commit) case .revert: await feature.revert(operation.commit) - case .reset: - await feature.resetCurrentBranch(to: operation.commit) + case .reset(let mode): + await feature.resetCurrentBranch(to: operation.commit, mode: mode) } } } @@ -743,6 +746,8 @@ struct GitLogView: View { rows: rows(for: kind), currentReference: currentReference, isActiveRepository: true, + repositoryColorIndex: nil, + remoteBranches: remoteBranches, actions: referenceRowActions ) } @@ -754,6 +759,10 @@ struct GitLogView: View { let collapseKey = "repository:" + repository.repositoryRoot.standardizedFileURL.path let isCollapsed = collapsedRepositoryGroups.contains(collapseKey) let actions = repoRowActions(for: repository.repositoryRoot, isActive: isActive) + // Only the active repository's rows can open the "Tracking Branch" + // submenu; a read-only row shows no menu, so it gets no remote list. + let rowRemoteBranches = isActive ? remoteBranches : [] + let colorIndex = gitRepositoryColorIndex(for: repository.repositoryRoot) return VStack(alignment: .leading, spacing: 2) { Button { if isCollapsed { @@ -768,6 +777,11 @@ struct GitLogView: View { .frame(width: 10) LitheSystemIcon(systemImage: "shippingbox", size: 14) .foregroundStyle(LitheTheme.secondaryText) + if let colorIndex { + Circle() + .fill(GitRepositoryColor.color(at: colorIndex)) + .frame(width: 8, height: 8) + } Text(repository.name) .font(GitVisual.section) .foregroundStyle(LitheTheme.primaryText) @@ -793,6 +807,8 @@ struct GitLogView: View { rows: repository.localRows, currentReference: repository.currentReference, isActiveRepository: isActive, + repositoryColorIndex: colorIndex, + remoteBranches: rowRemoteBranches, actions: actions ) referenceSection( @@ -803,6 +819,8 @@ struct GitLogView: View { rows: repository.remoteRows, currentReference: repository.currentReference, isActiveRepository: isActive, + repositoryColorIndex: colorIndex, + remoteBranches: rowRemoteBranches, actions: actions ) referenceSection( @@ -813,6 +831,8 @@ struct GitLogView: View { rows: repository.tagRows, currentReference: repository.currentReference, isActiveRepository: isActive, + repositoryColorIndex: colorIndex, + remoteBranches: rowRemoteBranches, actions: actions ) } @@ -827,6 +847,8 @@ struct GitLogView: View { rows: [GitReferenceRow], currentReference: GitReference?, isActiveRepository: Bool, + repositoryColorIndex: Int?, + remoteBranches: [GitReference], actions: GitReferenceRowActions ) -> some View { VStack(alignment: .leading, spacing: 1) { @@ -862,6 +884,8 @@ struct GitLogView: View { currentReferenceID: currentReference?.id, comparisonSourceID: comparisonSourceReference?.id, isReadOnly: !isActiveRepository, + repositoryColorIndex: repositoryColorIndex, + remoteBranches: remoteBranches, actions: actions ) .equatable() @@ -914,6 +938,13 @@ struct GitLogView: View { return actions } + /// Remote branches offered by the reference rows' "Tracking Branch" + /// submenu. Passed down as a value so a `refs` refresh that only changes the + /// remote branches still rebuilds a row whose own branch is unchanged. + private var remoteBranches: [GitReference] { + feature.gitReferences.filter { $0.kind == .remote } + } + /// Rebuilt on each body pass, but every closure is stable in behavior, and /// `GitReferenceRowView.==` ignores this struct so it cannot by itself cause /// a row to re-render. @@ -960,6 +991,19 @@ struct GitLogView: View { push: { reference in pendingPushReference = reference }, + copyBranchName: { reference in + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(reference.shortName, forType: .string) + }, + setBranchUpstream: { reference, upstream in + Task { + if let upstream { + await feature.setUpstream(reference, to: upstream) + } else { + await feature.unsetUpstream(reference) + } + } + }, branchOperation: { kind, reference in pendingBranchOperation = GitBranchOperationRequest(kind: kind, reference: reference) } @@ -1445,8 +1489,8 @@ struct GitLogView: View { onRevert: { commit in pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .revert, commit: commit) }, - onReset: { commit in - pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .reset, commit: commit) + onReset: { commit, mode in + pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .reset(mode), commit: commit) }, onCreateTag: { commit in tagDialogRequest = GitTagDialogRequest(commit: commit) @@ -1919,6 +1963,15 @@ struct GitLogView: View { == feature.gitRepositoryRoot?.standardizedFileURL } + /// The repository's palette slot, or `nil` when the pane is not grouping + /// repositories. The slot always comes from the full ordered root list, so + /// hiding worktrees cannot shift the colors of the repositories still shown. + private func gitRepositoryColorIndex(for repositoryRoot: URL) -> Int? { + let repositoryRoots = feature.availableRepositoryRoots + guard GitRepositoryColor.isVisible(for: repositoryRoots) else { return nil } + return GitRepositoryColor.index(for: repositoryRoot, in: repositoryRoots) + } + private var hasWorktreeRepositories: Bool { let roots = feature.availableRepositoryRoots return roots.contains { GitRepositoryHierarchy.isLinkedWorktreeRepository($0, among: roots) } @@ -2110,7 +2163,7 @@ enum GitLogDatePreset: String, CaseIterable, Identifiable, Hashable { private enum GitCommitOperationKind { case cherryPick case revert - case reset + case reset(GitResetMode) var title: String { switch self { @@ -2124,18 +2177,31 @@ private enum GitCommitOperationKind { switch self { case .cherryPick: "Cherry-pick" case .revert: "Revert" - case .reset: "Reset (Mixed)" + case .reset(.soft): "Reset (Soft)" + case .reset(.mixed): "Reset (Mixed)" + case .reset(.hard): "Reset (Hard)" } } + /// A hard reset discards working-tree changes, so its confirmation button is + /// destructive. Soft and mixed keep the changes. + var isDestructive: Bool { + if case .reset(.hard) = self { return true } + return false + } + func message(for commit: GitCommit) -> LocalizedStringKey { switch self { case .cherryPick: "Apply \(commit.shortHash) to the current branch." case .revert: "Create a new commit that reverses \(commit.shortHash)." - case .reset: + case .reset(.soft): + "Move the current branch to \(commit.shortHash) and keep changes staged." + case .reset(.mixed): "Move the current branch to \(commit.shortHash) and keep changes unstaged." + case .reset(.hard): + "Move the current branch to \(commit.shortHash) and discard all working-tree changes." } } } @@ -2144,7 +2210,7 @@ private struct GitCommitOperationRequest: Identifiable { let kind: GitCommitOperationKind let commit: GitCommit - var id: String { "\(kind.title):\(commit.hash)" } + var id: String { "\(kind.actionTitle):\(commit.hash)" } } private enum GitBranchDialogKind { @@ -2873,6 +2939,9 @@ private struct GitReferenceRowActions { let checkout: (GitReference) -> Void let updateCurrentBranch: (GitReference) -> Void let push: (GitReference) -> Void + let copyBranchName: (GitReference) -> Void + /// Sets the local branch's tracking branch. A `nil` upstream clears it. + let setBranchUpstream: (GitReference, GitReference?) -> Void let branchOperation: (GitBranchOperationKind, GitReference) -> Void } @@ -2887,15 +2956,34 @@ private struct GitReferenceRowView: View, Equatable { /// context menu, so this participates in equality to force a re-render when /// the active repository changes. let isReadOnly: Bool + /// The repository's palette slot for the row's leading color bar, or `nil` + /// when colors are not shown (single-repository workspaces). The index, not + /// the resolved `Color`, participates in equality so the row refresh rule + /// stays value-based. + let repositoryColorIndex: Int? + /// Remote branches backing this row's "Tracking Branch" submenu. Kept here, + /// next to the other compared values, rather than inside `actions` — which + /// `==` ignores — so a refresh that only changes the remote branch list + /// still rebuilds the row and its context menu. + let remoteBranches: [GitReference] let actions: GitReferenceRowActions static func == (lhs: Self, rhs: Self) -> Bool { - lhs.row == rhs.row - && lhs.isSelected == rhs.isSelected - && lhs.isPerformingBranchOperation == rhs.isPerformingBranchOperation - && lhs.currentReferenceID == rhs.currentReferenceID - && lhs.comparisonSourceID == rhs.comparisonSourceID - && lhs.isReadOnly == rhs.isReadOnly + lhs.renderKey == rhs.renderKey + } + + /// The values the row is compared on; see `GitReferenceRowRenderKey`. + private var renderKey: GitReferenceRowRenderKey { + GitReferenceRowRenderKey( + row: row, + isSelected: isSelected, + isPerformingBranchOperation: isPerformingBranchOperation, + currentReferenceID: currentReferenceID, + comparisonSourceID: comparisonSourceID, + isReadOnly: isReadOnly, + repositoryColorIndex: repositoryColorIndex, + remoteBranches: remoteBranches + ) } var body: some View { @@ -2956,6 +3044,13 @@ private struct GitReferenceRowView: View, Equatable { .padding(.leading, CGFloat(row.depth * 16)) .padding(.trailing, 8) .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) + .overlay(alignment: .leading) { + if let repositoryColorIndex { + Rectangle() + .fill(GitRepositoryColor.color(at: repositoryColorIndex)) + .frame(width: 2.5) + } + } .clipShape(RoundedRectangle(cornerRadius: 4)) .contentShape(Rectangle()) .litheRowHover( @@ -3075,7 +3170,44 @@ private struct GitReferenceRowView: View, Equatable { return .action("Rename…", role: role, isEnabled: isEnabled) { actions.renameBranch(reference) } + case .copyBranchName: + return .action("Copy Branch Name", role: role, isEnabled: isEnabled) { + actions.copyBranchName(reference) + } + case .trackingBranch: + return trackingBranchMenu(for: reference, isEnabled: isEnabled) + } + } + + /// Lists the workspace's remote branches so the user can point a local + /// branch at one, plus a clear entry that only appears when the branch + /// already tracks something. + private func trackingBranchMenu( + for reference: GitReference, + isEnabled: Bool + ) -> LitheContextMenuItem { + var items: [LitheContextMenuItem] = [] + if let upstream = reference.upstreamShortName { + items.append(.action(upstream, systemImage: "checkmark", isEnabled: false) {}) + items.append(.action("Stop Tracking Branch", isEnabled: isEnabled) { + actions.setBranchUpstream(reference, nil) + }) + items.append(.separator) + } + if remoteBranches.isEmpty { + items.append(.action("No Remote Branches", isEnabled: false) {}) + } else { + for remote in remoteBranches { + items.append(.action( + remote.shortName, + systemImage: "network", + isEnabled: isEnabled && remote.shortName != reference.upstreamShortName + ) { + actions.setBranchUpstream(reference, remote) + }) + } } + return .submenu("Tracking Branch", systemImage: "network", items: items) } private func referenceIcon(_ reference: GitReference) -> String { diff --git a/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift index 73039e6d2..90ff06f23 100644 --- a/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift +++ b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift @@ -91,6 +91,29 @@ enum GitReferenceRowsBuilder { } } +/// Values that decide whether a rendered reference row must be rebuilt. +/// +/// `GitReferenceRowView` compares itself with `==` so a `LazyVStack` row is +/// only re-evaluated when something it displays changes. Everything the row — +/// or its context menu — reads therefore has to be part of this key. The row's +/// `actions` struct is deliberately absent: its closures are rebuilt on every +/// body pass and are stable in behavior. +struct GitReferenceRowRenderKey: Equatable { + let row: GitReferenceRow + let isSelected: Bool + let isPerformingBranchOperation: Bool + let currentReferenceID: String? + let comparisonSourceID: String? + let isReadOnly: Bool + /// Palette slot for the row's leading color bar; `nil` when colors are off. + let repositoryColorIndex: Int? + /// Remote branches backing the "Tracking Branch" submenu. A `refs` refresh + /// can add or remove remote branches while every other field of a local row + /// stays identical; without this the row keeps the menu it built earlier + /// and offers a stale — or still empty — remote branch list. + let remoteBranches: [GitReference] +} + /// One entry of a reference row's context menu, stripped of closures and /// localization so the menu's *policy* is testable without a SwiftUI host. The /// view maps every entry to a `LitheContextMenuItem` and supplies the closure. @@ -116,6 +139,8 @@ enum GitReferenceMenuAction: Equatable { case push case delete case rename + case copyBranchName + case trackingBranch } /// Builds the context menu a reference row shows. @@ -165,6 +190,8 @@ enum GitReferenceRowMenu { entries.append(.separator) entries.append(.action(.pullRebase, isEnabled: !isPerformingBranchOperation, isDestructive: false)) entries.append(.action(.pullMerge, isEnabled: !isPerformingBranchOperation, isDestructive: false)) + entries.append(.separator) + entries.append(.action(.copyBranchName, isEnabled: true, isDestructive: false)) } if kind == .local { @@ -178,6 +205,9 @@ enum GitReferenceRowMenu { entries.append(.separator) entries.append(.action(.rename, isEnabled: !isPerformingBranchOperation, isDestructive: false)) + entries.append(.action(.trackingBranch, isEnabled: !isPerformingBranchOperation, isDestructive: false)) + entries.append(.separator) + entries.append(.action(.copyBranchName, isEnabled: true, isDestructive: false)) } return entries diff --git a/macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift b/macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift new file mode 100644 index 000000000..adff3a02b --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitRepositoryColor.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftUI + +/// Per-repository accent colors for the Git Log reference pane. +/// +/// IntelliJ IDEA marks every repository in the log's reference tree with a +/// colored dot. Lithe mirrors that: the repository group header gets a dot and +/// each reference row gets a thin leading bar. A color is assigned by the +/// repository's position in the workspace's *full*, ordered repository list, so +/// hiding worktrees never recolors the repositories that stay visible. +enum GitRepositoryColor { + /// Eight hues chosen to stay legible on both light and dark backgrounds. + static let paletteHex = [ + "#55d68b", "#65a9ff", "#d77eea", "#f3aa59", + "#e76c72", "#56c7cf", "#b08cff", "#ffd166" + ] + + /// A workspace with a single repository has nothing to disambiguate, so the + /// pane stays exactly as it was before this feature. + static func isVisible(for availableRepositoryRoots: [URL]) -> Bool { + availableRepositoryRoots.count > 1 + } + + /// The palette index for `repositoryRoot`, matched by standardized path + /// against the full ordered list. Unknown roots fall back to index 0. + static func index(for repositoryRoot: URL, in availableRepositoryRoots: [URL]) -> Int { + let target = repositoryRoot.standardizedFileURL.path + guard let position = availableRepositoryRoots.firstIndex(where: { + $0.standardizedFileURL.path == target + }) else { return 0 } + return position % paletteHex.count + } + + static func color(for repositoryRoot: URL, in availableRepositoryRoots: [URL]) -> Color { + color(at: index(for: repositoryRoot, in: availableRepositoryRoots)) + } + + static func color(at index: Int) -> Color { + Color(hex: paletteHex[((index % paletteHex.count) + paletteHex.count) % paletteHex.count]) + } +} diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 3e480d556..25946b71d 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -2615,6 +2615,38 @@ package final class GitFeatureModel: ObservableObject { } } + /// Points a local branch at a remote branch, mirroring the write operation + /// IntelliJ IDEA exposes as "Tracking Branch". + package func setUpstream(_ reference: GitReference, to upstream: GitReference) async { + guard let gitRepositoryRoot else { return } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.setUpstream(reference, to: upstream, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + if result.succeeded { + notify?("Tracking \(reference.shortName) with \(upstream.shortName)") + await refreshGit() + } else { + notify?(trimmedMessage(result)) + } + } + + package func unsetUpstream(_ reference: GitReference) async { + guard let gitRepositoryRoot else { return } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.unsetUpstream(reference, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + if result.succeeded { + notify?("Stopped tracking \(reference.shortName)") + await refreshGit() + } else { + notify?(trimmedMessage(result)) + } + } + package func deleteBranch(_ reference: GitReference) async { guard let gitRepositoryRoot else { return } isPerformingBranchOperation = true @@ -3436,14 +3468,14 @@ package final class GitFeatureModel: ObservableObject { await startIntegration(.commit(commit), operation: .revert) } - package func resetCurrentBranch(to commit: GitCommit) async { + package func resetCurrentBranch(to commit: GitCommit, mode: GitResetMode = .mixed) async { guard let gitRepositoryRoot else { return } isPerformingBranchOperation = true let result = await withGitOperation { await service.resetCurrentBranch( to: commit.hash, at: gitRepositoryRoot, - mode: "--mixed" + mode: mode.argument ) } isPerformingBranchOperation = false diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index a0d7bfb97..9aba267ad 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -1295,6 +1295,20 @@ package enum GitPullStrategy: String, Sendable { case rebase } +/// How far a reset moves HEAD, the index, and the working tree when the user +/// resets the current branch to a commit. +package enum GitResetMode: String, Sendable, CaseIterable { + /// Move HEAD only; the index and working tree keep their changes staged. + case soft + /// Move HEAD and the index; the working tree keeps its changes unstaged. + case mixed + /// Move HEAD, the index, and the working tree, discarding local changes. + case hard + + /// The argument Rust Core parses in the `reset` write operation. + package var argument: String { "--\(rawValue)" } +} + package enum GitOperationKind: String, Equatable, Sendable { case merge case rebase diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index e2dad6f63..18bcff000 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -127,6 +127,8 @@ package protocol GitOperations: Sendable { func repairWorktrees(at rootURL: URL) -> GitProcessResult? func pruneWorktrees(at rootURL: URL) -> GitProcessResult? func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? + func setUpstream(branch: GitReference, to upstream: GitReference, at rootURL: URL) -> GitProcessResult? + func unsetUpstream(branch: GitReference, at rootURL: URL) -> GitProcessResult? func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? @@ -207,6 +209,8 @@ package extension GitOperations { return createWorktree(named: name, from: reference, revision: request.revision, at: request.destination, repositoryRoot: rootURL) } func createHistoryRecoveryBranch(named name: String, reference: String, at rootURL: URL) -> GitProcessResult? { nil } + func setUpstream(branch: GitReference, to upstream: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func unsetUpstream(branch: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func exportPatch(at rootURL: URL, source: GitPatchSource, paths: [String], base: String?, target: String?, metadataOnly: Bool) -> Result { .failure(GitPatchFailure("Patch export is unavailable.")) } @@ -923,6 +927,22 @@ package struct GitService: Sendable { await command(at: repositoryRoot) { $0.renameBranch(reference, to: newName, at: repositoryRoot) } } + func setUpstream( + _ reference: GitReference, + to upstream: GitReference, + at repositoryRoot: URL + ) async -> CommandResult { + await command(at: repositoryRoot) { + $0.setUpstream(branch: reference, to: upstream, at: repositoryRoot) + } + } + + func unsetUpstream(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult { + await command(at: repositoryRoot) { + $0.unsetUpstream(branch: reference, at: repositoryRoot) + } + } + func deleteBranch(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult { await command(at: repositoryRoot) { $0.deleteBranch(reference, at: repositoryRoot) } } diff --git a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift index bfa168c1b..4dcf5c9f4 100644 --- a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift +++ b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift @@ -408,7 +408,7 @@ struct GitGraphPerformanceBaselineTests { onSelect: { _ in }, onCherryPick: { _ in }, onRevert: { _ in }, - onReset: { _ in }, + onReset: { _, _ in }, onCreateTag: { _ in } ) let scrollView = GitGraphScrollView.makeScrollView( @@ -472,7 +472,7 @@ struct GitGraphPerformanceBaselineTests { onSelect: { _ in }, onCherryPick: { _ in }, onRevert: { _ in }, - onReset: { _ in }, + onReset: { _, _ in }, onCreateTag: { _ in } ) rowsView.update( @@ -621,7 +621,7 @@ struct GitGraphPerformanceBaselineTests { onSelect: { _ in }, onCherryPick: { _ in }, onRevert: { _ in }, - onReset: { _ in }, + onReset: { _, _ in }, onCreateTag: { _ in } ) let scrollView = GitGraphScrollView.makeScrollView( diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index bd4d8d672..71e2052f6 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -151,6 +151,13 @@ struct AppLocalizationTests { #expect(translations["Cherry-pick Commit…"] == "拣选提交…") #expect(translations["Revert Commit…"] == "反向提交(保留历史)…") #expect(translations["Reset Current Branch to Here…"] == "将当前分支重置到这里…") + #expect(translations["Copy Branch Name"] == "复制分支名称") + #expect(translations["Tracking Branch"] == "跟踪的分支") + #expect(translations["Stop Tracking Branch"] == "停止跟踪分支") + #expect(translations["No Remote Branches"] == "没有远程分支") + #expect(translations["Soft Reset (Keep Changes Staged)"] == "软重置(保留暂存更改)") + #expect(translations["Mixed Reset (Keep Changes Unstaged)"] == "混合重置(保留未暂存更改)") + #expect(translations["Hard Reset (Discard Changes)"] == "硬重置(丢弃更改)") #expect(translations["Undo Commit…"] == "撤销最近一次提交(保留更改)…") #expect(translations["Edit Commit Message…"] == "编辑提交消息…") #expect(translations["Squash Commits…"] == "合并所选提交…") @@ -283,8 +290,10 @@ struct GitLocalizationTests { let keys = [ "Revert this commit?", "Revert", "Create a new commit that reverses %@.", "Cherry-pick this commit?", "Apply %@ to the current branch.", - "Reset current branch?", "Reset (Mixed)", + "Reset current branch?", "Reset (Soft)", "Reset (Mixed)", "Reset (Hard)", + "Move the current branch to %@ and keep changes staged.", "Move the current branch to %@ and keep changes unstaged.", + "Move the current branch to %@ and discard all working-tree changes.", "Delete branch?", "Merge branch?", "Rebase branch?", "Checkout and rebase branch?", "Pull remote branch with rebase?", "Pull remote branch with merge?", "New Tag", "Tag name", @@ -294,7 +303,10 @@ struct GitLocalizationTests { "Last 7 Days", "Last 30 Days", "Loading commits…", "Load more commits", "Clear Git console", "Git exited with code %d", "%lld files", "%lld worktrees", "Worktree action unavailable", "Invalid Git tag name.", - "Show worktree repositories", "Hide worktree repositories" + "Show worktree repositories", "Hide worktree repositories", + "Copy Branch Name", "Tracking Branch", "Stop Tracking Branch", "No Remote Branches", + "Soft Reset (Keep Changes Staged)", "Mixed Reset (Keep Changes Unstaged)", + "Hard Reset (Discard Changes)" ] let pattern = try NSRegularExpression(pattern: #"%(?:\d+\$)?(?:lld|ld|d|@)"#) for key in keys { diff --git a/macos/Tests/LitheTests/ContextMenuCoverageTests.swift b/macos/Tests/LitheTests/ContextMenuCoverageTests.swift index 9531e7804..ad6a88fa3 100644 --- a/macos/Tests/LitheTests/ContextMenuCoverageTests.swift +++ b/macos/Tests/LitheTests/ContextMenuCoverageTests.swift @@ -60,13 +60,23 @@ struct ContextMenuCoverageTests { onSelect: { _ in Issue.record("Right click must not check out or change selection") }, onCherryPick: { received.append("cherry:\($0.hash)") }, onRevert: { received.append("revert:\($0.hash)") }, - onReset: { received.append("reset:\($0.hash)") }, + onReset: { commit, mode in received.append("reset:\(mode.rawValue):\(commit.hash)") }, onCreateTag: { received.append("tag:\($0.hash)") } ).contextMenuItems(for: commit) - for title in ["New Tag…", "Cherry-pick Commit…", "Revert Commit…", "Reset Current Branch to Here…"] { + for title in ["New Tag…", "Cherry-pick Commit…", "Revert Commit…"] { try #require(menu.first { $0.title == title }).action() } - #expect(received == ["tag:abcdef123456", "cherry:abcdef123456", "revert:abcdef123456", "reset:abcdef123456"]) + let resetMenu = try #require(menu.first { $0.title == "Reset Current Branch to Here…" }) + guard case .submenu(let resetItems) = resetMenu.kind else { + Issue.record("Reset should offer soft, mixed, and hard as a submenu") + return + } + try #require(resetItems.first { $0.title == "Mixed Reset (Keep Changes Unstaged)" }).action() + try #require(resetItems.first { $0.title == "Hard Reset (Discard Changes)" }).action() + #expect(received == [ + "tag:abcdef123456", "cherry:abcdef123456", "revert:abcdef123456", + "reset:mixed:abcdef123456", "reset:hard:abcdef123456" + ]) } @Test diff --git a/macos/Tests/LitheTests/GitGraphInteractionTests.swift b/macos/Tests/LitheTests/GitGraphInteractionTests.swift index 99b249663..624de76e2 100644 --- a/macos/Tests/LitheTests/GitGraphInteractionTests.swift +++ b/macos/Tests/LitheTests/GitGraphInteractionTests.swift @@ -381,7 +381,7 @@ struct GitGraphInteractionTests { private func actions(_ select: @escaping (GitCommit) -> Void) -> GitGraphRowActions { GitGraphRowActions(onSelect: select, onCherryPick: { _ in }, onRevert: { _ in }, - onReset: { _ in }, onCreateTag: { _ in }) + onReset: { _, _ in }, onCreateTag: { _ in }) } } diff --git a/macos/Tests/LitheTests/GitReferenceRowRenderKeyTests.swift b/macos/Tests/LitheTests/GitReferenceRowRenderKeyTests.swift new file mode 100644 index 000000000..7075f09b0 --- /dev/null +++ b/macos/Tests/LitheTests/GitReferenceRowRenderKeyTests.swift @@ -0,0 +1,83 @@ +import Testing +@testable import Lithe +@testable import LitheGitModule + +/// `GitReferenceRowView` compares itself with `==` so a `LazyVStack` row is only +/// re-evaluated when something it shows changes. Its "Tracking Branch" submenu +/// reads the workspace's remote branches, so a `refs` refresh that only adds or +/// drops remote branches has to invalidate the row too; otherwise the row keeps +/// the menu it built earlier and offers a remote branch list that no longer +/// exists — or keeps saying "No Remote Branches" after one appeared. +@Suite("Git reference row render key") +struct GitReferenceRowRenderKeyTests { + private func localBranch(_ shortName: String) -> GitReference { + GitReference( + fullName: "refs/heads/\(shortName)", + shortName: shortName, + kind: .local, + isCurrent: false, + upstreamShortName: nil + ) + } + + private func remoteBranch(_ shortName: String) -> GitReference { + GitReference( + fullName: "refs/remotes/\(shortName)", + shortName: shortName, + kind: .remote, + isCurrent: false, + upstreamShortName: nil + ) + } + + /// A row whose own branch and every other compared field are held constant + /// except the ones a test varies explicitly. + private func key( + remoteBranches: [GitReference], + isSelected: Bool = false + ) -> GitReferenceRowRenderKey { + GitReferenceRowRenderKey( + row: GitReferenceRow( + id: "reference:feature/x", + name: "feature/x", + depth: 0, + content: .reference(localBranch("feature/x")) + ), + isSelected: isSelected, + isPerformingBranchOperation: false, + currentReferenceID: "refs/heads/main", + comparisonSourceID: nil, + isReadOnly: false, + repositoryColorIndex: nil, + remoteBranches: remoteBranches + ) + } + + @Test("a refresh that only gains a remote branch invalidates the row") + func gainingARemoteBranchInvalidatesTheRow() { + #expect(key(remoteBranches: []) != key(remoteBranches: [remoteBranch("origin/main")])) + } + + @Test("a refresh that only drops a remote branch invalidates the row") + func droppingARemoteBranchInvalidatesTheRow() { + #expect( + key(remoteBranches: [remoteBranch("origin/main"), remoteBranch("origin/release")]) + != key(remoteBranches: [remoteBranch("origin/main")]) + ) + } + + @Test("an unchanged remote branch list keeps the row equal") + func stableRemoteBranchListKeepsTheRowEqual() { + #expect( + key(remoteBranches: [remoteBranch("origin/main")]) + == key(remoteBranches: [remoteBranch("origin/main")]) + ) + } + + @Test("a changed row still invalidates the row with a stable remote branch list") + func changedRowStillInvalidatesTheRow() { + let remoteBranches = [remoteBranch("origin/main")] + + #expect(key(remoteBranches: remoteBranches) != key(remoteBranches: remoteBranches, isSelected: true)) + } +} diff --git a/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift b/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift index 574d93d01..f39b06c9c 100644 --- a/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift +++ b/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift @@ -208,10 +208,11 @@ struct GitReferenceRowMenuTests { guard case .action(let action, let isEnabled, _) = entry else { continue } switch action { case .checkout, .checkoutAndRebase, .merge, .rebase, - .pullRebase, .pullMerge, .update, .push, .delete, .rename: + .pullRebase, .pullMerge, .update, .push, .delete, .rename, + .trackingBranch: #expect(!isEnabled) case .newBranch, .showDiffWithWorkingTree, .compareWithCurrent, - .compareWithSelectedSource, .selectForCompare: + .compareWithSelectedSource, .selectForCompare, .copyBranchName: break } } @@ -224,4 +225,19 @@ struct GitReferenceRowMenuTests { #expect(result.contains(.action(.compareWithSelectedSource, isEnabled: true, isDestructive: false))) #expect(!result.contains(.action(.selectForCompare, isEnabled: true, isDestructive: false))) } + + @Test + func localAndRemoteRowsOfferCopyBranchNameButTagsDoNot() { + for kind in [GitReferenceKind.local, .remote] { + #expect(entries(kind: kind).contains(.action(.copyBranchName, isEnabled: true, isDestructive: false))) + } + #expect(!entries(kind: .tag).contains(.action(.copyBranchName, isEnabled: true, isDestructive: false))) + } + + @Test + func onlyLocalRowsOfferTrackingBranch() { + #expect(entries(kind: .local).contains(.action(.trackingBranch, isEnabled: true, isDestructive: false))) + #expect(!entries(kind: .remote).contains(.action(.trackingBranch, isEnabled: true, isDestructive: false))) + #expect(!entries(kind: .tag).contains(.action(.trackingBranch, isEnabled: true, isDestructive: false))) + } } diff --git a/macos/Tests/LitheTests/GitRepositoryColorTests.swift b/macos/Tests/LitheTests/GitRepositoryColorTests.swift new file mode 100644 index 000000000..f7a881e93 --- /dev/null +++ b/macos/Tests/LitheTests/GitRepositoryColorTests.swift @@ -0,0 +1,89 @@ +import AppKit +import Foundation +import SwiftUI +import Testing +@testable import Lithe + +/// The Git Log marks each repository with a color. The color must stay with a +/// repository when worktrees are hidden, or the pane would recolor itself on a +/// toggle and mislead the user about which repository a row belongs to. +@Suite("Git repository colors") +struct GitRepositoryColorTests { + private func roots(_ count: Int) -> [URL] { + (0.. outer { + #expect(colors[outer] != colors[inner]) + } + } + } + + @Test + func colorForRootMatchesItsSlot() throws { + let repositoryRoots = roots(3) + for (slot, root) in repositoryRoots.enumerated() { + let resolved = try #require( + NSColor(GitRepositoryColor.color(for: root, in: repositoryRoots)).usingColorSpace(.sRGB) + ) + let expected = try #require(NSColor(GitRepositoryColor.color(at: slot)).usingColorSpace(.sRGB)) + #expect(resolved == expected) + } + } + + @Test + func repositoriesReceiveDistinctSlotsInListOrder() { + let repositoryRoots = roots(8) + let slots = repositoryRoots.map { + GitRepositoryColor.index(for: $0, in: repositoryRoots) + } + #expect(slots == Array(0..<8)) + } + + @Test + func slotIsStableWhenTheVisibleSubsetChanges() { + let all = roots(3) + // Hiding the middle repository (for example a worktree) must not shift + // the third repository onto the second slot. Assignment always reads the + // full ordered list, so `repo-2` keeps slot 2. + #expect(GitRepositoryColor.index(for: all[2], in: all) == 2) + #expect(GitRepositoryColor.index(for: all[2], in: [all[0], all[2]]) == 1) + } + + @Test + func slotsWrapAfterThePaletteSize() { + let repositoryRoots = roots(9) + #expect(GitRepositoryColor.index(for: repositoryRoots[8], in: repositoryRoots) == 0) + } + + @Test + func singleRepositoryWorkspaceShowsNoColor() { + #expect(!GitRepositoryColor.isVisible(for: roots(1))) + #expect(GitRepositoryColor.isVisible(for: roots(2))) + // The fallback color still resolves, so a caller that ignores visibility + // cannot crash; it simply gets the first palette entry. + #expect(GitRepositoryColor.index(for: roots(1)[0], in: roots(1)) == 0) + } + + @Test + func unknownRootFallsBackToTheFirstColor() { + let repositoryRoots = roots(3) + let outsider = URL(fileURLWithPath: "/elsewhere/other") + #expect(GitRepositoryColor.index(for: outsider, in: repositoryRoots) == 0) + } +} diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index c281a619a..0b91af80e 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": "协作",