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);
+ });
+});