diff --git a/CHANGELOG.md b/CHANGELOG.md index 74dbf14..29bd7b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### Added + +- **Branches 视图按 `/` 前缀分组为文件夹树**:Local / Remote / Tags 三段支持在**平铺**与**按 `/` 前缀分组的可折叠文件夹树**间切换——共享前缀的分支收拢到以前缀命名的文件夹下、叶子仅显示末段后缀(如 `bak/2025`、`bak/master-2025-07` → 📁`bak` → `2025` / `master-2025-07`;远程整体收拢为 `origin` 文件夹并内嵌 `feature`)。新增纯逻辑引擎 [`buildRefTree`](./src/engine/ref/ref-tree.ts)(复用「变更文件目录树」`buildFileTree` 的 trie 构建 + compact 折叠范式;叶子携完整 `RawRef`、命令定位不受影响;分支感知排序:当前 HEAD → 收藏 → 同档文件夹优先、名称数字感知升序),并**隐藏 `origin/HEAD` 符号引用**(对齐主流 Git UI,消除 `origin` 叶子与 `origin` 文件夹同名冲突)。工具栏提供「平铺 ⇄ 树」切换(`hyperGit.branchesGroupByPrefix` / `hyperGit.branchesFlatten`,context key 驱动按钮互斥),偏好按仓库持久化于 `workspaceState`(默认树形),文件夹默认展开、展开态跨刷新稳定。文件夹节点用独立 `contextValue` 且不登记任何右键命令,checkout / delete / rename / merge / rebase / compare / favorite 与多选批量均零改动、零破坏。完整叙述见 [分支前缀分组树](./docs/features/branch-tree-group-by-prefix.md)。 + ## [0.0.14] - 2026-07-06 — 修复 GRAPH 视图提交记录时间倒序 修复 GRAPH 视图「All」范围下提交记录未按时间倒序排列的问题——取数参数由 `--topo-order` 改为 `--author-date-order`,令跨分支提交按作者日期正确交错、消除「日期回跳」。纯取数排序修复,不改数据协议、布局算法与任何视图交互。完整用户视角叙述见 [Release Note v0.0.14](./docs/releases/v0.0.14.md)。 diff --git a/README.md b/README.md index a8d089a..a47c513 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - **Multi-Changelist Changes view**: group edits into named lists, mark one as the active commit target, create / rename / delete / move lists, persisted in `workspaceState` (restored across restarts); status colors reuse the `gitDecoration.*` theme tokens. - **Commit panel**: a hand-built commit view with real-time Conventional Commits validation, Amend / Sign-off / skip hooks, and Commit / Commit & Push; the checkbox selection is the authoritative commit scope, and recent messages are reusable. - **Log commit graph (hand-rendered DAG)**: colored swimlanes, branch/merge edges, HEAD/branch/tag badges, virtualized incremental loading; 7 composable filters (author / path / grep / regex / merge-mode / date / clear); per-commit actions (Reset, new branch·tag, Cherry-Pick, Revert, list containing branches). -- **Branches management**: favorites / local / remote / tags grouped into four sections with ahead·behind·upstream tracking; create / checkout / delete / rename / merge / rebase / compare / favorite; **multi-select batch** delete, copy ref, and favorite (with an honest merged/unmerged split confirmation). +- **Branches management**: favorites / local / remote / tags grouped into four sections with ahead·behind·upstream tracking; **prefix folder grouping** (flat ⇄ tree toggle) that collapses `/`-shared branches under their common prefix, showing only the suffix; create / checkout / delete / rename / merge / rebase / compare / favorite; **multi-select batch** delete, copy ref, and favorite (with an honest merged/unmerged split confirmation). - **Stash & Shelf**: full stash operations (including keep-index / clear / create branch from stash); a standalone **Shelf** (patch-based, independent of stash, with 3-way merge Unshelve). - **Worktrees**: full-lifecycle management — create (new branch / checkout / detached), open in a new window, lock / unlock, move, remove, and prune stale entries. - **Line-level & hunk commits**: in-editor "Commit this Hunk" CodeLens, partial stage / unstage, stage at cursor, and hunk-to-changelist attribution. diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index 456e409..bf32110 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -20,6 +20,7 @@ - [Log 视图 CI 状态](../docs/features/log-ci-status.md) — 按提交显示 GitHub CI 最终状态(绿勾/红叉 + Tooltip 明细):认证、限流、懒加载、边界与配置。 - [Commit 视图整合](../docs/features/commit-view-consolidation.md) — 移除 CHANGES 视图,其文件操作/changelist 管理/Git 工具栏/未提交角标零回归平移入 Commit 视图。 - [变更文件目录树](../docs/features/file-list-group-by-directory.md) — Commit / Log 文件列表「平铺 ⇄ 按目录分组」切换:host 侧 `buildFileTree` 构树下发、compact 折叠、目录三态。 +- [分支前缀分组树](../docs/features/branch-tree-group-by-prefix.md) — Branches 视图 Local/Remote/Tags「平铺 ⇄ 按 `/` 前缀分组」切换:`buildRefTree` 构树、分支感知排序、compact 折叠、隐藏 `origin/HEAD`、按仓库持久化。 - [Log 提交悬浮详情](../docs/features/log-commit-tooltip.md) — 悬停提交行浮层展示 分支/标签/HEAD/完整消息/作者·提交者/时间/SHA;复用 CI 浮层范式、与其互斥。 - [实施状态总览](../docs/milestones/implementation-status.md) — M0-M5 交付记录 + API 限制 + M5 AI 设计 + 验证/发布(**实施看板**)。 - [工程实施方案](../docs/architecture/engineering-plan.md) — 路径 B 架构 + M0-M5 里程碑(**开发蓝图**)。 diff --git a/docs/features/branch-tree-group-by-prefix.md b/docs/features/branch-tree-group-by-prefix.md new file mode 100644 index 0000000..845b06c --- /dev/null +++ b/docs/features/branch-tree-group-by-prefix.md @@ -0,0 +1,73 @@ +# 分支前缀分组树(Group Branches by Prefix) + +> Branches 视图的 **Local / Remote / Tags** 三段支持在**平铺列表**与**按 `/` 前缀分组的文件夹树**之间切换(对齐 VS Code SCM / GitLens 的分支分组)。共享前缀的分支收拢到以前缀命名的可折叠文件夹下,叶子仅显示末段后缀;工具栏一键切换、按仓库记忆偏好,默认树形。**Favorites 段恒平铺**并显示完整短名。 + +## 效果:扁平 → 前缀树 + +```mermaid +flowchart LR + subgraph FLAT["平铺(切换前)"] + direction TB + a1["bak/2025"] + a2["bak/master-2025-07"] + a3["feature/1.0.0"] + a4["2022 / 2023 / 2024"] + end + subgraph TREE["前缀树(切换后)"] + direction TB + b0["📁 bak"] --> b1["2025"] + b0 --> b2["master-2025-07"] + b3["📁 feature"] --> b4["1.0.0"] + b5["2022 / 2023 / 2024"] + end + FLAT -->|Group by Prefix| TREE + style b0 fill:#8957e5,color:#fff + style b3 fill:#8957e5,color:#fff +``` + +远程段整体收拢为单个 `origin` 文件夹,内部再嵌 `feature`:`origin/feature/1.0.0` → `origin` → `feature` → `1.0.0`。 + +## 设计:引擎算树、适配层渲染(单一事实源) + +分支前缀分组与「变更文件目录树」(见 [file-list-group-by-directory](./file-list-group-by-directory.md))是同构问题,故沿用其成熟范式:纯逻辑引擎构树、上层仅渲染。 + +```mermaid +flowchart LR + A["RawRef[]
(displayRefs,已滤 origin/HEAD)"] -->|adapter| B["buildRefTree()
engine/ref/ref-tree"] + B -->|"RefTreeNode[]
(folder / leaf)"| C["BranchesTreeProvider
toBranchNodes()"] + C --> D{"grouping?"} + D -->|on| E["文件夹节点 + 后缀叶子"] + D -->|off| F["平铺(既有排序)"] + style B fill:#1f6feb,color:#fff + style E fill:#238636,color:#fff +``` + +- **纯逻辑 `buildRefTree`**([`engine/ref/ref-tree.ts`](../../src/engine/ref/ref-tree.ts),零 vscode 依赖、Vitest 覆盖):按 `shortName` 的 `/` 分段建 trie;叶子携带完整 `RawRef`(短名不丢,命令仍以 `ref.shortName` 定位)、`label` 取末段后缀。**分支感知排序**——当前 HEAD(第 0 档)→ 收藏(第 1 档)→ 同档内**文件夹在前**、名称数字感知升序、稳定;**compact 折叠**单目录子链(如 `a/b/c` → `a/b`,遇含叶子或多子目录即停,对齐 VS Code `explorer.compactFolders`)。 +- **含 `/` 的远程名无需特判**:fork 场景 remote 名可含 `/`(如 `myorg/repo`),compact 折叠会把 `myorg/repo` 单目录子链渲染为单个文件夹,视觉上与「remote 为一层」等价;删除等**正确性敏感**逻辑仍走 [`resolveRemoteBranch`](../../src/engine/ref/remote-ref.ts) 的最长前缀匹配,两者正交不耦合。 +- **隐藏 `origin/HEAD`**:远程符号引用 `refs/remotes//HEAD`(短名如 `origin`)非真实分支,视图统一过滤(`isRemoteHead`)——既对齐主流 Git UI,也消除「`origin` 叶子」与分组后「`origin` 文件夹」的同名冲突,远程计数随之归为真实分支数。 + +## 切换 UI:原生 TreeView 双命令 + context key + +Branches 为原生 TreeView(非 webview),切换采用 VS Code 惯用范式:两条互斥命令 + `setContext` 驱动工具栏按钮显隐。 + +| 状态 | 工具栏按钮 | 命令 | 图标 | +|---|---|---|---| +| 平铺 | 显示「分组」 | `hyperGit.branchesGroupByPrefix` | `$(list-tree)` | +| 树形 | 显示「平铺」 | `hyperGit.branchesFlatten` | `$(list-flat)` | + +- context key `hyperGit.branchesGrouping` 由 [`extension.ts`](../../src/extension.ts) 在 `activate` 时按 provider 持久化初值设定,命令翻转时同步更新;`package.json` 的 `view/title` `when` 子句据此互斥显示两按钮。 +- 偏好按仓库持久化于 `workspaceState`(键 `hyperGit.branchesGrouping:${repoRoot}`,仿 [`BranchFavorites`](../../src/adapter/branch-favorites.ts)),重启后恢复;文件夹默认展开、点击即折叠/展开,展开态经 `TreeItem.id`(`folder:${group}:${path}`)跨刷新稳定。 + +## 兼容性(命令零破坏) + +文件夹节点使用独立 `contextValue` `hyperGit.branchFolder`,不匹配任何 `view/item/context` 命令 → 无右键菜单;多选批量经 [`selectedBranchRefs`](../../src/adapter/branch-selection.ts) 只保留 `kind === 'branch'`,文件夹节点天然被忽略。checkout / delete / rename / merge / rebase / compare / favorite 等命令均无需改动。 + +## 实现 + +- 引擎:[`engine/ref/ref-tree.ts`](../../src/engine/ref/ref-tree.ts)(+ [`tests/unit/ref-tree.test.ts`](../../tests/unit/ref-tree.test.ts),12 用例)。 +- 适配层:[`adapter/tree/branches-tree.ts`](../../src/adapter/tree/branches-tree.ts)(`BranchFolderNode`、`grouping` 状态与持久化、`isRemoteHead` 过滤、`sectionNodes`/`toBranchNodes`、folder 的 `getTreeItem`)。 +- 装配 / 贡献点:[`extension.ts`](../../src/extension.ts)(context key + 命令)、[`package.json`](../../package.json)(两命令 + `view/title` 互斥菜单)。 + +## 验证 + +`pnpm run test:unit`(含 `ref-tree.test.ts`)全绿;Extension Development Host(F5):Local/Remote/Tags 在平铺 ⇄ 树间即时切换、刷新后保持;共享前缀收拢为文件夹、叶子仅显示后缀;`origin/HEAD` 不再单列、远程计数为真实分支数;文件夹可折叠、compact 生效;分支右键命令与多选批量均正常,文件夹节点无菜单、被多选忽略。 diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index e600ebe..317f1b9 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -27,7 +27,7 @@ - **多 Changelist Changes 视图**:将改动分组到命名列表,设活动列表为提交目标,新建/重命名/删除/移动,`workspaceState` 持久化(重启恢复);状态色复用 `gitDecoration.*` 主题色。 - **Commit 提交窗口**:自绘提交面板 + Conventional Commits 实时校验 + Amend / Sign-off / 跳过 Hook + 提交 / 提交并推送;勾选集即提交权威范围;最近消息复用。 - **Log 提交图(自绘 DAG)**:彩色泳道、分叉·合并连线、HEAD/分支/标签徽标,虚拟滚动增量加载;7 个可组合过滤器(作者/路径/grep/正则/合并模式/日期/清除);per-commit 操作(Reset、新建分支·标签、Cherry-Pick、Revert、查看包含分支)。 -- **Branches 分支管理**:收藏/本地/远程/标签四段分组 + ahead·behind·upstream 跟踪;新建/检出/删除/重命名/合并/变基/比较/收藏;**多选批量**删除、复制引用、收藏(已合并/未合并诚实分栏确认)。 +- **Branches 分支管理**:收藏/本地/远程/标签四段分组 + ahead·behind·upstream 跟踪;**前缀文件夹分组**(平铺 ⇄ 树切换)将共享 `/` 前缀的分支收拢到共同前缀下、仅显示后缀;新建/检出/删除/重命名/合并/变基/比较/收藏;**多选批量**删除、复制引用、收藏(已合并/未合并诚实分栏确认)。 - **Stash & Shelf**:Stash 全操作(含 keep-index / clear / 从 Stash 建分支);独立 **Shelf**(基于 patch、独立于 stash,含 3-way 合并 Unshelve)。 - **Worktrees**:全生命周期管理——新建(新分支/检出/detached)、在新窗口打开、锁定/解锁、移动、删除、清理失效。 - **行级与 Hunk 提交**:编辑器内 CodeLens「Commit this Hunk」、部分暂存/取消暂存、光标处暂存、Hunk 归属 Changelist。 diff --git a/package.json b/package.json index 695c2bc..c0dba0f 100644 --- a/package.json +++ b/package.json @@ -194,6 +194,18 @@ "category": "Hyper Git", "icon": "$(refresh)" }, + { + "command": "hyperGit.branchesGroupByPrefix", + "title": "Group Branches by Prefix", + "category": "Hyper Git", + "icon": "$(list-tree)" + }, + { + "command": "hyperGit.branchesFlatten", + "title": "Show Branches as Flat List", + "category": "Hyper Git", + "icon": "$(list-flat)" + }, { "command": "hyperGit.logFilter", "title": "Filter Graph…", @@ -764,6 +776,16 @@ "when": "view == hyperGit.branches", "group": "navigation@5" }, + { + "command": "hyperGit.branchesGroupByPrefix", + "when": "view == hyperGit.branches && !hyperGit.branchesGrouping", + "group": "navigation@6" + }, + { + "command": "hyperGit.branchesFlatten", + "when": "view == hyperGit.branches && hyperGit.branchesGrouping", + "group": "navigation@6" + }, { "command": "hyperGit.fetch", "when": "view == hyperGit.branches", diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts index 4168b38..a32bf60 100644 --- a/src/adapter/tree/branches-tree.ts +++ b/src/adapter/tree/branches-tree.ts @@ -3,6 +3,7 @@ import type { Ref } from '../../types/git'; import type { GitRepositoryService } from '../git-repository-service'; import type { BranchFavorites } from '../branch-favorites'; import { FOR_EACH_REF_FORMAT, type RawRef, parseForEachRef } from '../../engine/ref/for-each-ref'; +import { buildRefTree, type RefTreeNode } from '../../engine/ref/ref-tree'; import { mdTooltip } from './tree-tooltip'; export type BranchGroupId = 'favorites' | 'local' | 'remote' | 'tags'; @@ -15,13 +16,36 @@ export interface BranchGroupNode { readonly count: number; } +/** 前缀分组文件夹节点(如 `bak` / `origin` / compact 折叠的 `myorg/repo`)。无右键命令。 */ +export interface BranchFolderNode { + readonly kind: 'folder'; + /** 所属分段(local/remote/tags),用于 TreeItem.id 归属隔离。 */ + readonly group: BranchGroupId; + /** 展示段(compact 折叠时形如 `a/b`)。 */ + readonly label: string; + /** 完整前缀路径(展开/折叠稳定 key)。 */ + readonly path: string; + /** 其下叶子总数。 */ + readonly count: number; + readonly children: readonly BranchNode[]; +} + export interface BranchRefNode { readonly kind: 'branch'; readonly ref: RawRef; readonly remote: boolean; + /** 分组形态下的展示后缀(末段);平铺/收藏时省略,回退 `ref.shortName`。 */ + readonly label?: string; } -export type BranchNode = BranchGroupNode | BranchRefNode; +export type BranchNode = BranchGroupNode | BranchFolderNode | BranchRefNode; + +/** 远程符号引用 `refs/remotes//HEAD`(短名如 `origin`):非真实分支,视图统一隐藏, + * 且规避「`origin` 叶子」与分组后「`origin` 文件夹」同名冲突。对 CLI(短名 `origin`)与 + * API(可能为 `origin/HEAD`)两种形态均鲁棒。 */ +function isRemoteHead(r: RawRef): boolean { + return r.isRemote && (!r.shortName.includes('/') || r.shortName.endsWith('/HEAD') || r.refname.endsWith('/HEAD')); +} /** * Branches 视图 TreeDataProvider。 @@ -37,11 +61,18 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider private readonly disposables: vscode.Disposable[] = []; private refsCache: RawRef[] | undefined; private refsInFlight: Promise | undefined; + private readonly groupingKey: string; + /** 「按前缀分组为文件夹树」开关(工具栏切换、按仓库持久化,默认开启)。 */ + private groupingEnabled: boolean; constructor( private readonly service: GitRepositoryService, private readonly favorites: BranchFavorites, + private readonly workspaceState: vscode.Memento, + repoRoot: string, ) { + this.groupingKey = `hyperGit.branchesGrouping:${repoRoot}`; + this.groupingEnabled = this.workspaceState.get(this.groupingKey) ?? true; this.disposables.push(service.onDidChange(() => this.refresh())); this.disposables.push(favorites.onDidChange(() => this.refresh())); } @@ -51,13 +82,32 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider this._onDidChange.fire(undefined); } + /** 当前是否按前缀分组(供 extension 初始化 setContext)。 */ + get grouping(): boolean { + return this.groupingEnabled; + } + + /** 切换分组形态:持久化(按仓库)并刷新视图;no-op 时不刷新。 */ + setGrouping(value: boolean): void { + if (this.groupingEnabled === value) { + return; + } + this.groupingEnabled = value; + void this.workspaceState.update(this.groupingKey, value); + this.refresh(); + } + async getChildren(element?: BranchNode): Promise { const repo = this.service.repo; if (!repo) { return []; } + if (element?.kind === 'folder') { + return [...element.children]; + } + // displayRefs:隐藏远程 HEAD 符号引用(origin/HEAD),供计数 / 收藏 / 分组一致取用。 + const refs = (await this.loadRefs()).filter((r) => !isRemoteHead(r)); if (!element) { - const refs = await this.loadRefs(); const byName = new Map(refs.map((r) => [r.shortName, r] as const)); const favCount = this.favorites.list().filter((n) => byName.has(n)).length; const localCount = refs.filter((r) => !r.isRemote && !r.isTag).length; @@ -77,27 +127,64 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider return groups; } if (element.kind === 'group') { - const refs = await this.loadRefs(); switch (element.id) { case 'favorites': + // 收藏为精选小列表,恒平铺并显示完整短名,便于辨识 `bak/2025` vs `2025`。 return this.favoriteNodes(refs); case 'local': - return this.sortLocal(refs.filter((r) => !r.isRemote && !r.isTag)).map((r) => this.toNode(r)); + return this.sectionNodes( + refs.filter((r) => !r.isRemote && !r.isTag), + 'local', + ); case 'remote': - return refs - .filter((r) => r.isRemote) - .sort((a, b) => a.shortName.localeCompare(b.shortName)) - .map((r) => this.toNode(r)); + return this.sectionNodes( + refs.filter((r) => r.isRemote), + 'remote', + ); case 'tags': - return refs - .filter((r) => r.isTag) - .sort((a, b) => a.shortName.localeCompare(b.shortName)) - .map((r) => this.toNode(r)); + return this.sectionNodes( + refs.filter((r) => r.isTag), + 'tags', + ); } } return []; } + /** + * 生成某分段(local/remote/tags)的子节点:分组开启则按 `/` 前缀构建文件夹树({@link buildRefTree}), + * 关闭则回退平铺(local 用 active→fav→字母序,remote/tags 字母序,保持既有行为)。 + */ + private sectionNodes(refs: RawRef[], group: BranchGroupId): BranchNode[] { + if (this.groupingEnabled) { + const tree = buildRefTree(refs, { + compactFolders: true, + isActive: (r) => this.isActive(r), + isFavorite: (r) => this.favorites.isFavorite(r.shortName), + }); + return this.toBranchNodes(tree, group); + } + const flat = + group === 'local' ? this.sortLocal(refs) : [...refs].sort((a, b) => a.shortName.localeCompare(b.shortName)); + return flat.map((r) => this.toNode(r)); + } + + /** 递归把引擎 {@link RefTreeNode} 映射为适配层 BranchNode(叶携 ref + 后缀 label,文件夹携 group)。 */ + private toBranchNodes(nodes: readonly RefTreeNode[], group: BranchGroupId): BranchNode[] { + return nodes.map((n) => + n.kind === 'leaf' + ? { kind: 'branch', ref: n.ref, remote: n.ref.isRemote, label: n.label } + : { + kind: 'folder', + group, + label: n.label, + path: n.path, + count: n.count, + children: this.toBranchNodes(n.children, group), + }, + ); + } + private toNode(r: RawRef): BranchRefNode { return { kind: 'branch', ref: r, remote: r.isRemote }; } @@ -175,15 +262,30 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider item.contextValue = 'hyperGit.branchGroup'; item.description = String(element.count); const icon = - element.id === 'remote' ? 'repo' : element.id === 'tags' ? 'tag' : element.id === 'favorites' ? 'star-full' : 'git-branch'; + element.id === 'remote' + ? 'repo' + : element.id === 'tags' + ? 'tag' + : element.id === 'favorites' + ? 'star-full' + : 'git-branch'; item.iconPath = new vscode.ThemeIcon(icon); return item; } + if (element.kind === 'folder') { + // 前缀文件夹:默认展开(对齐图2);id 按分段+前缀隔离,令展开态跨 refresh 稳定。 + const item = new vscode.TreeItem(element.label, vscode.TreeItemCollapsibleState.Expanded); + item.id = `folder:${element.group}:${element.path}`; + item.contextValue = 'hyperGit.branchFolder'; + item.iconPath = new vscode.ThemeIcon('folder'); + return item; + } const ref = element.ref; const active = this.isActive(ref); const fav = this.favorites.isFavorite(ref.shortName); const isTag = ref.isTag; - const item = new vscode.TreeItem(ref.shortName, vscode.TreeItemCollapsibleState.None); + // 分组形态下展示后缀(element.label);平铺/收藏回退完整短名。命令仍以 ref.shortName 定位。 + const item = new vscode.TreeItem(element.label ?? ref.shortName, vscode.TreeItemCollapsibleState.None); item.description = this.describe(ref, active); item.contextValue = isTag ? 'hyperGit.tag' : element.remote ? 'hyperGit.remoteBranch' : 'hyperGit.branch'; item.tooltip = this.tooltip(ref, active, fav); diff --git a/src/engine/ref/ref-tree.ts b/src/engine/ref/ref-tree.ts new file mode 100644 index 0000000..121888e --- /dev/null +++ b/src/engine/ref/ref-tree.ts @@ -0,0 +1,175 @@ +/** + * 分支短名 → 前缀目录树构建(纯逻辑,零 vscode 依赖)。 + * + * 供 Branches 视图「按 `/` 前缀分组」形态使用:把 `bak/2025`、`bak/master-2025-07`、 + * `feature/1.0.0` 这类含斜杠短名,按 `/` 拆段收拢为可折叠文件夹节点,叶子仅显示末段后缀 + * (如文件夹 `bak` → `2025` / `master-2025-07`)。与 {@link ../tree/file-tree.buildFileTree} + * 是同构问题(VS Code SCM「按目录分组」范式),故沿用其「MutNode 累积 → finalize → compact」 + * 结构;差异在于:叶子携带完整 {@link RawRef}(供适配层命令定位,短名不丢),且排序为**分支感知** + * (当前 HEAD → 收藏 → 字母序),而非文件树的纯目录优先。 + * + * 远程名可含 `/`(fork `myorg/repo`):本模块只做通用 `/` 拆分,`compact` 折叠会把 + * `myorg/repo` 单目录子链渲染为单个文件夹,视觉上与「remote 为一层」等价,故无需感知 remote + * 边界(删除等正确性敏感逻辑仍走 {@link ./remote-ref.resolveRemoteBranch})。 + */ + +import type { RawRef } from './for-each-ref'; + +/** 叶子节点:一条分支/标签,label 为末段后缀,ref 保留完整短名供命令定位。 */ +export interface RefTreeLeaf { + readonly kind: 'leaf'; + readonly label: string; + readonly ref: RawRef; +} + +/** 文件夹节点:共同前缀,path 为完整前缀(稳定展开 key),count 为其下叶子总数。 */ +export interface RefTreeFolder { + readonly kind: 'folder'; + /** 展示段:前缀名 / compact 折叠的 "a/b"。 */ + readonly label: string; + /** 完整前缀路径(展开/折叠稳定 key)。 */ + readonly path: string; + /** 其下(递归)叶子总数。 */ + readonly count: number; + readonly children: readonly RefTreeNode[]; +} + +export type RefTreeNode = RefTreeFolder | RefTreeLeaf; + +export interface BuildRefTreeOptions { + /** 折叠单目录子链(默认 true,对齐 VS Code;正确处理含 `/` 的 remote 名)。 */ + readonly compactFolders?: boolean; + /** 是否当前 HEAD(排序第 0 档,置顶)。 */ + readonly isActive?: (ref: RawRef) => boolean; + /** 是否收藏(排序第 1 档,次置顶)。 */ + readonly isFavorite?: (ref: RawRef) => boolean; +} + +interface MutNode { + name: string; + readonly dir: boolean; + path: string; + /** 仅叶子:对应 ref。 */ + readonly ref?: RawRef; + /** 仅目录:子节点(Map 保插入序,供稳定排序的相等兜底)。 */ + readonly children: Map; +} + +/** + * 将分支列表按 `shortName` 的 `/` 前缀构建为目录树。 + * 纯函数、永不抛错:非法项跳过,空段过滤,重复短名 keep-first。 + */ +export function buildRefTree(refs: readonly RawRef[], opts?: BuildRefTreeOptions): readonly RefTreeNode[] { + const compact = opts?.compactFolders ?? true; + const isActive = opts?.isActive ?? (() => false); + const isFavorite = opts?.isFavorite ?? (() => false); + const root = new Map(); + const seen = new Set(); + + for (const ref of refs) { + const short = ref?.shortName; + if (typeof short !== 'string') { + continue; + } + const segs = short.split('/').filter((s) => s.length > 0); + if (segs.length === 0 || seen.has(short)) { + continue; + } + seen.add(short); + + let level = root; + let acc = ''; + for (let i = 0; i < segs.length - 1; i++) { + acc = acc ? `${acc}/${segs[i]}` : segs[i]; + let node = level.get(segs[i]); + if (!node || !node.dir) { + node = { name: segs[i], dir: true, path: acc, children: new Map() }; + level.set(segs[i], node); + } + level = node.children; + } + const leafName = segs[segs.length - 1]; + // 叶子与同名目录冲突时以先到者为准(正常 git ref 不会出现 D/F 冲突,如 `a` 与 `a/b`)。 + if (!level.has(leafName)) { + level.set(leafName, { name: leafName, dir: false, path: short, ref, children: new Map() }); + } + } + + return finalizeLevel(root, compact, isActive, isFavorite); +} + +/** 单层排序:当前 HEAD → 收藏 → (同档)文件夹在前 → 名称数字感知升序,稳定(相等按插入序)。 */ +function finalizeLevel( + level: Map, + compact: boolean, + isActive: (ref: RawRef) => boolean, + isFavorite: (ref: RawRef) => boolean, +): RefTreeNode[] { + return Array.from(level.values()) + .sort((a, b) => compareNodes(a, b, isActive, isFavorite)) + .map((n) => finalizeNode(n, compact, isActive, isFavorite)); +} + +/** 排序档位:叶子按 active(0) / favorite(1) / 其它(2);文件夹恒为 2(不冒泡活动态)。 */ +function tierOf(node: MutNode, isActive: (ref: RawRef) => boolean, isFavorite: (ref: RawRef) => boolean): number { + if (node.dir || !node.ref) { + return 2; + } + return isActive(node.ref) ? 0 : isFavorite(node.ref) ? 1 : 2; +} + +function compareNodes( + a: MutNode, + b: MutNode, + isActive: (ref: RawRef) => boolean, + isFavorite: (ref: RawRef) => boolean, +): number { + const ta = tierOf(a, isActive, isFavorite); + const tb = tierOf(b, isActive, isFavorite); + if (ta !== tb) { + return ta - tb; + } + // 同档内目录在前、叶子在后(对齐 VS Code 与图2:文件夹先于散叶)。用折叠前的顶层段名排序。 + if (a.dir !== b.dir) { + return a.dir ? -1 : 1; + } + return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); +} + +function finalizeNode( + node: MutNode, + compact: boolean, + isActive: (ref: RawRef) => boolean, + isFavorite: (ref: RawRef) => boolean, +): RefTreeNode { + if (!node.dir) { + // 叶子:ref 必非空(buildRefTree 仅以 dir:false + ref 建叶)。 + return { kind: 'leaf', label: node.name, ref: node.ref as RawRef }; + } + let name = node.name; + let path = node.path; + let childMap = node.children; + if (compact) { + // 折叠单目录子链(如 `a/b/c`),遇含叶子或多子的目录即停。 + while (childMap.size === 1) { + const only = childMap.values().next().value as MutNode; + if (!only.dir) { + break; + } + name = `${name}/${only.name}`; + path = only.path; + childMap = only.children; + } + } + const children = finalizeLevel(childMap, compact, isActive, isFavorite); + return { kind: 'folder', label: name, path, count: countLeaves(children), children }; +} + +/** 递归统计一组节点下的叶子总数。 */ +function countLeaves(nodes: readonly RefTreeNode[]): number { + let n = 0; + for (const node of nodes) { + n += node.kind === 'leaf' ? 1 : node.count; + } + return n; +} diff --git a/src/extension.ts b/src/extension.ts index fc53702..901e74d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -63,9 +63,7 @@ export async function activate(context: vscode.ExtensionContext): Promise if (!api) { logger.warn('vscode.git API 不可用,视图保持空状态'); const empty = new EmptyTreeProvider(); - context.subscriptions.push( - vscode.window.registerTreeDataProvider('hyperGit.worktrees', empty), - ); + context.subscriptions.push(vscode.window.registerTreeDataProvider('hyperGit.worktrees', empty)); return; } @@ -86,7 +84,12 @@ export async function activate(context: vscode.ExtensionContext): Promise }); const commitView = new CommitWebviewProvider(service, registry, commit); const logTree = new LogWebviewProvider(service, ciService); - const branchesTree = new BranchesTreeProvider(service, favorites); + const branchesTree = new BranchesTreeProvider( + service, + favorites, + context.workspaceState, + service.repoRoot ?? workspaceRoot, + ); // Branches 视图启用多选(canSelectMany 仅 createTreeView 支持,registerTreeDataProvider 不支持); // 多选后批量操作(删除分支/标签、复制引用、收藏)作用于整个选区。 const branchesView = vscode.window.createTreeView('hyperGit.branches', { @@ -111,6 +114,17 @@ export async function activate(context: vscode.ExtensionContext): Promise void vscode.commands.executeCommand('hyperGit.commit.focus'); }; + // Branches 「平铺 ⇄ 树」切换:context key 驱动工具栏两个按钮互斥显示(见 package.json when 子句), + // 初值取自 provider 的持久化状态;命令翻转分组态并同步 context key。 + const setBranchesGroupingContext = (v: boolean): void => { + void vscode.commands.executeCommand('setContext', 'hyperGit.branchesGrouping', v); + }; + setBranchesGroupingContext(branchesTree.grouping); + const applyBranchesGrouping = (v: boolean): void => { + branchesTree.setGrouping(v); + setBranchesGroupingContext(v); + }; + context.subscriptions.push( service, registry, @@ -139,6 +153,8 @@ export async function activate(context: vscode.ExtensionContext): Promise ...registerMergeCommands(service), ...registerMiscCommands(service, branchesTree, logTree), vscode.commands.registerCommand('hyperGit.toggleBlameAnnotation', () => blame.toggle()), + vscode.commands.registerCommand('hyperGit.branchesGroupByPrefix', () => applyBranchesGrouping(true)), + vscode.commands.registerCommand('hyperGit.branchesFlatten', () => applyBranchesGrouping(false)), ...registerStashCommands(service, stashTree), ...registerShelfCommands(service, shelfService, shelfTree), ...registerWorktreeCommands(service, worktreeTree), diff --git a/tests/unit/ref-tree.test.ts b/tests/unit/ref-tree.test.ts new file mode 100644 index 0000000..40485d9 --- /dev/null +++ b/tests/unit/ref-tree.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from 'vitest'; +import { buildRefTree, type RefTreeNode } from '../../src/engine/ref/ref-tree'; +import type { RawRef } from '../../src/engine/ref/for-each-ref'; + +/** 构造最小 RawRef(仅填测试关心字段)。 */ +function ref(shortName: string, over: Partial = {}): RawRef { + return { + refname: `refs/heads/${shortName}`, + shortName, + objectname: 'deadbee', + upstream: undefined, + ahead: undefined, + behind: undefined, + head: false, + isRemote: false, + isTag: false, + ...over, + }; +} + +/** 便于断言:把树摊平为 "path:label(kind)" 结构快照。 */ +function shape(nodes: readonly RefTreeNode[]): unknown { + return nodes.map((n) => + n.kind === 'leaf' + ? { leaf: n.label, ref: n.ref.shortName } + : { folder: n.label, path: n.path, count: n.count, children: shape(n.children) }, + ); +} + +describe('ref-tree buildRefTree', () => { + it('空输入返回空数组', () => { + expect(buildRefTree([])).toEqual([]); + }); + + it('无 / 的分支保持根级叶子(label 即短名)', () => { + const tree = buildRefTree([ref('master'), ref('2022')]); + expect(shape(tree)).toEqual([ + { leaf: '2022', ref: '2022' }, + { leaf: 'master', ref: 'master' }, + ]); + }); + + it('单前缀单分支也成文件夹,叶子仅显示后缀', () => { + const tree = buildRefTree([ref('feature/1.0.0')]); + expect(shape(tree)).toEqual([ + { folder: 'feature', path: 'feature', count: 1, children: [{ leaf: '1.0.0', ref: 'feature/1.0.0' }] }, + ]); + }); + + it('同前缀多分支收拢到同一文件夹,叶子仅后缀', () => { + const tree = buildRefTree([ref('bak/2025'), ref('bak/master-2025-07')]); + expect(shape(tree)).toEqual([ + { + folder: 'bak', + path: 'bak', + count: 2, + children: [ + { leaf: '2025', ref: 'bak/2025' }, + { leaf: 'master-2025-07', ref: 'bak/master-2025-07' }, + ], + }, + ]); + }); + + it('图2 Local 排序:当前 HEAD → 收藏 → 文件夹(字母序)→ 散叶(数字感知)', () => { + const favSet = new Set(['master']); + const tree = buildRefTree( + [ + ref('2024-bak'), + ref('feature/1.0.0'), + ref('2022'), + ref('master'), + ref('bak/2025'), + ref('2024'), + ref('2026', { head: true }), + ref('2023'), + ref('bak/master-2025-07'), + ref('2024-10-29'), + ], + { isActive: (r) => r.head, isFavorite: (r) => favSet.has(r.shortName) }, + ); + expect(tree.map((n) => (n.kind === 'folder' ? `📁${n.label}` : n.label))).toEqual([ + '2026', // active + 'master', // favorite + '📁bak', // 文件夹优先 + '📁feature', + '2022', + '2023', + '2024', + '2024-10-29', + '2024-bak', + ]); + }); + + it('远程整体收拢为单个 origin 文件夹,内部再嵌 feature', () => { + const remote = (s: string): RawRef => ref(s, { isRemote: true, refname: `refs/remotes/${s}` }); + const tree = buildRefTree([remote('origin/2022'), remote('origin/feature/1.0.0'), remote('origin/master')]); + expect(shape(tree)).toEqual([ + { + folder: 'origin', + path: 'origin', + count: 3, + children: [ + { + folder: 'feature', + path: 'origin/feature', + count: 1, + children: [{ leaf: '1.0.0', ref: 'origin/feature/1.0.0' }], + }, + { leaf: '2022', ref: 'origin/2022' }, + { leaf: 'master', ref: 'origin/master' }, + ], + }, + ]); + }); + + it('compact 折叠含 / 的 remote 名(myorg/repo 视觉合并为单文件夹)', () => { + const remote = (s: string): RawRef => ref(s, { isRemote: true, refname: `refs/remotes/${s}` }); + const tree = buildRefTree([remote('myorg/repo/a'), remote('myorg/repo/b')]); + expect(shape(tree)).toEqual([ + { + folder: 'myorg/repo', + path: 'myorg/repo', + count: 2, + children: [ + { leaf: 'a', ref: 'myorg/repo/a' }, + { leaf: 'b', ref: 'myorg/repo/b' }, + ], + }, + ]); + }); + + it('compact 关:逐级建目录不折叠', () => { + const tree = buildRefTree([ref('a/b/c')], { compactFolders: false }); + expect(shape(tree)).toEqual([ + { + folder: 'a', + path: 'a', + count: 1, + children: [{ folder: 'b', path: 'a/b', count: 1, children: [{ leaf: 'c', ref: 'a/b/c' }] }], + }, + ]); + }); + + it('compact 不越过含叶子或多子的目录', () => { + // a 直接含叶子 x 与目录 b → 不折叠 a;b 单目录子链 c → 折叠 b/c + const tree = buildRefTree([ref('a/x'), ref('a/b/c/d')]); + expect(shape(tree)).toEqual([ + { + folder: 'a', + path: 'a', + count: 2, + children: [ + { folder: 'b/c', path: 'a/b/c', count: 1, children: [{ leaf: 'd', ref: 'a/b/c/d' }] }, + { leaf: 'x', ref: 'a/x' }, + ], + }, + ]); + }); + + it('去重:重复短名 keep-first', () => { + const tree = buildRefTree([ref('feature/x'), ref('feature/x')]); + expect(shape(tree)).toEqual([ + { folder: 'feature', path: 'feature', count: 1, children: [{ leaf: 'x', ref: 'feature/x' }] }, + ]); + }); + + it('跳过空段 / 纯分隔短名', () => { + expect(buildRefTree([ref('/'), ref('//')])).toEqual([]); + }); + + it('文件夹 count 递归统计全部后代叶子', () => { + const tree = buildRefTree([ref('g/a/1'), ref('g/a/2'), ref('g/b/1')], { compactFolders: false }); + const g = tree[0]; + expect(g.kind === 'folder' && g.count).toBe(3); + }); +});