From 543833527d07f90fc407fe90bf5f60d83976449c Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 23:08:55 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(engine):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=A4=9A=E6=A0=B9=E4=BB=93=E5=BA=93=E9=80=89=E5=8F=96=E4=B8=8E?= =?UTF-8?q?=20shelf=20=E7=9B=AE=E5=BD=95=E5=91=BD=E5=90=8D=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0(issue=20#107);?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pickRepositoryRoot:持久化恢复 → 首个 folder 匹配(可注入 getRepository 语义, 缺省最长祖先匹配)→ 首个仓库 的三级优先选取,路径归一化(去尾分隔符 + win32 大小写不敏感)保证跨平台稳定比较; - shelfRepoDirName:basename 截 48 字符 + sha1 前 8 位生成按仓库隔离的 shelf 存储子目录名,防同名仓库碰撞; - 配套 19 个单测(选取优先级/归一化/碰撞/截断/兜底)。 🤖 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 --- src/engine/git-state/repo-selection.ts | 74 ++++++++++++++++++++++++ src/engine/git-state/shelf-dir.ts | 18 ++++++ tests/unit/repo-selection.test.ts | 78 ++++++++++++++++++++++++++ tests/unit/shelf-dir.test.ts | 35 ++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 src/engine/git-state/repo-selection.ts create mode 100644 src/engine/git-state/shelf-dir.ts create mode 100644 tests/unit/repo-selection.test.ts create mode 100644 tests/unit/shelf-dir.test.ts diff --git a/src/engine/git-state/repo-selection.ts b/src/engine/git-state/repo-selection.ts new file mode 100644 index 0000000..7d211b4 --- /dev/null +++ b/src/engine/git-state/repo-selection.ts @@ -0,0 +1,74 @@ +/** + * 活跃仓库选取纯逻辑(issue #107 多根工作区仓库切换)。 + * + * 选取优先级: + * 1. 持久化的上次活跃仓库(用户显式选择或 last-active 留痕)仍存在 → 恢复; + * 2. 首个工作区文件夹命中的仓库(folder 为 git root 或位于某 repo 内); + * 3. 首个已发现仓库(与旧版 pickRepository 的回退一致); + * 4. 无仓库 → null。 + * + * 零 vscode 依赖,路径归一化(去尾分隔符 + win32 大小写不敏感)保证跨平台稳定比较。 + */ + +/** 仓库最小投影(adapter 侧由 vscode.git Repository 投影而来)。 */ +export interface RepoLike { + readonly rootPath: string; +} + +/** pickRepositoryRoot 输入(repoForFolder 可注入 vscode.git 的 getRepository 语义)。 */ +export interface PickRepoInput { + readonly repos: readonly R[]; + /** 首个工作区文件夹路径(workspaceFolders[0]),可能不是 git root。 */ + readonly firstWorkspaceFolder?: string; + /** 持久化的上次活跃仓库根路径(workspaceState)。 */ + readonly persistedRoot?: string; + /** + * 文件夹 → 仓库匹配器(注入 `api.getRepository(uri)` 语义:folder 为 git root 或处于 repo 内均命中)。 + * 缺省用最长祖先路径匹配(单测与无 API 场景的等价实现)。 + */ + readonly repoForFolder?: (folderPath: string) => R | undefined; +} + +/** 路径归一化:去尾分隔符;win32 盘符大小写不敏感 → 统一小写比较。 */ +export function normalizeRoot(p: string): string { + const trimmed = p.endsWith('/') || p.endsWith('\\') ? p.slice(0, -1) : p; + return process.platform === 'win32' ? trimmed.toLowerCase() : trimmed; +} + +/** 最长祖先路径匹配:返回 repos 中 rootPath 为 folder 祖先(或相等)的最深仓库。 */ +function matchByAncestor(repos: readonly R[], folder: string): R | undefined { + const nFolder = normalizeRoot(folder); + let best: R | undefined; + let bestLen = -1; + for (const repo of repos) { + const nRoot = normalizeRoot(repo.rootPath); + const isAncestor = nFolder === nRoot || nFolder.startsWith(nRoot + '/') || nFolder.startsWith(nRoot + '\\'); + if (isAncestor && nRoot.length > bestLen) { + best = repo; + bestLen = nRoot.length; + } + } + return best; +} + +/** 依优先级选取活跃仓库(引用稳定:命中即原样返回 repos 中的元素)。 */ +export function pickRepositoryRoot(input: PickRepoInput): R | null { + const { repos, firstWorkspaceFolder, persistedRoot, repoForFolder } = input; + // 1. 持久化恢复:上次活跃仓库仍存在于已发现列表(避免 VS Code 发现顺序不稳定导致的漂移)。 + if (persistedRoot) { + const nPersisted = normalizeRoot(persistedRoot); + const hit = repos.find((r) => normalizeRoot(r.rootPath) === nPersisted); + if (hit) { + return hit; + } + } + // 2. 首个工作区文件夹命中的仓库(注入匹配器优先,缺省最长祖先匹配)。 + if (firstWorkspaceFolder) { + const hit = repoForFolder?.(firstWorkspaceFolder) ?? matchByAncestor(repos, firstWorkspaceFolder); + if (hit) { + return hit; + } + } + // 3. 回退:首个已发现仓库(保持与旧版行为兼容)。 + return repos[0] ?? null; +} diff --git a/src/engine/git-state/shelf-dir.ts b/src/engine/git-state/shelf-dir.ts new file mode 100644 index 0000000..5ce8899 --- /dev/null +++ b/src/engine/git-state/shelf-dir.ts @@ -0,0 +1,18 @@ +import { createHash } from 'crypto'; + +/** + * Shelf 仓库子目录命名(issue #107 按仓库隔离)。 + * + * `basename(rootPath)` 截 48 字符保证可读,拼 `.` + sha1(rootPath) 前 8 位防 + * 同名仓库(如 `/a/api` 与 `/b/api`)碰撞。零 vscode 依赖,可单测。 + */ + +const SAFE = /[^a-zA-Z0-9_-]/g; + +/** 仓库根路径 → shelf 存储子目录名(确定性:同 root 恒同值)。 */ +export function shelfRepoDirName(repoRoot: string): string { + const base = repoRoot.split(/[\\/]/).filter(Boolean).pop() ?? ''; + const safe = base.replace(SAFE, '_').slice(0, 48) || 'unnamed'; + const hash = createHash('sha1').update(repoRoot).digest('hex').slice(0, 8); + return `${safe}.${hash}`; +} diff --git a/tests/unit/repo-selection.test.ts b/tests/unit/repo-selection.test.ts new file mode 100644 index 0000000..2557748 --- /dev/null +++ b/tests/unit/repo-selection.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeRoot, pickRepositoryRoot } from '../../src/engine/git-state/repo-selection'; + +const repo = (rootPath: string) => ({ rootPath }); + +describe('normalizeRoot', () => { + it('去尾分隔符', () => { + expect(normalizeRoot('/a/b/')).toBe('/a/b'); + expect(normalizeRoot('/a/b')).toBe('/a/b'); + }); + + it('空串与单分隔符原样(无尾可去)', () => { + expect(normalizeRoot('')).toBe(''); + expect(normalizeRoot('/')).toBe(''); + }); +}); + +describe('pickRepositoryRoot', () => { + const a = repo('/ws/repo-a'); + const b = repo('/ws/repo-b'); + + it('无仓库时返回 null', () => { + expect(pickRepositoryRoot({ repos: [] })).toBeNull(); + }); + + it('仅 repos 时回退首个(兼容旧行为)', () => { + expect(pickRepositoryRoot({ repos: [a, b] })).toBe(a); + }); + + it('persistedRoot 命中优先恢复', () => { + expect(pickRepositoryRoot({ repos: [a, b], persistedRoot: '/ws/repo-b' })).toBe(b); + }); + + it('persistedRoot 带尾分隔符仍命中(归一化比较)', () => { + expect(pickRepositoryRoot({ repos: [a, b], persistedRoot: '/ws/repo-b/' })).toBe(b); + }); + + it('persistedRoot 指向不存在路径 → 回退 folder0 匹配', () => { + expect( + pickRepositoryRoot({ repos: [a, b], firstWorkspaceFolder: '/ws/repo-b', persistedRoot: '/gone' }), + ).toBe(b); + }); + + it('folder0 是 git root → 命中该仓库', () => { + expect(pickRepositoryRoot({ repos: [a, b], firstWorkspaceFolder: '/ws/repo-a' })).toBe(a); + }); + + it('folder0 处于某 repo 内(非 root)→ 祖先最长匹配', () => { + const outer = repo('/ws/monorepo'); + const inner = repo('/ws/monorepo/packages/x'); + expect(pickRepositoryRoot({ repos: [outer, inner], firstWorkspaceFolder: '/ws/monorepo/packages/x/src' })).toBe( + inner, + ); + }); + + it('folder0 非 git root(注入 getRepository 语义命中)→ 命中', () => { + const hit = pickRepositoryRoot({ + repos: [a, b], + firstWorkspaceFolder: '/ws/arbitrary', + repoForFolder: () => b, + }); + expect(hit).toBe(b); + }); + + it('folder0 完全无 repo → 回退 repos[0]', () => { + expect(pickRepositoryRoot({ repos: [a, b], firstWorkspaceFolder: '/nowhere' })).toBe(a); + }); + + it('persistedRoot 优先于 folder0(用户上次选择压倒默认锚点)', () => { + expect( + pickRepositoryRoot({ repos: [a, b], firstWorkspaceFolder: '/ws/repo-a', persistedRoot: '/ws/repo-b' }), + ).toBe(b); + }); + + it('返回引用稳定(命中即 repos 中的原对象)', () => { + expect(pickRepositoryRoot({ repos: [a, b], persistedRoot: '/ws/repo-a' })).toBe(a); + }); +}); diff --git a/tests/unit/shelf-dir.test.ts b/tests/unit/shelf-dir.test.ts new file mode 100644 index 0000000..43dc284 --- /dev/null +++ b/tests/unit/shelf-dir.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { shelfRepoDirName } from '../../src/engine/git-state/shelf-dir'; + +describe('shelfRepoDirName', () => { + it('确定性:同 root 两次调用相同', () => { + expect(shelfRepoDirName('/ws/repo-a')).toBe(shelfRepoDirName('/ws/repo-a')); + }); + + it('同 basename 不同父目录不碰撞(sha1 区分)', () => { + expect(shelfRepoDirName('/a/api')).not.toBe(shelfRepoDirName('/b/api')); + }); + + it('含 hostile 字符的 basename 被清洗', () => { + const name = shelfRepoDirName('/ws/my repo!@#'); + expect(name).toMatch(/^[a-zA-Z0-9_-]+\.[0-9a-f]{8}$/); + expect(name.startsWith('my_repo___'.slice(0, 8))).toBe(true); + }); + + it('超长 basename 截 48 字符 + 8 位 hash 后缀', () => { + const long = 'x'.repeat(200); + const name = shelfRepoDirName(`/ws/${long}`); + const base = name.split('.')[0]; + expect(base.length).toBe(48); + expect(name).toMatch(/\.[0-9a-f]{8}$/); + }); + + it('空串 root 兜底 unnamed', () => { + expect(shelfRepoDirName('')).toMatch(/^unnamed\.[0-9a-f]{8}$/); + }); + + it('尾分隔符与无分隔符的 root 同名(basename 相同且 hash 输入不同则区分)', () => { + // 尾分隔符会改变 sha1 输入,属预期:调用方传 service.repoRoot(vscode.git 保证无尾分隔符)。 + expect(shelfRepoDirName('/ws/repo-a')).not.toBe(shelfRepoDirName('/ws/repo-a/')); + }); +}); From 3d233f310e7795c825bc5659018434fa1d296478 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 23:12:34 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat(multi-repo):=20GitRepositoryService?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E6=89=8B=E5=8A=A8=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E6=B4=BB=E8=B7=83=E4=BB=93=E5=BA=93=E5=B9=B6=E7=BA=A7=E8=81=94?= =?UTF-8?q?=20rebind(issue=20#107);?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 onDidChangeRepository 事件与 selectRepository/listRepositories API, 选取逻辑下沉 engine(pickRepositoryRoot 三级优先级:持久化恢复 → 首个 folder 匹配 → 首个仓库),活跃仓库持久化于 workspaceState(hyperGit.activeRepoRoot, last-active 语义,重开窗口回到上次操作的仓库); - applyRepository 编码顺序不变量:切换事件(同步 rebind)先于 onDidChange (防抖刷新)fire;顺带修复激活竞态——repo 发现晚于 activate 时由 rebind 纠正; - extension.ts 装配级联:registry/favorites/branchesTree setRepoRoot + branchesGrouping context key 同步(原仅 activate 设置一次); - activate 返回 { service } 供集成测试程序化驱动(vscode.git 同款导出模式); - Blame 注解订阅仓库切换自清理(旧仓库 blame 数据不再残留编辑器)。 🤖 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 --- src/adapter/editor/blame-annotation.ts | 11 ++++ src/adapter/git-repository-service.ts | 86 +++++++++++++++++++++----- src/extension.ts | 26 +++++++- 3 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/adapter/editor/blame-annotation.ts b/src/adapter/editor/blame-annotation.ts index b2639f3..6e679d1 100644 --- a/src/adapter/editor/blame-annotation.ts +++ b/src/adapter/editor/blame-annotation.ts @@ -31,6 +31,8 @@ export class BlameAnnotationController implements vscode.Disposable { } }), ); + // 活跃仓库切换(issue #107)后清除注解:blame 数据属于旧仓库,不清理会残留误导。 + this.disposables.push(service.onDidChangeRepository(() => this.clearAll())); } async toggle(): Promise { @@ -85,6 +87,15 @@ export class BlameAnnotationController implements vscode.Disposable { this.annotated.delete(uri.toString()); } + /** 清除全部注解(仓库切换时,annotated 中的 uri 逐个对可见编辑器复位装饰)。 */ + private clearAll(): void { + for (const key of [...this.annotated]) { + const editor = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === key); + editor?.setDecorations(this.decoration, []); + } + this.annotated.clear(); + } + dispose(): void { this.decoration.dispose(); this.disposables.forEach((d) => d.dispose()); diff --git a/src/adapter/git-repository-service.ts b/src/adapter/git-repository-service.ts index e4a2cfd..b85aaad 100644 --- a/src/adapter/git-repository-service.ts +++ b/src/adapter/git-repository-service.ts @@ -4,8 +4,20 @@ 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 { normalizeRoot, pickRepositoryRoot } from '../engine/git-state/repo-selection'; import { mapGitStatus } from './git-status-map'; +/** workspaceState 持久化键:上次活跃仓库根路径(per-workspace 天然隔离)。 */ +const ACTIVE_REPO_KEY = 'hyperGit.activeRepoRoot'; + +/** 活跃仓库切换事件载荷。 */ +export interface ActiveRepositoryChange { + /** 切换前的仓库根(null = 此前无仓库)。 */ + readonly previousRoot: string | null; + /** 切换后的仓库根(null = 全部仓库已关闭)。 */ + readonly root: string | null; +} + /** 适配层视图模型:一个文件的变更(携带 vscode.Uri 供 diff/操作)。 */ export interface ChangeItem { /** 仓库相对路径(posix 分隔),用作 changelist 分组稳定 key。 */ @@ -20,15 +32,26 @@ export interface ChangeItem { /** * GitRepositoryService:封装 vscode.git 的活跃 Repository。 * 职责:选取活跃仓库、读取变更(→ ChangeItem)、暴露状态变更事件、提供 diff/写操作委托。 + * + * 多根工作区(issue #107):活跃仓库可经 {@link selectRepository} 手动切换并持久化 + * (`hyperGit.activeRepoRoot`,"last active" 语义)。**契约:任何构造期快照 repoRoot 的组件 + * 必须订阅 {@link onDidChangeRepository} 重绑**——该事件先于 onDidChange fire(同步完成 + * rebind,之后防抖刷新才重取数据)。 */ export class GitRepositoryService implements vscode.Disposable { private _repo: Repository | null = null; private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChange: vscode.Event = this._onDidChange.event; + private readonly _onDidChangeRepository = new vscode.EventEmitter(); + /** 活跃仓库切换(打开/关闭/手动选择/持久化恢复)。rebind 订阅必须先于 onDidChange 消费注册。 */ + readonly onDidChangeRepository: vscode.Event = this._onDidChangeRepository.event; private readonly disposables: vscode.Disposable[] = []; private repoSub?: vscode.Disposable; - constructor(private readonly api: API) { + constructor( + private readonly api: API, + private readonly workspaceState: vscode.Memento, + ) { this.disposables.push(api.onDidOpenRepository(() => this.pickRepository())); this.disposables.push(api.onDidCloseRepository(() => this.pickRepository())); this.pickRepository(); @@ -42,25 +65,58 @@ export class GitRepositoryService implements vscode.Disposable { return this._repo?.rootUri.fsPath ?? null; } - /** 选取活跃仓库:优先匹配工作区根(用 API.getRepository,路径段精确匹配),否则首个。 */ + /** + * 选取活跃仓库(引擎三级优先级:持久化恢复 → 首个 folder 匹配 → 首个仓库), + * 任何来源的成功选取都写回持久化("last active" 留痕,重开窗口回到上次操作的仓库)。 + * repoForFolder 注入:folder 为 git root 或 repo 内路径均命中(getRepository 同语义的 + * rootPath 等价实现,直接持有 api 处无需额外间接层)。 + */ private pickRepository(): void { - let repo: Repository | null = null; const folder = vscode.workspace.workspaceFolders?.[0]; - if (folder) { - repo = this.api.getRepository(folder.uri) ?? null; + const picked = pickRepositoryRoot({ + repos: this.api.repositories.map((r) => ({ rootPath: r.rootUri.fsPath, repo: r })), + firstWorkspaceFolder: folder?.uri.fsPath, + persistedRoot: this.workspaceState.get(ACTIVE_REPO_KEY), + }); + this.applyRepository(picked?.repo ?? null); + if (picked) { + void this.workspaceState.update(ACTIVE_REPO_KEY, picked.rootPath); } - if (!repo) { - repo = this.api.repositories[0] ?? null; + } + + /** 应用活跃仓库:重订状态订阅并按序 fire(先切换事件同步 rebind,后变更事件驱动防抖刷新)。 */ + private applyRepository(repo: Repository | null): void { + if (repo === this._repo) { + return; } - if (repo !== this._repo) { - this.repoSub?.dispose(); - this.repoSub = undefined; - this._repo = repo; - if (repo) { - this.repoSub = repo.state.onDidChange(() => this._onDidChange.fire()); - } - this._onDidChange.fire(); + const previousRoot = this.repoRoot; + this.repoSub?.dispose(); + this.repoSub = undefined; + this._repo = repo; + if (repo) { + this.repoSub = repo.state.onDidChange(() => this._onDidChange.fire()); + } + this._onDidChangeRepository.fire({ previousRoot, root: this.repoRoot }); + this._onDidChange.fire(); + } + + /** 手动切换活跃仓库(仓库选择器 / 命令接缝)。未命中返回 false;命中当前仓库返回 true(幂等)。 */ + selectRepository(root: string): boolean { + const hit = this.api.repositories.find((r) => normalizeRoot(r.rootUri.fsPath) === normalizeRoot(root)); + if (!hit) { + return false; + } + if (hit === this._repo) { + return true; } + this.applyRepository(hit); + void this.workspaceState.update(ACTIVE_REPO_KEY, hit.rootUri.fsPath); + return true; + } + + /** 已发现仓库投影(QuickPick / webview 消费,不暴露内部 Repository)。 */ + listRepositories(): readonly { rootPath: string }[] { + return this.api.repositories.map((r) => ({ rootPath: r.rootUri.fsPath })); } /** 读取本地变更(已暂存 + 工作区 + 未跟踪,按相对路径去重,index 优先),映射为 ChangeItem。 */ diff --git a/src/extension.ts b/src/extension.ts index 9f25bb8..9831792 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -46,8 +46,11 @@ class EmptyTreeProvider implements vscode.TreeDataProvider { /** * 扩展入口。仅做装配(DI 注册),业务逻辑下沉到 engine/adapter 层。 + * 返回 `{ service }` 供集成测试程序化驱动仓库切换(vscode.git 同款导出模式)。 */ -export async function activate(context: vscode.ExtensionContext): Promise { +export async function activate( + context: vscode.ExtensionContext, +): Promise<{ service: GitRepositoryService } | void> { const logger = createLogger(); logger.info('Hyper Git activated'); @@ -69,7 +72,7 @@ export async function activate(context: vscode.ExtensionContext): Promise } const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? 'default'; - const service = new GitRepositoryService(api); + const service = new GitRepositoryService(api, context.workspaceState); const registry = new ChangelistRegistry(context.workspaceState, service.repoRoot ?? workspaceRoot); const favorites = new BranchFavorites(context.workspaceState, service.repoRoot ?? workspaceRoot); const githubAuth = new GitHubAuth(context.subscriptions); @@ -126,6 +129,23 @@ export async function activate(context: vscode.ExtensionContext): Promise setBranchesGroupingContext(v); }; + // 多根仓库切换级联(issue #107):构造期快照 repoRoot 的持久化组件在此 rebind(换 memento key、 + // 重载状态、fire 刷新),并同步 branchesGrouping context key(否则仅 activate 设一次,切库后按钮显隐错)。 + // 顺序不变量:本订阅先于下方 service.onDidChange(refreshAll) 注册,且切换事件在 applyRepository 内 + // 先于 onDidChange fire —— rebind 同步完成后,防抖刷新才重取数据。root 为 null(全部仓库关闭)时 + // 跳过:无 repo 的一切写路径均短路,旧 key 残留无害。 + const rebindRepoScopedState = (root: string): void => { + registry.setRepoRoot(root); + favorites.setRepoRoot(root); + branchesTree.setRepoRoot(root); + setBranchesGroupingContext(branchesTree.grouping); + }; + context.subscriptions.push(service.onDidChangeRepository((e) => { + if (e.root) { + rebindRepoScopedState(e.root); + } + })); + context.subscriptions.push( service, registry, @@ -222,6 +242,8 @@ export async function activate(context: vscode.ExtensionContext): Promise worktreeTree.refresh(); updateBadge(); }, 500); + + return { service }; } export function deactivate(): void { From b756e0ea1e31f9f95030516af0cf63aba1d217f9 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 23:14:05 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat(multi-repo):=20changelist/=E6=94=B6?= =?UTF-8?q?=E8=97=8F/=E5=88=86=E6=94=AF=E5=88=86=E7=BB=84=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E6=94=AF=E6=8C=81=20setRepoRoot=20=E9=87=8D?= =?UTF-8?q?=E7=BB=91(issue=20#107);?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChangelistRegistry/BranchFavorites/BranchesTreeProvider 三处构造期冻结的 memento key(hyperGit.changelists/branchFavorites/branchesGrouping:${repoRoot}) 改为可重绑:同构「幂等守卫 → 换 key → 重载 → fire/refresh」模式; - 单测:EventEmitter mock 升级为功能性实现(fire 通知监听);新增跨仓库隔离、 同 root 幂等、切换后 persist 写入新 key 等用例(16 个)。 🤖 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 --- src/adapter/branch-favorites.ts | 13 +++- src/adapter/changelist-registry.ts | 19 +++++- src/adapter/tree/branches-tree.ts | 16 ++++- tests/unit/branch-favorites.test.ts | 91 ++++++++++++++++++++++++++ tests/unit/changelist-registry.test.ts | 63 +++++++++++++++++- 5 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 tests/unit/branch-favorites.test.ts diff --git a/src/adapter/branch-favorites.ts b/src/adapter/branch-favorites.ts index a5c10c9..698bcb5 100644 --- a/src/adapter/branch-favorites.ts +++ b/src/adapter/branch-favorites.ts @@ -9,7 +9,7 @@ import { toggleFavorite } from '../engine/ref/favorites'; */ export class BranchFavorites implements vscode.Disposable { private names: string[]; - private readonly storageKey: string; + private storageKey: string; private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChange: vscode.Event = this._onDidChange.event; @@ -18,6 +18,17 @@ export class BranchFavorites implements vscode.Disposable { this.names = this.load(); } + /** 多根仓库切换 rebind(issue #107):换 storageKey 重载该仓库的收藏,幂等,切换后 fire 刷新。 */ + setRepoRoot(repoRoot: string): void { + const key = `hyperGit.branchFavorites:${repoRoot}`; + if (key === this.storageKey) { + return; + } + this.storageKey = key; + this.names = this.load(); + this._onDidChange.fire(); + } + private load(): string[] { const raw = this.workspaceState.get(this.storageKey); if (!raw) { diff --git a/src/adapter/changelist-registry.ts b/src/adapter/changelist-registry.ts index 56983b4..d3d6b2e 100644 --- a/src/adapter/changelist-registry.ts +++ b/src/adapter/changelist-registry.ts @@ -20,7 +20,7 @@ export class ChangelistRegistry implements vscode.Disposable { private defs: ChangelistDef[]; private activeId: string; private assignments: ChangelistAssignment; - private readonly storageKey: string; + private storageKey: string; private readonly _onDidChange = new vscode.EventEmitter(); readonly onDidChange: vscode.Event = this._onDidChange.event; @@ -32,6 +32,23 @@ export class ChangelistRegistry implements vscode.Disposable { this.assignments = loaded.assignments; } + /** + * 多根仓库切换 rebind(issue #107):换 storageKey 并重载该仓库的持久化状态。 + * 幂等(同 root 不动);切换后 fire onDidChange 并入 refreshAll 防抖刷新。 + */ + setRepoRoot(repoRoot: string): void { + const key = `hyperGit.changelists:${repoRoot}`; + if (key === this.storageKey) { + return; + } + this.storageKey = key; + const loaded = this.load(); + this.defs = loaded.defs; + this.activeId = loaded.activeId; + this.assignments = loaded.assignments; + this._onDidChange.fire(); + } + private load(): PersistedState { const fallback: PersistedState = { defs: [{ id: DEFAULT_ID, name: DEFAULT_NAME }], activeId: DEFAULT_ID, assignments: {} }; const raw = this.workspaceState.get(this.storageKey); diff --git a/src/adapter/tree/branches-tree.ts b/src/adapter/tree/branches-tree.ts index a32bf60..90b8d79 100644 --- a/src/adapter/tree/branches-tree.ts +++ b/src/adapter/tree/branches-tree.ts @@ -61,7 +61,7 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider private readonly disposables: vscode.Disposable[] = []; private refsCache: RawRef[] | undefined; private refsInFlight: Promise | undefined; - private readonly groupingKey: string; + private groupingKey: string; /** 「按前缀分组为文件夹树」开关(工具栏切换、按仓库持久化,默认开启)。 */ private groupingEnabled: boolean; @@ -77,6 +77,20 @@ export class BranchesTreeProvider implements vscode.TreeDataProvider this.disposables.push(favorites.onDidChange(() => this.refresh())); } + /** + * 多根仓库切换 rebind(issue #107):换 groupingKey 并重读该仓库的分组偏好,refresh 清缓存重取。 + * 配套的 `hyperGit.branchesGrouping` context key 由 extension.ts 级联处同步(读取重绑后的 grouping)。 + */ + setRepoRoot(repoRoot: string): void { + const key = `hyperGit.branchesGrouping:${repoRoot}`; + if (key === this.groupingKey) { + return; + } + this.groupingKey = key; + this.groupingEnabled = this.workspaceState.get(this.groupingKey) ?? true; + this.refresh(); + } + refresh(): void { this.refsCache = undefined; this._onDidChange.fire(undefined); diff --git a/tests/unit/branch-favorites.test.ts b/tests/unit/branch-favorites.test.ts new file mode 100644 index 0000000..ae9a38d --- /dev/null +++ b/tests/unit/branch-favorites.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('vscode', () => ({ + // 功能性 EventEmitter mock:event 注册监听、fire 通知(供 rebind fire 断言)。 + EventEmitter: class { + private listeners: Array<() => void> = []; + get event() { + return (l: () => void) => { + this.listeners.push(l); + return { dispose: () => { this.listeners = this.listeners.filter((x) => x !== l); } }; + }; + } + fire(): void { + for (const l of [...this.listeners]) { + l(); + } + } + dispose(): void { + this.listeners = []; + } + }, +})); + +import type { Memento } from 'vscode'; +import { BranchFavorites } from '../../src/adapter/branch-favorites'; + +/** 内存 Memento(模拟 workspaceState 持久化往返)。 */ +class MemMemento { + private readonly map = new Map(); + get(key: string): T | undefined { + return this.map.get(key) as T | undefined; + } + update(key: string, value: unknown): Thenable { + this.map.set(key, value); + return Promise.resolve(); + } +} + +function makeFavorites(repoRoot = '/repo'): { fav: BranchFavorites; memento: MemMemento } { + const memento = new MemMemento(); + const fav = new BranchFavorites(memento as unknown as Memento, repoRoot); + return { fav, memento }; +} + +describe('BranchFavorites', () => { + it('初始为空', () => { + const { fav } = makeFavorites(); + expect(fav.list()).toEqual([]); + expect(fav.isFavorite('main')).toBe(false); + }); + + it('toggle 收藏并持久化(重建恢复)', () => { + const { fav, memento } = makeFavorites(); + fav.toggle('main'); + expect(fav.isFavorite('main')).toBe(true); + const fav2 = new BranchFavorites(memento as unknown as Memento, '/repo'); + expect(fav2.isFavorite('main')).toBe(true); + }); + + it('toggle 再点取消', () => { + const { fav } = makeFavorites(); + fav.toggle('main'); + fav.toggle('main'); + expect(fav.isFavorite('main')).toBe(false); + }); + + it('setRepoRoot 切换后读到另一仓库的收藏(多根隔离,issue #107)', () => { + const memento = new MemMemento(); + memento.update('hyperGit.branchFavorites:/repo-b', JSON.stringify(['dev', 'release'])); + const fav = new BranchFavorites(memento as unknown as Memento, '/repo-a'); + fav.toggle('main'); // repo-a 的收藏 + fav.setRepoRoot('/repo-b'); + expect(fav.list()).toEqual(['dev', 'release']); + expect(fav.isFavorite('main')).toBe(false); + }); + + it('setRepoRoot 同 root 幂等(不 fire)', () => { + const { fav } = makeFavorites(); + let fired = 0; + fav.onDidChange(() => fired++); + fav.setRepoRoot('/repo'); + expect(fired).toBe(0); + }); + + it('setRepoRoot 切换后 persist 写入新 key', () => { + const { fav, memento } = makeFavorites('/repo-a'); + fav.setRepoRoot('/repo-b'); + fav.toggle('dev'); + expect(memento.get('hyperGit.branchFavorites:/repo-b')).toBe(JSON.stringify(['dev'])); + }); +}); diff --git a/tests/unit/changelist-registry.test.ts b/tests/unit/changelist-registry.test.ts index 5b0d7e1..f7d5beb 100644 --- a/tests/unit/changelist-registry.test.ts +++ b/tests/unit/changelist-registry.test.ts @@ -1,12 +1,23 @@ import { describe, it, expect, vi } from 'vitest'; vi.mock('vscode', () => ({ + // 功能性 EventEmitter mock:event 注册监听、fire 通知(供 rebind fire 断言)。 EventEmitter: class { + private listeners: Array<() => void> = []; get event() { - return () => ({ dispose: () => undefined }); + return (l: () => void) => { + this.listeners.push(l); + return { dispose: () => { this.listeners = this.listeners.filter((x) => x !== l); } }; + }; + } + fire(): void { + for (const l of [...this.listeners]) { + l(); + } + } + dispose(): void { + this.listeners = []; } - fire() {} - dispose() {} }, })); @@ -90,4 +101,50 @@ describe('ChangelistRegistry', () => { expect(reg.listDefs().map((d) => d.id)).toEqual(['default']); expect(reg.activeChangelistId).toBe('default'); }); + + it('setRepoRoot 切换后读到另一仓库的数据(多根隔离,issue #107)', () => { + const { reg } = makeRegistry('/repo-a'); + const id = reg.create('Feature A'); + reg.move('a.ts', id); + + const mementoB = new MemMemento(); + mementoB.update( + 'hyperGit.changelists:/repo-b', + JSON.stringify({ + defs: [ + { id: 'default', name: 'Default' }, + { id: 'cl_b', name: 'Feature B' }, + ], + activeId: 'cl_b', + assignments: { 'b.ts': 'cl_b' }, + }), + ); + // 同一实例切到 repo-b:换 key 重载 + (reg as unknown as { workspaceState: Memento }).workspaceState = mementoB as unknown as Memento; + reg.setRepoRoot('/repo-b'); + expect(reg.listDefs().map((d) => d.name)).toEqual(['Default', 'Feature B']); + expect(reg.activeChangelistId).toBe('cl_b'); + }); + + it('setRepoRoot 同 root 幂等(不 fire 不动状态)', () => { + const { reg } = makeRegistry(); + let fired = 0; + reg.onDidChange(() => fired++); + reg.setRepoRoot('/repo'); + expect(fired).toBe(0); + }); + + it('setRepoRoot 切换后 fire onDidChange 并 persist 写入新 key', () => { + const memento = new MemMemento(); + const reg = new ChangelistRegistry(memento as unknown as Memento, '/repo-a'); + let fired = 0; + reg.onDidChange(() => fired++); + reg.setRepoRoot('/repo-b'); + expect(fired).toBe(1); + const id = reg.create('in B'); + const raw = memento.get('hyperGit.changelists:/repo-b'); + expect(raw).toBeDefined(); + expect(JSON.parse(raw as string).defs.some((d: { name: string }) => d.name === 'in B')).toBe(true); + expect(id).toBeTruthy(); + }); }); From 8b8478e29b5deda4d7acb60fb6ed85d825aab5cb Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 23:15:23 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat(shelf):=20shelf=20=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E6=8C=89=E4=BB=93=E5=BA=93=E9=9A=94=E7=A6=BB=E5=B9=B6=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E8=BF=81=E7=A7=BB=E6=97=A7=E5=B9=B3=E9=93=BA=E6=95=B0?= =?UTF-8?q?=E6=8D=AE(issue=20#107);?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 目录改为 shelves/./,随 service.repoRoot 动态求值 (零快照零双源),切换仓库即切换 shelf 集,杜绝 A 库 shelf 误 apply 到 B 库; - migrateLegacyShelves 一次性迁移:仅 rename(零删除)、目标已存在即跳过 (零覆盖)、失败静默可重试(已移入保留、未移入下次续迁); - 多仓库历史混仓数据归属当前活跃仓库(无法事后归因,已列 Known Limitation)。 🤖 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 --- src/adapter/shelf.ts | 79 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/src/adapter/shelf.ts b/src/adapter/shelf.ts index 77302b5..9af6046 100644 --- a/src/adapter/shelf.ts +++ b/src/adapter/shelf.ts @@ -5,6 +5,8 @@ import * as vscode from 'vscode'; import type { GitRepositoryService } from './git-repository-service'; import { handleGitConflict } from './conflict-ui'; import { mdTooltip, relativeDate } from './tree/tree-tooltip'; +import { shelfRepoDirName } from '../engine/git-state/shelf-dir'; +import { logGit } from '../infra/git-console'; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -42,13 +44,54 @@ export type ShelfTreeNode = ShelfNode | ShelfFileNode; * - Unshelve:读取 patch → `git apply`(静默)或 `git apply --3way`(三方合并冲突解决)。 * - Drop:删除 patch 文件。 * + * 多根仓库(issue #107):存储按仓库隔离——`shelves/./`, + * 目录随 `service.repoRoot` 动态求值(零快照零双源),切换仓库即切换 shelf 集。 + * * 与 git stash 的区别:Shelf 是命名 patch 序列(扩展存储),不占用 git stash 栈;可同时保留多个独立 shelf。 */ export class ShelfService { - private readonly shelvesDir: string; + private readonly shelvesBase: string; constructor(private readonly service: GitRepositoryService, storageDir: string) { - this.shelvesDir = path.join(storageDir, 'shelves'); + this.shelvesBase = path.join(storageDir, 'shelves'); + } + + /** 当前活跃仓库的 shelf 目录;无 repo 时 null(调用方各自短路)。 */ + private currentDir(): string | null { + const root = this.service.repoRoot; + return root ? path.join(this.shelvesBase, shelfRepoDirName(root)) : null; + } + + /** + * 旧版平铺目录(shelves/*.json 不分仓库)一次性安全迁移到当前仓库子目录: + * 仅 rename(零删除)、目标已存在即跳过(零覆盖)、失败静默可重试(已移入保留,未移入下次续迁)。 + * 多仓库历史混仓数据无法事后归因,归属当前活跃仓库(Known Limitation)。 + */ + private migrateLegacyShelves(targetDir: string): void { + try { + if (fs.existsSync(targetDir)) { + return; // 本仓库已迁移/已使用(幂等出口) + } + if (!fs.existsSync(this.shelvesBase)) { + return; + } + const legacyFiles = fs.readdirSync(this.shelvesBase).filter((f) => f.endsWith('.json')); + if (legacyFiles.length === 0) { + return; + } + fs.mkdirSync(targetDir, { recursive: true }); + for (const f of legacyFiles) { + const dest = path.join(targetDir, f); + if (fs.existsSync(dest)) { + continue; // 绝不覆盖 + } + fs.renameSync(path.join(this.shelvesBase, f), dest); + } + logGit(['shelf:migrateLegacy'], targetDir); + } catch (e) { + // 权限/占用等:已移入的保留(合法数据),未移入的留在原处下次重试,零数据损失。 + logGit(['shelf:migrateLegacy:failed'], undefined, errMsg(e)); + } } async shelve(name: string, paths: readonly string[], timestamp: string): Promise { @@ -56,13 +99,18 @@ export class ShelfService { if (!repo) { throw new Error('No Git repository found'); } + const dir = this.currentDir(); + if (!dir) { + throw new Error('No Git repository found'); + } const patch = await this.service.execGit(['diff', '--', ...paths]); if (!patch.trim()) { throw new Error('Selected files have no changes (or are untracked)'); } - fs.mkdirSync(this.shelvesDir, { recursive: true }); + this.migrateLegacyShelves(dir); + fs.mkdirSync(dir, { recursive: true }); const entry: ShelfEntry = { name, paths, timestamp, patch }; - fs.writeFileSync(path.join(this.shelvesDir, `${sanitize(name)}.json`), JSON.stringify(entry, null, 2)); + fs.writeFileSync(path.join(dir, `${sanitize(name)}.json`), JSON.stringify(entry, null, 2)); // 移除工作区改动(变更已保存在 patch) await this.service.execGit(['checkout', '--', ...paths]); } @@ -96,22 +144,31 @@ export class ShelfService { } drop(name: string): void { - const file = path.join(this.shelvesDir, `${sanitize(name)}.json`); + const dir = this.currentDir(); + if (!dir) { + return; + } + const file = path.join(dir, `${sanitize(name)}.json`); if (fs.existsSync(file)) { fs.unlinkSync(file); } } listShelves(): ShelfNode[] { - if (!fs.existsSync(this.shelvesDir)) { + const dir = this.currentDir(); + if (!dir) { + return []; + } + this.migrateLegacyShelves(dir); + if (!fs.existsSync(dir)) { return []; } return fs - .readdirSync(this.shelvesDir) + .readdirSync(dir) .filter((f) => f.endsWith('.json')) .map((f) => { try { - const entry = JSON.parse(fs.readFileSync(path.join(this.shelvesDir, f), 'utf8')) as ShelfEntry; + const entry = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) as ShelfEntry; return { kind: 'shelf' as const, name: entry.name, paths: entry.paths, timestamp: entry.timestamp }; } catch { return null; @@ -121,7 +178,11 @@ export class ShelfService { } private readEntry(name: string): ShelfEntry | null { - const file = path.join(this.shelvesDir, `${sanitize(name)}.json`); + const dir = this.currentDir(); + if (!dir) { + return null; + } + const file = path.join(dir, `${sanitize(name)}.json`); if (!fs.existsSync(file)) { return null; } From 81846eb308fe91403427d45131d9dbaf80190038 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 23:17:40 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat(log):=20Graph=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A0=8F=E5=A2=9E=E5=8A=A0=E5=A4=9A=E6=A0=B9=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E9=80=89=E6=8B=A9=E5=99=A8(issue=20#107);?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 协议:LogWebviewToHostMessage 新增 log/selectRepo(webview→host), LogGraphState 新增可选 multiRepo(向后兼容); - 工具栏仓库名升级为可点击按钮(Git Graph 形态):多仓库态显示 basename+▾、 hover 高亮、点击弹原生 QuickPick;单仓库退化纯文本观感(多数用户零感知); - 新增 hyperGit.selectRepository 命令(palette 入口):带参=程序化切换(测试 接缝),无参=QuickPick(当前项预选);单仓库提示 no-op,零仓库静默; - host 级 LogFilter 在仓库切换时清空(过滤条件是旧仓库语境产物,跨库无意义)。 🤖 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 | 6 ++++ src/adapter/repository-selection.ts | 43 +++++++++++++++++++++++++++++ src/adapter/webview/log-webview.ts | 38 ++++++++++++++++++++++--- src/extension.ts | 2 ++ src/shared/protocol.ts | 6 +++- 5 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 src/adapter/repository-selection.ts diff --git a/package.json b/package.json index d2eded3..09a8545 100644 --- a/package.json +++ b/package.json @@ -182,6 +182,12 @@ "category": "Hyper Git", "icon": "$(refresh)" }, + { + "command": "hyperGit.selectRepository", + "title": "Select Repository…", + "category": "Hyper Git", + "icon": "$(repo)" + }, { "command": "hyperGit.ci.signIn", "title": "Sign In to GitHub for CI Status", diff --git a/src/adapter/repository-selection.ts b/src/adapter/repository-selection.ts new file mode 100644 index 0000000..72151a7 --- /dev/null +++ b/src/adapter/repository-selection.ts @@ -0,0 +1,43 @@ +import * as vscode from 'vscode'; +import type { GitRepositoryService } from './git-repository-service'; + +/** + * 仓库选择命令(issue #107 多根工作区):Graph 工具栏按钮与 Command Palette 共用入口。 + * + * - 带参调用(root 路径)= 程序化切换(集成测试接缝,无 UI); + * - 无参 = QuickPick 交互(Git Graph 形态:仓库名 + 完整路径,当前项预选); + * - 单仓库时提示 no-op;零仓库静默返回(对齐全扩展「无 repo 短路」惯例)。 + */ +export function registerRepositorySelectionCommand(service: GitRepositoryService): vscode.Disposable { + return vscode.commands.registerCommand('hyperGit.selectRepository', async (root?: string) => { + const repos = service.listRepositories(); + if (repos.length === 0) { + return; + } + if (root !== undefined) { + // 程序化切换:失败给出可见反馈(仓库可能已被移除)。 + if (!service.selectRepository(root)) { + void vscode.window.showWarningMessage('Repository is no longer available'); + } + return; + } + if (repos.length === 1) { + void vscode.window.showInformationMessage('Only one Git repository in this workspace'); + return; + } + const current = service.repoRoot; + const items = repos.map((r) => ({ + label: `$(repo) ${r.rootPath.split(/[\\/]/).filter(Boolean).pop() ?? r.rootPath}`, + description: r.rootPath, + rootPath: r.rootPath, + picked: r.rootPath === current, + })); + const pick = await vscode.window.showQuickPick(items, { placeHolder: 'Select Git Repository' }); + if (!pick) { + return; + } + if (!service.selectRepository(pick.rootPath)) { + void vscode.window.showWarningMessage('Repository is no longer available'); + } + }); +} diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index 9b671e9..5f84297 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -108,6 +108,9 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter t = setTimeout(() => this.refresh(), 400); }), ); + // 活跃仓库切换(issue #107):host 级过滤条件是旧仓库语境的产物,跨仓库无意义 → 清空; + // 图数据重拉由上方 onDidChange 订阅驱动,无需额外处理。 + this.disposables.push(service.onDidChangeRepository(() => { this.filter = {}; })); } setFilter(filter: LogFilter): void { @@ -193,6 +196,10 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter case 'log/showCommitDetail': void this.showCommitDetail(msg.payload.hash); break; + case 'log/selectRepo': + // 复用 postMessage → 原生交互 → executeCommand 通路(同 handleCommitMenu 形态)。 + void vscode.commands.executeCommand('hyperGit.selectRepository'); + break; } } @@ -263,6 +270,7 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter hasMore: page.hasMore, scope: this.scope, repoRoot: this.service.repoRoot ?? '', + multiRepo: this.service.listRepositories().length > 1, }; this.post({ type: 'log/graphData', payload: state }); // CI 元信息异步随附(不阻塞建图):远程为 GitHub 才启用,未授权则提示登录。 @@ -470,6 +478,10 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod .seg button:hover { background: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground)); opacity: 0.9; } .seg button.active { background: var(--vscode-inputOption-activeBackground, var(--vscode-button-background)); color: var(--vscode-inputOption-activeForeground, var(--vscode-button-foreground)); opacity: 1; } .repo { margin-left: auto; font-size: 10px; opacity: 0.55; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* 多仓库态:仓库名升级为可点击切换按钮(Git Graph 形态);单仓库保持纯文本观感。 */ +button.repo { background: transparent; color: var(--vscode-foreground); border: none; padding: 1px 6px; border-radius: 3px; cursor: pointer; } +button.repo.switchable:hover { opacity: 1; background: var(--vscode-list-hoverBackground); } +button.repo:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 1px; } #viewport { flex: 1; overflow-y: auto; overflow-x: hidden; position: relative; outline: none; } #spacer { position: relative; } #rows { position: absolute; left: 0; right: 0; } @@ -589,7 +601,7 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod - +
@@ -629,7 +641,7 @@ let scope = normalizeScope(persisted.scope); let detailsMode = persisted.dmode === 'tree' ? 'tree' : 'flat'; const dcollapsed = new Set(persisted.dcollapsed || []); function persist() { vscode.setState({ selectedHash: selectedHash, scope: scope, dmode: detailsMode, dcollapsed: Array.from(dcollapsed) }); } -let model = { rows: [], maxLanes: 0, hasMore: false, repoRoot: '' }; +let model = { rows: [], maxLanes: 0, hasMore: false, repoRoot: '', multiRepo: false }; let renderedFirst = -1, renderedLast = -1, fetching = false; // ── CI 状态(懒加载、仅取可见行;ciByHash 稳定缓存、ciRequested 去重、ciPending 防抖批量)── // ciByHash 跨 graphData 刷新保留(CI 状态以不可变 hash 为键),杜绝每次 git 状态变化引发的重拉闪烁。 @@ -664,6 +676,8 @@ const errorMsgEl = document.getElementById('error-msg'); const retryBtnEl = document.getElementById('retry-btn'); function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +// 仓库根路径 → basename(多仓库态按钮文案;尾分隔符安全)。 +function repoBasename(p) { const parts = String(p).split(/[\\\\/]/).filter(Boolean); return parts.pop() || String(p); } // 引用胶囊图标(内联 SVG,仿 codicon git-branch / cloud / tag;fill=currentColor 继承 chip 前景色)。 // 项目未引入 codicon 字体(localResourceRoots=[]、CSP 无 font-src),故图标一律内联,与 ciGlyph 一致。 const ICO_BRANCH = ''; @@ -1057,6 +1071,11 @@ function setScope(next) { if (scope !== next) { scope = next; persist(); vscode. document.getElementById('scope-all').addEventListener('click', function () { setScope('all'); }); document.getElementById('scope-current').addEventListener('click', function () { setScope('current'); }); document.getElementById('scope-checkpointer').addEventListener('click', function () { setScope('checkpointer'); }); +// 仓库名按钮(多仓库态):点击弹出原生仓库选择(host 复用 hyperGit.selectRepository)。 +repoEl.addEventListener('click', function () { + if (!model.multiRepo) { return; } + vscode.postMessage({ type: 'log/selectRepo' }); +}); detailsCloseEl.addEventListener('click', function () { detailsEl.classList.remove('show'); }); retryBtnEl.addEventListener('click', function () { errorEl.style.display = 'none'; spinnerEl.style.display = 'block'; vscode.postMessage({ type: 'log/retry' }); }); viewport.addEventListener('scroll', scheduleRender, { passive: true }); @@ -1243,8 +1262,19 @@ commitTipEl.addEventListener('keydown', function (e) { window.addEventListener('message', function (e) { const m = e.data; if (m.type === 'log/graphData') { - model = { rows: m.payload.rows, maxLanes: m.payload.maxLanes, hasMore: m.payload.hasMore, repoRoot: m.payload.repoRoot }; - scope = m.payload.scope; repoEl.textContent = m.payload.repoRoot; repoEl.title = m.payload.repoRoot; + model = { rows: m.payload.rows, maxLanes: m.payload.maxLanes, hasMore: m.payload.hasMore, repoRoot: m.payload.repoRoot, multiRepo: !!m.payload.multiRepo }; + // 仓库名按钮:多仓库态显示 basename + ▾(Git Graph 形态),单仓库保持完整路径纯文本观感。 + scope = m.payload.scope; + if (model.multiRepo) { + repoEl.textContent = repoBasename(m.payload.repoRoot) + ' ▾'; + repoEl.title = m.payload.repoRoot + ' — Switch repository'; + repoEl.setAttribute('aria-label', 'Switch repository: ' + m.payload.repoRoot); + repoEl.classList.add('switchable'); + } else { + repoEl.textContent = m.payload.repoRoot; repoEl.title = m.payload.repoRoot; + repoEl.removeAttribute('aria-label'); + repoEl.classList.remove('switchable'); + } // 保留 ciByHash 稳定缓存(CI 状态以不可变 hash 为键):图重置只清请求去重集合, // 已缓存的提交重绘时立即可见图标,避免「清缓存→重拉→整行重建」的闪烁。 ciRequested.clear(); ciPending.clear(); diff --git a/src/extension.ts b/src/extension.ts index 9831792..dd9629c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,6 +28,7 @@ import { RebaseWebview } from './adapter/webview/rebase-webview'; import { registerMergeCommands } from './adapter/webview/merge-editor'; import { registerMiscCommands } from './adapter/misc-commands'; import { registerClaudeCommands } from './adapter/claude-commands'; +import { registerRepositorySelectionCommand } from './adapter/repository-selection'; import { getGitApi } from './adapter/git-api'; import { GitRepositoryService } from './adapter/git-repository-service'; import { GitHubAuth } from './adapter/ci/github-auth'; @@ -174,6 +175,7 @@ export async function activate( ...registerMergeCommands(service), ...registerMiscCommands(service, branchesTree, logTree), ...registerClaudeCommands(), + registerRepositorySelectionCommand(service), vscode.commands.registerCommand('hyperGit.toggleBlameAnnotation', () => blame.toggle()), vscode.commands.registerCommand('hyperGit.branchesGroupByPrefix', () => applyBranchesGrouping(true)), vscode.commands.registerCommand('hyperGit.branchesFlatten', () => applyBranchesGrouping(false)), diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 6f35e84..39b864c 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -176,6 +176,8 @@ export interface LogGraphState { readonly hasMore: boolean; readonly scope: LogScope; readonly repoRoot: string; + /** 工作区含多个 git 仓库时为 true:仓库名按钮呈现可切换态(缺省视为 false,向后兼容)。 */ + readonly multiRepo?: boolean; } /** per-commit 操作枚举(webview 右键菜单 → host 重调用既有命令)。 */ @@ -236,7 +238,9 @@ export type LogWebviewToHostMessage = | { readonly type: 'log/ciSignIn' } | { readonly type: 'log/openExternal'; readonly payload: { readonly url: string } } /** 悬停提交行 → 请求在右侧编辑器区打开该提交的详情面板。 */ - | { readonly type: 'log/showCommitDetail'; readonly payload: { readonly hash: string } }; + | { readonly type: 'log/showCommitDetail'; readonly payload: { readonly hash: string } } + /** 工具栏仓库名按钮 → 打开原生仓库选择(host 复用 hyperGit.selectRepository 命令,issue #107)。 */ + | { readonly type: 'log/selectRepo' }; /** Host → Commit 详情面板(编辑器区 WebviewPanel)。 */ export type CommitDetailHostToWebviewMessage = From 3d7c9f9b21ed6c4ee5870946594dc54e7113fcf0 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 23:21:31 +0800 Subject: [PATCH 06/10] =?UTF-8?q?feat(multi-repo):=20webview=20=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E7=8A=B6=E6=80=81=E4=B8=8E=E6=9C=80=E8=BF=91=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E6=B6=88=E6=81=AF=E6=8C=89=E4=BB=93=E5=BA=93=E5=88=86?= =?UTF-8?q?=E5=8C=BA=E8=AE=B0=E5=BF=86(issue=20#107);?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Graph webview:vscode.getState() 升级 v2 byRepo 结构(scope/选中/dmode/ dcollapsed 跟随仓库换装载),旧平铺结构一次性升级不丢偏好;host 侧 scope 内存级按仓库记忆,切回仓库恢复其视图范围; - Commit webview:勾选集/视图模式同模式按仓库分区(勾选是相对路径集合, 跨仓库本就错位);CommitViewState 新增 repoRoot 字段; - 最近提交消息改 per-repo key(hyperGit.recentCommitMessages:${repoRoot}), 未命中回落旧全局 key 一次(平滑迁移,不删旧数据)。 🤖 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 --- src/adapter/commit/commit-service.ts | 15 ++++++-- src/adapter/webview/commit-webview.ts | 31 ++++++++++++++--- src/adapter/webview/log-webview.ts | 49 ++++++++++++++++++++++----- src/shared/protocol.ts | 2 ++ 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/adapter/commit/commit-service.ts b/src/adapter/commit/commit-service.ts index a88731b..e2f012e 100644 --- a/src/adapter/commit/commit-service.ts +++ b/src/adapter/commit/commit-service.ts @@ -40,7 +40,11 @@ export interface AiSeams { readonly conflict: IConflictResolver; } -const RECENT_KEY = 'hyperGit.recentCommitMessages'; +/** 旧版全局 key(升级前数据):per-repo key 未命中时回落读一次,平滑迁移不删旧数据。 */ +const RECENT_KEY_LEGACY = 'hyperGit.recentCommitMessages'; +/** per-repo key(issue #107 多根隔离):最近提交消息按仓库记忆。 */ +const recentKey = (repoRoot: string | null): string => + repoRoot ? `hyperGit.recentCommitMessages:${repoRoot}` : RECENT_KEY_LEGACY; const RECENT_MAX = 10; /** @@ -85,7 +89,12 @@ export class CommitService implements vscode.Disposable { } getRecentMessages(): readonly string[] { - const raw = this.workspaceState.get(RECENT_KEY); + // per-repo key 优先;未命中回落旧全局 key(升级平滑迁移,不删旧数据)。 + const key = recentKey(this.service.repoRoot); + let raw = this.workspaceState.get(key); + if (!raw && key !== RECENT_KEY_LEGACY) { + raw = this.workspaceState.get(RECENT_KEY_LEGACY); + } try { return raw ? (JSON.parse(raw) as string[]) : []; } catch { @@ -96,7 +105,7 @@ export class CommitService implements vscode.Disposable { private pushRecent(message: string): void { const trimmed = message.trim(); const list = [trimmed, ...this.getRecentMessages().filter((m) => m !== trimmed)].slice(0, RECENT_MAX); - void this.workspaceState.update(RECENT_KEY, JSON.stringify(list)); + void this.workspaceState.update(recentKey(this.service.repoRoot), JSON.stringify(list)); } /** 提交:stage 选中文件 → Checkin hook 链 → commit → 可选 push。 */ diff --git a/src/adapter/webview/commit-webview.ts b/src/adapter/webview/commit-webview.ts index 70c72e9..db5f974 100644 --- a/src/adapter/webview/commit-webview.ts +++ b/src/adapter/webview/commit-webview.ts @@ -180,6 +180,7 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider { tree: buildFileTree(files.map((f) => f.path)), conventionalEnabled: this.commit.conventionalEnabled(), busy: false, + repoRoot: this.service.repoRoot ?? '', }; this.post({ type: 'state', payload: state }); this.sendValidation(); @@ -274,11 +275,30 @@ details.advanced[open] summary { margin-bottom: 4px; }