Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/.agents/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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」的时序类误区。


5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@
"id": "hyperGit.worktrees",
"name": "Worktrees",
"visibility": "visible"
},
{
"id": "hyperGit.changesBadge",
"name": "Uncommitted",
"when": "false"
}
]
},
Expand Down
21 changes: 19 additions & 2 deletions src/adapter/git-repository-service.ts
Original file line number Diff line number Diff line change
@@ -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/操作)。 */
Expand Down Expand Up @@ -72,7 +72,7 @@ export class GitRepositoryService implements vscode.Disposable {
const root = repo.rootUri.fsPath;
const map = new Map<string, ChangeItem>();
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;
}
Expand All @@ -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);
Expand Down
20 changes: 2 additions & 18 deletions src/adapter/webview/commit-webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -46,28 +45,13 @@ 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();
}

refresh(): void {
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':
Expand Down
30 changes: 30 additions & 0 deletions src/engine/scm-mapping/change-count.ts
Original file line number Diff line number Diff line change
@@ -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<ReadonlyArray<{ readonly uri: { readonly fsPath: string } }>>
): number {
const seen = new Set<string>();
for (const group of groups) {
for (const change of group) {
seen.add(toRelKey(root, change.uri.fsPath));
}
}
return seen.size;
}
39 changes: 31 additions & 8 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
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');
};
Expand All @@ -116,6 +123,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
shelfTree,
blame,
branchesView,
badgeView,
vscode.window.registerWebviewViewProvider(CommitWebviewProvider.viewType, commitView),
vscode.window.registerWebviewViewProvider(LogWebviewProvider.viewType, logTree),
vscode.window.registerTreeDataProvider('hyperGit.stash', stashTree),
Expand All @@ -142,19 +150,27 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
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<typeof setTimeout> | 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<typeof setTimeout> | undefined;
const refreshAll = (): void => {
scheduleBadge();
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
updateCommitBadge();
commitView.refresh();
logTree.refresh();
branchesTree.refresh();
Expand All @@ -168,7 +184,14 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
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 此后可能不再触发。主动刷新一次确保
Expand All @@ -177,7 +200,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
branchesTree.refresh();
logTree.refresh();
worktreeTree.refresh();
updateCommitBadge();
updateBadge();
}, 500);
}

Expand Down
69 changes: 69 additions & 0 deletions tests/unit/change-count.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReadonlyArray<{ uri: { fsPath: string } }>>): number {
const map = new Map<string, unknown>();
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);
});
});