From 6527f6f7763f6d5273376b9d3c24c6a90ea3950a Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 4 Jul 2026 13:07:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(Badge):=20=E4=BF=AE=E5=A4=8D=E6=B4=BB?= =?UTF-8?q?=E5=8A=A8=E6=A0=8F=E6=9C=AA=E6=8F=90=E4=BA=A4=E6=95=B0=E8=A7=92?= =?UTF-8?q?=E6=A0=87=E6=9B=B4=E6=96=B0=E4=B8=8D=E5=8F=8A=E6=97=B6;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:角标挂在 Commit WebviewView 上,VS Code 在 resolveWebviewView(用户打开过面板)前无法显示 webview 角标(vscode#164974/#146330),故面板未打开时新变更不点亮、提交/撤销后不清除; - 承载迁移:改由隐藏 TreeView(hyperGit.changesBadge, when:false)承载,createTreeView 于 activate 即实例化,其 badge 无论面板是否打开都可靠聚合到容器图标; - 单一事实源:新增 engine 纯函数 change-count(toRelKey/countUniqueChanges)与 GitRepositoryService.getChangeCount(),getChanges 复用同一去重逻辑,避免双实现漂移; - 及时且高效:角标走独立 40ms 微防抖快路径,与 150ms 重刷新解耦、合并事件风暴;释放期清理悬挂定时器; - 熵减:移除 Commit webview 死代码(updateBadge/pendingBadge)杜绝容器求和 2× 计数;补充 change-count 单元测试锁定计数等值不变式。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- package.json | 5 ++ src/adapter/git-repository-service.ts | 21 +++++++- src/adapter/webview/commit-webview.ts | 20 +------- src/engine/scm-mapping/change-count.ts | 30 +++++++++++ src/extension.ts | 39 ++++++++++++--- tests/unit/change-count.test.ts | 69 ++++++++++++++++++++++++++ 6 files changed, 156 insertions(+), 28 deletions(-) create mode 100644 src/engine/scm-mapping/change-count.ts create mode 100644 tests/unit/change-count.test.ts diff --git a/package.json b/package.json index 276dd10..222455a 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,11 @@ "id": "hyperGit.worktrees", "name": "Worktrees", "visibility": "visible" + }, + { + "id": "hyperGit.changesBadge", + "name": "Uncommitted", + "when": "false" } ] }, diff --git a/src/adapter/git-repository-service.ts b/src/adapter/git-repository-service.ts index 12eec17..e4a2cfd 100644 --- a/src/adapter/git-repository-service.ts +++ b/src/adapter/git-repository-service.ts @@ -1,9 +1,9 @@ import { execFile } from 'child_process'; -import * as path from 'path'; import * as vscode from 'vscode'; import { logGit } from '../infra/git-console'; import type { API, Change, Repository } from '../types/git'; import { FileStatus } from '../engine/model'; +import { countUniqueChanges, toRelKey } from '../engine/scm-mapping/change-count'; import { mapGitStatus } from './git-status-map'; /** 适配层视图模型:一个文件的变更(携带 vscode.Uri 供 diff/操作)。 */ @@ -72,7 +72,7 @@ export class GitRepositoryService implements vscode.Disposable { const root = repo.rootUri.fsPath; const map = new Map(); const add = (c: Change, staged: boolean): void => { - const rel = path.relative(root, c.uri.fsPath).split(path.sep).join('/'); + const rel = toRelKey(root, c.uri.fsPath); if (map.has(rel)) { return; } @@ -97,6 +97,23 @@ export class GitRepositoryService implements vscode.Disposable { return [...map.values()]; } + /** + * 未提交变更计数(已暂存 + 工作区 + 未跟踪,按相对路径去重)。语义等同 `getChanges().length`, + * 但不构造 ChangeItem,供活动栏角标高频刷新走轻量路径(复用同一去重事实源)。 + */ + getChangeCount(): number { + const repo = this._repo; + if (!repo) { + return 0; + } + return countUniqueChanges( + repo.rootUri.fsPath, + repo.state.indexChanges, + repo.state.workingTreeChanges, + repo.state.untrackedChanges, + ); + } + /** 构造任意 ref 版本的资源 Uri(diff 原始端,复用 vscode.git 的 git scheme)。 */ toGitUri(uri: vscode.Uri, ref: string): vscode.Uri { return this.api.toGitUri(uri, ref); diff --git a/src/adapter/webview/commit-webview.ts b/src/adapter/webview/commit-webview.ts index 5ee7334..70c72e9 100644 --- a/src/adapter/webview/commit-webview.ts +++ b/src/adapter/webview/commit-webview.ts @@ -22,14 +22,13 @@ import { getBaseStyles } from './shared-styles'; * 承载活动 changelist 文件列表(平铺 / 目录树两态可切)+ 文件单击看 diff + 单文件右键操作 + * changelist 切换与管理(由原 Changes 视图平移而来)+ 多行 Commit Message 编辑器 + * Amend/sign-off/skip-hooks 选项 + Commit/Commit and Push 按钮 + Conventional Commits 实时校验 + - * 最近消息复用 + 活动栏未提交数角标。选中态由 webview 端管理(host 不回写,避免覆盖用户操作)。 + * 最近消息复用。选中态由 webview 端管理(host 不回写,避免覆盖用户操作)。 + * 注:活动栏未提交数角标已迁至隐藏的 hyperGit.changesBadge TreeView 承载(见 extension.ts)。 */ export class CommitWebviewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'hyperGit.commit'; private view?: vscode.WebviewView; private currentMessage = ''; - /** view 尚未 resolve 时暂存的角标计数,resolve 后回填(活动栏容器角标 = 各视图 badge 之和)。 */ - private pendingBadge?: number; constructor( private readonly service: GitRepositoryService, @@ -46,11 +45,6 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider { msgSub.dispose(); this.view = undefined; }); - if (this.pendingBadge !== undefined) { - const c = this.pendingBadge; - this.pendingBadge = undefined; - this.updateBadge(c); - } this.pushState(); } @@ -58,16 +52,6 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider { this.pushState(); } - /** 未提交数角标(由 extension 的 refreshAll 驱动):迁自原 Changes 视图,未 resolve 时暂存。 */ - updateBadge(count: number): void { - const badge = count > 0 ? { value: count, tooltip: `${count} uncommitted change(s)` } : undefined; - if (this.view) { - this.view.badge = badge; - } else { - this.pendingBadge = count; - } - } - private onMessage(msg: WebviewToHostMessage): void { switch (msg.type) { case 'requestState': diff --git a/src/engine/scm-mapping/change-count.ts b/src/engine/scm-mapping/change-count.ts new file mode 100644 index 0000000..494827c --- /dev/null +++ b/src/engine/scm-mapping/change-count.ts @@ -0,0 +1,30 @@ +import * as path from 'path'; + +/** + * 变更去重的单一事实源(Single Source of Truth)。 + * + * `getChanges()`(构造 ChangeItem[])与 `getChangeCount()`(仅计数)共用同一相对路径归一逻辑, + * 杜绝双实现漂移导致「列表条目数」与「活动栏角标数」不一致。 + */ + +/** 仓库相对路径(posix 分隔),作为 changelist 分组与去重的稳定 key。 */ +export function toRelKey(root: string, fsPath: string): string { + return path.relative(root, fsPath).split(path.sep).join('/'); +} + +/** + * 去重后的变更计数:按相对路径跨多组(index / 工作区 / 未跟踪)去重,仅返回唯一路径数。 + * 与 `getChanges().length` 严格相等,但不分配 ChangeItem 对象,供高频角标刷新走轻量路径。 + */ +export function countUniqueChanges( + root: string, + ...groups: ReadonlyArray> +): number { + const seen = new Set(); + for (const group of groups) { + for (const change of group) { + seen.add(toRelKey(root, change.uri.fsPath)); + } + } + return seen.size; +} diff --git a/src/extension.ts b/src/extension.ts index 0a07ca2..da79ae7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -99,6 +99,13 @@ export async function activate(context: vscode.ExtensionContext): Promise const blame = new BlameAnnotationController(service); const shelfService = new ShelfService(service, context.globalStorageUri.fsPath); const shelfTree = new ShelfTreeProvider(shelfService); + // 活动栏未提交数角标承载:隐藏 TreeView(package.json 中 when:false,永不渲染)。createTreeView 于 + // activate 即实例化视图对象,其 badge 无论面板是否打开都可靠聚合到容器图标——规避 WebviewView.badge + // 在 resolveWebviewView(用户至少打开过一次视图)前无法显示的已知限制(microsoft/vscode#164974、#146330)。 + // 复用占位 EmptyTreeProvider(空树)。 + const badgeView = vscode.window.createTreeView('hyperGit.changesBadge', { + treeDataProvider: new EmptyTreeProvider(), + }); const focusCommitView = (): void => { void vscode.commands.executeCommand('hyperGit.commit.focus'); }; @@ -116,6 +123,7 @@ export async function activate(context: vscode.ExtensionContext): Promise shelfTree, blame, branchesView, + badgeView, vscode.window.registerWebviewViewProvider(CommitWebviewProvider.viewType, commitView), vscode.window.registerWebviewViewProvider(LogWebviewProvider.viewType, logTree), vscode.window.registerTreeDataProvider('hyperGit.stash', stashTree), @@ -142,19 +150,27 @@ export async function activate(context: vscode.ExtensionContext): Promise registerInlineCommitCommand(service, inlineLens), ); - // 活动栏角标:复用 service.getChanges() 计数(index+工作区+未跟踪去重),承载于 Commit webview; - // 活动栏容器图标角标 = 容器内各视图 badge 之和,故点亮 Commit 视图角标即映射到 Hyper Git 图标。 - // 计数为 0 时清空,对齐原生 SCM 行为。 - const updateCommitBadge = (): void => { - commitView.updateBadge(service.getChanges().length); + // 活动栏未提交数角标:承载于隐藏的 changesBadge TreeView(见其创建处说明)。 + // 计数复用 service.getChangeCount()(index+工作区+未跟踪去重),为 0 时清空,对齐原生 SCM 行为。 + const updateBadge = (): void => { + const n = service.getChangeCount(); + badgeView.badge = n > 0 ? { value: n, tooltip: `${n} uncommitted change(s)` } : undefined; + }; + + // 角标走独立快路径(~40ms 微防抖):面板即便未打开也近实时更新,并合并 add -A/checkout 等事件风暴, + // 与下方重刷新(150ms)解耦,避免被 log/branches 等高频重拉阻塞。 + let badgeTimer: ReturnType | undefined; + const scheduleBadge = (): void => { + clearTimeout(badgeTimer); + badgeTimer = setTimeout(updateBadge, 40); }; - // git 状态变化频繁(add/checkout/diff 缓存失效均触发),防抖合并避免 log/stash 高频重拉。 + // git 状态变化频繁(add/checkout/diff 缓存失效均触发),重刷新防抖合并避免 log/stash 高频重拉。 let refreshTimer: ReturnType | undefined; const refreshAll = (): void => { + scheduleBadge(); clearTimeout(refreshTimer); refreshTimer = setTimeout(() => { - updateCommitBadge(); commitView.refresh(); logTree.refresh(); branchesTree.refresh(); @@ -168,7 +184,14 @@ export async function activate(context: vscode.ExtensionContext): Promise service.onDidChange(refreshAll), registry.onDidChange(refreshAll), commit.onDidChange(refreshAll), + // 释放期清理悬挂定时器,避免回调触及已 dispose 的视图。 + new vscode.Disposable(() => { + clearTimeout(badgeTimer); + clearTimeout(refreshTimer); + }), ); + // 首帧同步:即便后续无事件也确保角标初值正确。 + updateBadge(); // 首帧保险:若 repo 在 activate 前已就绪,GitRepositoryService 构造函数的 _onDidChange.fire() // 早于任何订阅者挂载而被丢失,state.onDidChange 此后可能不再触发。主动刷新一次确保 @@ -177,7 +200,7 @@ export async function activate(context: vscode.ExtensionContext): Promise branchesTree.refresh(); logTree.refresh(); worktreeTree.refresh(); - updateCommitBadge(); + updateBadge(); }, 500); } diff --git a/tests/unit/change-count.test.ts b/tests/unit/change-count.test.ts new file mode 100644 index 0000000..e14f481 --- /dev/null +++ b/tests/unit/change-count.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { countUniqueChanges, toRelKey } from '../../src/engine/scm-mapping/change-count'; + +/** 构造最小 change:仅携带 uri.fsPath(countUniqueChanges 只读该字段)。 */ +const c = (fsPath: string): { uri: { fsPath: string } } => ({ uri: { fsPath } }); + +const ROOT = '/repo'; + +/** 参考实现:镜像 GitRepositoryService.getChanges() 的去重(Map first-wins),用于锁定计数等值不变式。 */ +function refCount(root: string, ...groups: ReadonlyArray>): number { + const map = new Map(); + for (const g of groups) { + for (const ch of g) { + const rel = toRelKey(root, ch.uri.fsPath); + if (!map.has(rel)) { + map.set(rel, ch); + } + } + } + return map.size; +} + +describe('toRelKey', () => { + it('绝对路径 → 仓库相对 posix 路径', () => { + expect(toRelKey(ROOT, '/repo/src/a.ts')).toBe('src/a.ts'); + expect(toRelKey(ROOT, '/repo/README.md')).toBe('README.md'); + }); + + it('嵌套目录保留层级', () => { + expect(toRelKey(ROOT, '/repo/a/b/c/d.ts')).toBe('a/b/c/d.ts'); + }); +}); + +describe('countUniqueChanges', () => { + it('空 → 0', () => { + expect(countUniqueChanges(ROOT)).toBe(0); + expect(countUniqueChanges(ROOT, [], [], [])).toBe(0); + }); + + it('单组不相交 → 条目数', () => { + expect(countUniqueChanges(ROOT, [c('/repo/a.ts'), c('/repo/b.ts')])).toBe(2); + }); + + it('三组不相交 → 求和', () => { + const index = [c('/repo/a.ts')]; + const work = [c('/repo/b.ts'), c('/repo/c.ts')]; + const untracked = [c('/repo/d.ts')]; + expect(countUniqueChanges(ROOT, index, work, untracked)).toBe(4); + }); + + it('跨组同路径(暂存+改动同文件)→ 去重为 1', () => { + const index = [c('/repo/src/x.ts')]; + const work = [c('/repo/src/x.ts')]; + expect(countUniqueChanges(ROOT, index, work)).toBe(1); + }); + + it('组内重复路径 → 去重', () => { + expect(countUniqueChanges(ROOT, [c('/repo/a.ts'), c('/repo/a.ts')])).toBe(1); + }); + + it('计数与 getChanges 去重语义严格相等(混合重叠夹具)', () => { + const index = [c('/repo/a.ts'), c('/repo/shared.ts')]; + const work = [c('/repo/b.ts'), c('/repo/shared.ts'), c('/repo/dir/c.ts')]; + const untracked = [c('/repo/dir/c.ts'), c('/repo/new.ts')]; + expect(countUniqueChanges(ROOT, index, work, untracked)).toBe(refCount(ROOT, index, work, untracked)); + // 唯一路径:a, shared, b, dir/c, new = 5 + expect(countUniqueChanges(ROOT, index, work, untracked)).toBe(5); + }); +}); From 2bc816c6e54700910e01e36788c1045dbf1487f5 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 4 Jul 2026 13:27:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(Issue):=20=E8=A1=A5=E5=BD=95=20#10=20?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E6=A0=8F=E8=A7=92=E6=A0=87=20WebviewView.bad?= =?UTF-8?q?ge=20resolve=20=E5=89=8D=E4=B8=8D=E6=98=BE=E7=A4=BA=E7=9A=84?= =?UTF-8?q?=E6=A0=B9=E5=9B=A0=E4=B8=8E=E5=A4=84=E7=90=86;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 留存本次角标更新不及时问题的表因/根因/处理/防范,并交叉引用 #8 关于 badge 承载与 pendingBadge 时序的预警。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- docs/.agents/issue.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 3f04e62..be55432 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -78,4 +78,12 @@ - **后续防范**:① 「全分支视图」语义应映射到 `--branches --tags --remotes` 而非 `--all`——`--all` 是「全部引用」而非「全部分支」,二者差异恰是工具注入引用的污染面。② 客户端按提交 message 正则过滤是**漏的抽象**(拦不住作为祖先被带入的游离提交);根治应在 ref 选取层(服务端参数)而非 subject 过滤层。③ **诊断 git 引用类问题时务必先 `git for-each-ref` 列出全部命名空间**——本案最初误判为「远端已删、本地未 prune」(#44 与一度推进的 prune-on-fetch 方案均为此误判),直到列出 refs 才发现真凶是 conductor-* 引用;「prune 无效」本身就是关键反证,应据其反向收敛而非强行加 prune。④ 修正「错漏逻辑」前先用 `git log --all` vs `--branches --tags --remotes` 的差集实证根因,避免再次基于关键字匹配机械式修改。 - **同类问题影响**:所有在带「工具注入内部引用」环境(IDE/Agent checkpoint、`refs/stash`、`refs/replace/*`、`refs/notes/*` 等)下展示 `git log --all` 图的 Git GUI;凡把「范围 = 引用集合」与「范围 = message 过滤」混为一谈的实现均可能漏过游离提交。 +## #10 活动栏未提交数角标更新不及时(WebviewView.badge resolve 前不显示) + +- **表因**:用户截图反馈 Hyper Git 活动栏图标的未提交变更数角标更新不及时——有时已有变更却不显示角标,有时文件已提交/撤销角标仍不消失。 +- **根因**:角标承载于 Commit `WebviewView.badge`(#8 移除 Changes 视图后迁入)。命中 VS Code 已知限制:webview 角标在 `resolveWebviewView`(即用户至少打开过一次该视图)之前无法显示([microsoft/vscode#164974](https://github.com/microsoft/vscode/issues/164974)、[#146330](https://github.com/microsoft/vscode/issues/146330));源码印证 `commit-webview.ts` 未 resolve 时 `updateBadge` 仅写入 `pendingBadge`、永不上屏,`WebviewView.onDidDispose` 亦仅在用户显式取消勾选视图时触发。故只要面板未打开/隐藏(用户在编辑器或其他活动容器工作),新变更无法点亮、提交/撤销后无法清除。#8 的「后续防范」已预警此 `pendingBadge` 首帧时序隐患,本 Issue 即其兑现。TreeView 无此限制——`createTreeView` 可在 activate 强制实例化视图对象,`.badge` 无论可见与否都可靠聚合到容器图标(容器角标 = 容器内各视图 badge 之和)。 +- **处理方式**:新增隐藏承载视图 `hyperGit.changesBadge`(package.json `when:false`,永不渲染,复用 `EmptyTreeProvider`),经 `createTreeView` 于 activate 即实例化并置 `.badge`;角标承载由 Commit WebviewView 整体迁出(移除 `updateBadge`/`pendingBadge` 死代码,杜绝容器求和 2× 计数)。新增 `engine/scm-mapping/change-count.ts`(`toRelKey`/`countUniqueChanges`)作为去重单一事实源,`GitRepositoryService.getChangeCount()` 与 `getChanges()` 共用;角标走独立 40ms 微防抖快路径(与 150ms 重刷新解耦、合并事件风暴、释放期清理定时器),首帧同步置初值。 +- **后续防范**:① 需要「面板未打开也持续显示」的活动栏计数角标,**必须**承载于 `createTreeView` 建立的 TreeView(可用 `when:false` 隐藏视图专职承载),**不可**依赖 `WebviewView.badge`——其 resolve 前不显示是 VS Code 已知限制而非本仓 bug;这与 #8「`.badge` TreeView/WebviewView 均支持」并行:「支持置 badge」≠「未 resolve 也上屏」。② 容器角标为**各视图 badge 之和**,全仓须保证**唯一承载者**,迁移承载时务必删除旧承载,否则重复计数。③ 计数与文件列表去重须共用单一事实源(`toRelKey`),避免「列表条目数 ≠ 角标数」漂移。④ `when:false` 承载视图的实机角标渲染需在 EDH 回归确认(跨 VS Code 版本聚合行为),失败则回退为 `visibility:collapsed` 的空视图。 +- **同类问题影响**:所有以 `WebviewView.badge` 承载活动栏/视图角标的自定义视图容器扩展;凡角标承载迁移未清理旧承载导致的重复计数;以及把「支持 badge 属性」误判为「隐藏态也能显示 badge」的时序类误区。 +