From 81b4f294b37a180ae13c42e3b2e69c2a9f2a8914 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 23 Oct 2026 19:30:44 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(Log):=20Filter=20by=20Author=20?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E6=8C=89=E4=BB=93=E5=BA=93=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=EF=BC=8C=E9=87=8D=E5=90=AF=E4=B8=8E=E5=88=87=E5=BA=93?= =?UTF-8?q?=E5=90=8E=E6=81=A2=E5=A4=8D;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - authors 过滤接入 scope/dmode 同款按仓库 workspaceState 范式(hyperGit.log.authors:); - setFilter/clearFilter 汇聚唯一写入点 persistAuthors,空选确认与 Clear Filter 的清除同样持久化; - 恢复并入 loadRepoScopedPrefs(构造期 + 切库),切库语义改为「清非持久维度 + 恢复新仓库持久值」; - engine 纯函数 parsePersistedAuthors 元素级校验脏数据(JSON 串 → AuthorTerm[],永不抛异常); - showHistory 改增量式 setFilter(与其余过滤命令同构),修复整替静默清空 authors 的遗留不一致; - 重开选择器可见已勾选清单(既有预勾选能力随状态存活自动生效),新增 12 个单测覆盖。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini Code Assist](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- src/adapter/history-commands.ts | 3 +- src/adapter/webview/log-webview.ts | 39 ++++- src/engine/log/log-filter.ts | 34 +++++ tests/unit/log-filter.test.ts | 52 ++++++- .../unit/log-webview-authors-persist.test.ts | 142 ++++++++++++++++++ 5 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 tests/unit/log-webview-authors-persist.test.ts diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index 883b7e1..10f26ee 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -79,7 +79,8 @@ export function registerHistoryCommands( subs.push( vscode.commands.registerCommand('hyperGit.showHistory', async (change?: ChangeItem) => { if (change?.relativePath) { - logTree.setFilter({ path: change.relativePath }); + // 增量式与其余过滤命令同构:整替 filter 会静默清掉持久化的 authors(文件历史与作者过滤相交而非重置)。 + logTree.setFilter({ ...logTree.getFilter(), path: change.relativePath }); } await vscode.commands.executeCommand('hyperGit.log.focus'); }), diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index 929977f..ed24d28 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -1,7 +1,13 @@ import * as vscode from 'vscode'; import type { GitRepositoryService } from '../git-repository-service'; import { parseNameStatus, statusLabel, parseShortStat } from '../../engine/log/commit-files'; -import { applyClientFilters, toClientFilter, type LogFilter } from '../../engine/log/log-filter'; +import { + applyClientFilters, + parsePersistedAuthors, + toClientFilter, + type AuthorTerm, + type LogFilter, +} from '../../engine/log/log-filter'; import { DEFAULT_LANE_PALETTE } from '../../engine/log/graph-color'; import { computeGraphLayout, maxLanes } from '../../engine/log/graph-layout'; import { getBaseStyles, GRAPH_ROW_H, GRAPH_LANE_W, ICON_CHEVRON_DOWN, ICON_CLOSE } from './shared-styles'; @@ -110,6 +116,10 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter private static dmodeKey(root: string): string { return `hyperGit.log.dmode:${root}`; } + /** authors 过滤按仓库持久化 key(同 scope/dmode 范式;值为 AuthorTerm[] JSON 串,脏数据经 parsePersistedAuthors 免疫)。 */ + private static authorsKey(root: string): string { + return `hyperGit.log.authors:${root}`; + } /** context key 同步(标题栏按钮显隐 / 勾选态由 when 子句驱动)。 */ private static setCtx(key: string, value: string | boolean): void { void vscode.commands.executeCommand('setContext', key, value); @@ -128,25 +138,27 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter t = setTimeout(() => this.refresh(), 400); }), ); - // 激活即恢复当前仓库的 scope/dmode 记忆并同步 context key(标题栏控件先于视图解析渲染)。 + // 激活即恢复当前仓库的 scope/dmode/authors 记忆并同步 context key(标题栏控件先于视图解析渲染)。 this.loadRepoScopedPrefs(); - // 活跃仓库切换(issue #107):host 级过滤条件是旧仓库语境的产物,跨仓库无意义 → 清空; - // scope/dmode 恢复该仓库的记忆值(无记忆回退 'all'/'flat');图数据重拉由上方 onDidChange 订阅驱动。 + // 活跃仓库切换(issue #107):非持久过滤维度是旧仓库语境的产物,跨仓库无意义 → 清空; + // authors 恢复新仓库的持久值(无记忆回退不过滤);图数据重拉由上方 onDidChange 订阅驱动 + // (service 契约保证本事件先于 onDidChange fire)。 this.disposables.push( service.onDidChangeRepository(() => { - this.filter = {}; this.loadRepoScopedPrefs(); }), ); } - /** 装载当前仓库的 scope / dmode 偏好(memento → 内存 + context key)。 */ + /** 装载当前仓库的 scope / dmode / authors 偏好(memento → 内存 + context key;authors 整体重赋 filter)。 */ private loadRepoScopedPrefs(): void { const root = this.service.repoRoot; this.scope = (root ? this.workspaceState.get(LogWebviewProvider.scopeKey(root)) : undefined) ?? 'all'; this.detailMode = (root ? this.workspaceState.get<'flat' | 'tree'>(LogWebviewProvider.dmodeKey(root)) : undefined) ?? 'flat'; LogWebviewProvider.setCtx('hyperGit.log.scope', this.scope); LogWebviewProvider.setCtx('hyperGit.log.tree', this.detailMode === 'tree'); + const authors = root ? parsePersistedAuthors(this.workspaceState.get(LogWebviewProvider.authorsKey(root))) : undefined; + this.filter = authors ? { authors } : {}; } /** 标题栏 Scope 子菜单选中项(hyperGit.log.scopeAll/Current/Checkpointer 命令入口)。 */ @@ -176,14 +188,29 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter setFilter(filter: LogFilter): void { this.filter = filter; + this.persistAuthors(filter.authors); this.refresh(); } clearFilter(): void { this.filter = {}; + this.persistAuthors(undefined); this.refresh(); } + /** + * authors 维度按仓库持久化(唯一写入点,setFilter/clearFilter 汇聚于此):重启与切库后经 + * {@link loadRepoScopedPrefs} 恢复;repoRoot 未知时仅内存(激活后切库事件自然覆盖)。 + * `update(key, undefined)` 即删除条目 —— 空选确认与 Clear Filter 的「清除」同样持久化,重启不复活。 + */ + private persistAuthors(authors: readonly AuthorTerm[] | undefined): void { + const root = this.service.repoRoot; + if (!root) { + return; + } + void this.workspaceState.update(LogWebviewProvider.authorsKey(root), authors?.length ? JSON.stringify(authors) : undefined); + } + getFilter(): LogFilter { return this.filter; } diff --git a/src/engine/log/log-filter.ts b/src/engine/log/log-filter.ts index 9af9420..4c9ca9b 100644 --- a/src/engine/log/log-filter.ts +++ b/src/engine/log/log-filter.ts @@ -33,6 +33,40 @@ export type AuthorTerm = | { readonly kind: 'exact'; readonly name: string; readonly email: string } | { readonly kind: 'pattern'; readonly pattern: string }; +/** 元素级合法性判定(持久化 salvage 用):exact 项要求 name/email 均为非空串,pattern 项要求非空 pattern。 */ +function isAuthorTerm(x: unknown): x is AuthorTerm { + if (typeof x !== 'object' || x === null) { + return false; + } + const t = x as Record; + if (t.kind === 'exact') { + return typeof t.name === 'string' && t.name.length > 0 && typeof t.email === 'string' && t.email.length > 0; + } + return t.kind === 'pattern' && typeof t.pattern === 'string' && t.pattern.length > 0; +} + +/** + * 解析持久化的 authors 过滤(memento JSON 串 → {@link AuthorTerm} 列表): + * 非字符串 / 非法 JSON / 非数组返回 undefined;元素级剔除脏项(kind 未知或缺字段), + * 剔除后为空亦返回 undefined(与「空选 = 不过滤」的规范化一致)。脏数据免疫,永不抛异常。 + */ +export function parsePersistedAuthors(raw: unknown): readonly AuthorTerm[] | undefined { + if (typeof raw !== 'string' || raw.length === 0) { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (!Array.isArray(parsed)) { + return undefined; + } + const terms = parsed.filter(isAuthorTerm); + return terms.length ? terms : undefined; +} + /** BRE 特殊字符集:git `--author` 缺省 POSIX BRE(非 ERE),仅 `\ . [ * ^ $` 有特殊义; * `+ ? | ( ) {` 在 BRE 中是普通字符,加反斜杠反成量词/分组操作符(实测 `\+` 使含 `+` 的邮箱归零命中),勿扩大转义集。 */ const BRE_SPECIAL = /[.*[\]^$\\]/g; diff --git a/tests/unit/log-filter.test.ts b/tests/unit/log-filter.test.ts index 2952075..80fb1a5 100644 --- a/tests/unit/log-filter.test.ts +++ b/tests/unit/log-filter.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { CHECKPOINT_SUBJECT_RE, applyClientFilters, isCheckpointSubject, safeRegex, type FilterableCommit, type LogClientFilter } from '../../src/engine/log/log-filter'; +import { + CHECKPOINT_SUBJECT_RE, + applyClientFilters, + isCheckpointSubject, + parsePersistedAuthors, + safeRegex, + type FilterableCommit, + type LogClientFilter, +} from '../../src/engine/log/log-filter'; const D = (iso: string): Date => new Date(iso); const C = (message: string, parents: string[], authorDate?: Date): FilterableCommit => ({ message, parents, authorDate }); @@ -114,3 +122,45 @@ describe('safeRegex', () => { expect(safeRegex('(')).toBeUndefined(); }); }); + +describe('parsePersistedAuthors', () => { + it('合法 exact + pattern 混合 JSON 串还原结构(round-trip)', () => { + const terms = [ + { kind: 'exact', name: 'ThreeFish', email: 'fish@example.com' }, + { kind: 'pattern', pattern: '^bot' }, + ] as const; + expect(parsePersistedAuthors(JSON.stringify(terms))).toEqual(terms); + }); + + it('非字符串输入返回 undefined', () => { + expect(parsePersistedAuthors(undefined)).toBeUndefined(); + expect(parsePersistedAuthors(42)).toBeUndefined(); + expect(parsePersistedAuthors({})).toBeUndefined(); + }); + + it('空串与非法 JSON 返回 undefined', () => { + expect(parsePersistedAuthors('')).toBeUndefined(); + expect(parsePersistedAuthors('not-json')).toBeUndefined(); + }); + + it('非数组 JSON 返回 undefined', () => { + expect(parsePersistedAuthors('{"kind":"exact"}')).toBeUndefined(); + }); + + it('元素脏项剔除,仅留合法项', () => { + const raw = JSON.stringify([ + { kind: 'exact', name: 'A', email: 'a@x.com' }, + { kind: 'exact', name: '', email: 'b@x.com' }, // name 空 + { kind: 'exact', name: 'C' }, // 缺 email + { kind: 'pattern', pattern: '' }, // pattern 空 + { kind: 'unknown' }, // kind 未知 + 'not-an-object', + ]); + expect(parsePersistedAuthors(raw)).toEqual([{ kind: 'exact', name: 'A', email: 'a@x.com' }]); + }); + + it('全脏 / 空数组返回 undefined(与「空选 = 不过滤」规范化一致)', () => { + expect(parsePersistedAuthors(JSON.stringify([{ kind: 'nope' }]))).toBeUndefined(); + expect(parsePersistedAuthors('[]')).toBeUndefined(); + }); +}); diff --git a/tests/unit/log-webview-authors-persist.test.ts b/tests/unit/log-webview-authors-persist.test.ts new file mode 100644 index 0000000..75fba3c --- /dev/null +++ b/tests/unit/log-webview-authors-persist.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('vscode', () => ({ + // 构造路径仅触 setCtx(commands.executeCommand);webview 未 resolve 时 pushState 早退,无需其余 API。 + commands: { executeCommand: vi.fn(() => Promise.resolve()) }, +})); + +import type { Memento } from 'vscode'; +import type { GitRepositoryService } from '../../src/adapter/git-repository-service'; +import type { GitHubCiService } from '../../src/adapter/ci/github-ci-service'; +import type { AuthorTerm } from '../../src/engine/log/log-filter'; +import { LogWebviewProvider } from '../../src/adapter/webview/log-webview'; + +/** 内存 Memento(模拟 workspaceState 持久化往返;update(key, undefined) 即删除条目,对齐 Memento 语义)。 */ +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 { + if (value === undefined) { + this.map.delete(key); + } else { + this.map.set(key, value); + } + return Promise.resolve(); + } +} + +/** 功能型 EventEmitter mock(事件注册监听、fire 通知;branch-favorites.test.ts 同范式)。 */ +class FakeEmitter { + private listeners: Array<(a: A) => void> = []; + readonly event = (l: (a: A) => void): { dispose: () => void } => { + this.listeners.push(l); + return { dispose: () => { this.listeners = this.listeners.filter((x) => x !== l); } }; + }; + fire(a: A): void { + for (const l of [...this.listeners]) { + l(a); + } + } +} + +/** fake service:可变 repoRoot + 可手动 fire 的两个生命周期事件。 */ +function makeService(root: string | null) { + const repoChanged = new FakeEmitter<{ previousRoot: string | null; root: string | null }>(); + const changed = new FakeEmitter(); + const service = { + repoRoot: root, + onDidChange: changed.event, + onDidChangeRepository: repoChanged.event, + fireRepoChange(next: string | null): void { + const previousRoot = this.repoRoot; + this.repoRoot = next; + repoChanged.fire({ previousRoot, root: next }); + }, + }; + return service; +} + +const AUTHORS: readonly AuthorTerm[] = [ + { kind: 'exact', name: 'ThreeFish', email: 'fish@example.com' }, + { kind: 'pattern', pattern: '^bot' }, +]; + +function makeProvider(root: string | null, memento: MemMemento): { provider: LogWebviewProvider; service: ReturnType } { + const service = makeService(root); + const provider = new LogWebviewProvider( + service as unknown as GitRepositoryService, + {} as GitHubCiService, + memento as unknown as Memento, + ); + return { provider, service }; +} + +describe('LogWebviewProvider authors 持久化', () => { + it('构造期从 memento 恢复 authors', () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-a', JSON.stringify(AUTHORS)); + const { provider } = makeProvider('/repo-a', memento); + expect(provider.getFilter()).toEqual({ authors: AUTHORS }); + provider.dispose(); + }); + + it('setFilter 持久化往返:同 memento 重建 provider 恢复', () => { + const memento = new MemMemento(); + const { provider } = makeProvider('/repo-a', memento); + provider.setFilter({ authors: AUTHORS, grep: 'x' }); + expect(memento.get('hyperGit.log.authors:/repo-a')).toBe(JSON.stringify(AUTHORS)); + provider.dispose(); + + const { provider: revived } = makeProvider('/repo-a', memento); + expect(revived.getFilter()).toEqual({ authors: AUTHORS }); + revived.dispose(); + }); + + it('清除同样持久化:空选确认(authors: undefined)与 clearFilter 均删 key,重启不复活', () => { + const memento = new MemMemento(); + const { provider } = makeProvider('/repo-a', memento); + provider.setFilter({ ...provider.getFilter(), authors: AUTHORS }); + expect(memento.get('hyperGit.log.authors:/repo-a')).toBeDefined(); + provider.setFilter({ ...provider.getFilter(), authors: undefined }); + expect(memento.get('hyperGit.log.authors:/repo-a')).toBeUndefined(); + + provider.setFilter({ authors: AUTHORS }); + provider.clearFilter(); + expect(memento.get('hyperGit.log.authors:/repo-a')).toBeUndefined(); + provider.dispose(); + }); + + it('脏持久化数据免疫:视为未设置,不崩溃', () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-a', 'not-json'); + const { provider } = makeProvider('/repo-a', memento); + expect(provider.getFilter()).toEqual({}); + provider.dispose(); + }); + + it('切库重绑:非持久维度清空,authors 恢复新仓库持久值', () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-b', JSON.stringify([{ kind: 'exact', name: 'B', email: 'b@x.com' }])); + const { provider, service } = makeProvider('/repo-a', memento); + provider.setFilter({ authors: AUTHORS, grep: 'stale' }); + + service.fireRepoChange('/repo-b'); + expect(provider.getFilter()).toEqual({ authors: [{ kind: 'exact', name: 'B', email: 'b@x.com' }] }); + + // 往返切回 A:恢复 A 的 authors + service.fireRepoChange('/repo-a'); + expect(provider.getFilter()).toEqual({ authors: AUTHORS }); + provider.dispose(); + }); + + it('repoRoot 为 null 时仅内存,不写 memento', () => { + const memento = new MemMemento(); + const { provider } = makeProvider(null, memento); + provider.setFilter({ authors: AUTHORS }); + expect(memento.get('hyperGit.log.authors:null')).toBeUndefined(); + expect(provider.getFilter()).toEqual({ authors: AUTHORS }); + provider.dispose(); + }); +}); From d6ff75e6d5872f98c43581223261c02d60a994e9 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 23 Oct 2026 19:30:51 +0800 Subject: [PATCH 2/7] =?UTF-8?q?docs(Issue):=20=E8=AE=B0=E5=BD=95=20authors?= =?UTF-8?q?=20=E8=BF=87=E6=BB=A4=E6=8C=81=E4=B9=85=E5=8C=96=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=BB=8F=E9=AA=8C=E4=B8=8E=20CHANGELOG=20=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG [Unreleased] 补 Fixed 条目(含 Show History 增量式行为变更说明); - issue.md 追加 #20:表因 / 根因 / 处理方式 / 后续防范(视图态持久化范式与增量式 setFilter 约束)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini Code Assist](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- CHANGELOG.md | 4 ++++ docs/.agents/issue.md | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea9426..62e8a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### Fixed + +- **Graph 视图「Filter by Author…」选择不再随重启丢失(按仓库持久化)**:authors 过滤此前仅存于内存,重启 VS Code 或切换仓库后即被清空。现在沿用 scope/dmode 的按仓库 workspaceState 范式持久化(`hyperGit.log.authors:`),重启与切库往返后自动恢复为上次设置;重开选择器即可见已勾选清单并继续增删;空选确认与 Clear Filter 的「清除」同样持久化,重启不复活。脏持久化数据经 `parsePersistedAuthors` 元素级校验免疫(engine 纯函数,全量单测)。**附带行为变更**:SCM 变更列表右键「Show History」由整替过滤器改为与其余过滤命令一致的增量式——文件历史现与活跃的作者过滤相交,而非静默清空后者(M3 期遗留不一致,亦是持久化被破坏的唯一硬冲突点)。 + ## [0.0.21] - 2026-10-23 — Filter by Author 候选建议 · 多选过滤 · 元字符作者修复 ### Added diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 465848f..8b48257 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -172,3 +172,11 @@ - **处理方式**:删除循环改三桶(成功 / 服务端已无此 ref / 真失败)——「服务端已无此 ref」经 engine 纯逻辑 `isRemoteRefMissingError`(stderr 优先,匹配 `remote ref does not exist` / `delete of non-existent ref` 变体)识别,按达成收尾并把本地陈旧 tracking ref 纳入既有 `branch -D -r` 清理;真失败经 stderr 优先的 `gitErrorText`(`engine/git-state/git-error.ts` 单一事实源)提取原因,由 `formatRemoteDeleteResult` 汇总为通知(失败走 `showGitError`,联动 Show Output 看完整 stderr)。确认弹窗/受保护主干硬阻断/上游软警示逻辑不动。 - **后续防范**:① 批量操作的 `catch` 必须保留可行动的失败原因(execGit 拒绝值已挂 `.stderr`,消费即可),`grep -rn 'catch {' src/adapter/` 应逐个确认非吞错;② remote-tracking ref 与服务端存在时滞,任何「对远程分支的操作」都要考虑「本地有、服务端无」的陈旧态——删除/比较/PR 匹配等场景遇到 `does not exist` 类报错应先判陈旧再判失败;③ 视图列表的「幽灵分支」根治靠 `fetch --prune`(Prune Remotes 命令已有),删除路径的容错只是补救。 - **同类问题影响**:所有列出 remote-tracking ref 并提供远程删除的 Git GUI;凡把服务端拒绝一概当失败、不区分「目标态已达成」的实现,以及任何丢弃 stderr 的批量 CLI 包装。 + +## #20 Graph 视图 Filter by Author 过滤选择随重启丢失(未持久化) + +- **表因**:Graph 视图「Filter Graph… → Filter by Author…」多选作者后,重启 VS Code(或切换仓库再切回)过滤被清空,需从头重选。 +- **根因**:过滤状态仅存于 `LogWebviewProvider` 内存字段 `private filter: LogFilter = {}`,`setFilter/clearFilter` 只改内存不落盘;切库 handler 还会显式 `this.filter = {}`。同文件的 scope/dmode 早有按仓库 workspaceState 持久化范式(`loadRepoScopedPrefs`),filter 维度从未接入。另有一处硬冲突:`hyperGit.showHistory` 用 `setFilter({ path })` 整替 filter 对象(M3 期遗留,早于其余 5 个过滤命令统一的 `{ ...f, <维度> }` 增量范式),若接入持久化会静默清空已保存的 authors。 +- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构。 +- **后续防范**:① 视图 UI 状态凡期望跨会话存活,一律挂到既有的按仓库 memento 范式(key 带 repoRoot、读时校验、写时 undefined 删 key),勿新造存储;② 新增过滤维度命令必须沿用 `{ ...getFilter(), <维度> }` 增量式,整替 filter 会静默摧毁其他维度(可 grep `setFilter({` 检查是否带 spread);③ 持久化读写要过脏数据校验纯函数(engine 层),adapter 只做编排。 +- **同类问题影响**:其余 6 个过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)仍未持久化(本次范围裁定不动,如需按同范式扩展);所有「内存态 + 仓库语境」的视图组件在接入持久化前都存在同样的重启丢失。 From 73b60272d2bd1ea801be24e74de201599dd5af68 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 23 Oct 2026 20:13:54 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(Log):=20pickAuthors=20=E5=88=87?= =?UTF-8?q?=E5=BA=93=E5=AE=88=E5=8D=AB=E9=98=B2=E8=AF=AF=E5=86=99=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=EF=BC=8C=E8=A1=A5=20wiring=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=B9=B6=E5=90=8C=E6=AD=A5=E6=96=87=E6=A1=A3;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - settle 增 rootAtStart 切库守卫:环境驱动切库(不夺焦点)后确认,不再把旧仓库勾选经 persistAuthors 误写新仓库持久化 key; - 新增 history-commands-filter-wiring.test.ts:真实 Provider 链路钉住 showHistory 增量式与 settle 守卫(整替回退即红); - multi-root-repo-selection.md 已知限制同步新切库语义;issue.md #20 记录守卫与经验; 🤖 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 | 4 +- docs/features/multi-root-repo-selection.md | 2 +- src/adapter/history-commands.ts | 8 +- .../history-commands-filter-wiring.test.ts | 177 ++++++++++++++++++ 4 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 tests/unit/history-commands-filter-wiring.test.ts diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 8b48257..6ed5176 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -177,6 +177,6 @@ - **表因**:Graph 视图「Filter Graph… → Filter by Author…」多选作者后,重启 VS Code(或切换仓库再切回)过滤被清空,需从头重选。 - **根因**:过滤状态仅存于 `LogWebviewProvider` 内存字段 `private filter: LogFilter = {}`,`setFilter/clearFilter` 只改内存不落盘;切库 handler 还会显式 `this.filter = {}`。同文件的 scope/dmode 早有按仓库 workspaceState 持久化范式(`loadRepoScopedPrefs`),filter 维度从未接入。另有一处硬冲突:`hyperGit.showHistory` 用 `setFilter({ path })` 整替 filter 对象(M3 期遗留,早于其余 5 个过滤命令统一的 `{ ...f, <维度> }` 增量范式),若接入持久化会静默清空已保存的 authors。 -- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构。 -- **后续防范**:① 视图 UI 状态凡期望跨会话存活,一律挂到既有的按仓库 memento 范式(key 带 repoRoot、读时校验、写时 undefined 删 key),勿新造存储;② 新增过滤维度命令必须沿用 `{ ...getFilter(), <维度> }` 增量式,整替 filter 会静默摧毁其他维度(可 grep `setFilter({` 检查是否带 spread);③ 持久化读写要过脏数据校验纯函数(engine 层),adapter 只做编排。 +- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构;④ Review 收敛补充:`pickAuthors` settle 增切库守卫(rootAtStart 同范式——环境驱动切库不夺焦点、QuickPick 不隐藏,取数窗口外确认会把旧仓库勾选经 persistAuthors 误写新仓库持久化 key),`multi-root-repo-selection.md` 已知限制同步新切库语义,wiring 级测试(`history-commands-filter-wiring.test.ts`)钉住 showHistory 增量式与 settle 守卫。 +- **后续防范**:① 视图 UI 状态凡期望跨会话存活,一律挂到既有的按仓库 memento 范式(key 带 repoRoot、读时校验、写时 undefined 删 key),勿新造存储;② 新增过滤维度命令必须沿用 `{ ...getFilter(), <维度> }` 增量式,整替 filter 会静默摧毁其他维度(可 grep `setFilter({` 检查是否带 spread);③ 持久化读写要过脏数据校验纯函数(engine 层),adapter 只做编排;④ 跨 await 的确认回调(QuickPick settle 等)写仓库语境数据前复核 repoRoot 未变——持久化把瞬态竞态升级为跨重启的粘性污染。 - **同类问题影响**:其余 6 个过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)仍未持久化(本次范围裁定不动,如需按同范式扩展);所有「内存态 + 仓库语境」的视图组件在接入持久化前都存在同样的重启丢失。 diff --git a/docs/features/multi-root-repo-selection.md b/docs/features/multi-root-repo-selection.md index 0c51898..344c749 100644 --- a/docs/features/multi-root-repo-selection.md +++ b/docs/features/multi-root-repo-selection.md @@ -69,6 +69,6 @@ ## 已知限制 -- 仓库切换时 Graph 的 host 级过滤条件(author/path/grep 等)清空——过滤条件是旧仓库语境的产物,跨仓库无意义; +- 仓库切换时 Graph 的非持久过滤维度(path/grep/日期等)清空——过滤条件是旧仓库语境的产物,跨仓库无意义;authors 例外:按仓库持久化(`hyperGit.log.authors:`),切库即恢复目标仓库的记忆值(无记忆回退不过滤); - 多仓库历史混仓 Shelf 数据归属迁移时的当前活跃仓库(无法按仓库归因); - CI 状态按 commit hash 缓存(跨仓库理论碰撞概率可忽略,行为正确)。 diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index 10f26ee..6a13d17 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -782,6 +782,9 @@ async function pickAuthors(service: GitRepositoryService, logTree: LogFilterCont }); // settle 幂等守卫:accept 后 onDidHide 仍会触发;undefined = 取消不改,[] = 清除 author 维度。 + // 取数窗口外仍可切库(环境驱动切换不夺焦点、QuickPick 不隐藏):确认时仓库已变则放弃写回, + // 防旧仓库勾选经 setFilter→persistAuthors 误写新仓库的持久化 key。 + const rootAtStart = service.repoRoot; let settled = false; const settle = (terms: readonly AuthorTerm[] | undefined): void => { if (settled) { @@ -789,16 +792,15 @@ async function pickAuthors(service: GitRepositoryService, logTree: LogFilterCont } settled = true; qp.dispose(); - if (terms) { + if (terms && service.repoRoot === rootAtStart) { logTree.setFilter({ ...logTree.getFilter(), authors: terms.length ? terms : undefined }); } }; qp.onDidAccept(() => settle(qp.selectedItems.map((i) => i.term))); qp.onDidHide(() => settle(undefined)); - // 候选按当前视图 scope 一次取数(非逐键);切库竞态守卫沿用 rootAtStart 范式; + // 候选按当前视图 scope 一次取数(非逐键);切库竞态守卫见上方 rootAtStart; // 空仓库 / unborn HEAD / 查询失败 → 空列表降级(仅 pattern 逃生口可用)。 - const rootAtStart = service.repoRoot; authors = parseShortlog(await service.execGit(['shortlog', ...buildAuthorListArgs(logTree.getScope())]).catch(() => '')); if (service.repoRoot !== rootAtStart) { qp.dispose(); diff --git a/tests/unit/history-commands-filter-wiring.test.ts b/tests/unit/history-commands-filter-wiring.test.ts new file mode 100644 index 0000000..b8898a2 --- /dev/null +++ b/tests/unit/history-commands-filter-wiring.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi } from 'vitest'; + +/** 命令 handler 注册表 + 可脚本化 QuickPick(pickAuthors 全交互在此驱动,不依赖真实 quick input)。 */ +const h = vi.hoisted(() => ({ + handlers: new Map unknown>(), + qpListeners: new Map void>(), + qp: undefined as { selectedItems: unknown[] } | undefined, +})); + +vi.mock('vscode', () => ({ + commands: { + registerCommand: vi.fn((id: string, handler: (arg?: unknown) => unknown) => { + h.handlers.set(id, handler); + return { dispose: () => undefined }; + }), + executeCommand: vi.fn(() => Promise.resolve()), + }, + window: { + createQuickPick: () => { + const qp = { + canSelectMany: false, + matchOnDescription: false, + matchOnDetail: false, + title: '', + placeholder: '', + busy: false, + items: [] as unknown[], + selectedItems: [] as unknown[], + value: '', + show: () => undefined, + dispose: () => undefined, + onDidChangeValue: (cb: (...args: unknown[]) => void) => { + h.qpListeners.set('value', cb); + return { dispose: () => undefined }; + }, + onDidChangeSelection: (cb: (...args: unknown[]) => void) => { + h.qpListeners.set('selection', cb); + return { dispose: () => undefined }; + }, + onDidAccept: (cb: (...args: unknown[]) => void) => { + h.qpListeners.set('accept', cb); + return { dispose: () => undefined }; + }, + onDidHide: (cb: (...args: unknown[]) => void) => { + h.qpListeners.set('hide', cb); + return { dispose: () => undefined }; + }, + }; + h.qp = qp; + return qp; + }, + }, +})); + +import type { Memento } from 'vscode'; +import type { ChangeItem, GitRepositoryService } from '../../src/adapter/git-repository-service'; +import type { GitHubCiService } from '../../src/adapter/ci/github-ci-service'; +import type { AuthorTerm } from '../../src/engine/log/log-filter'; +import { LogWebviewProvider } from '../../src/adapter/webview/log-webview'; +import { registerHistoryCommands } from '../../src/adapter/history-commands'; + +/** 内存 Memento(对齐 workspaceState 语义:update(key, undefined) 即删除条目)。 */ +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 { + if (value === undefined) { + this.map.delete(key); + } else { + this.map.set(key, value); + } + return Promise.resolve(); + } +} + +/** 功能型 EventEmitter mock(branch-favorites.test.ts 同范式)。 */ +class FakeEmitter { + private listeners: Array<(a: A) => void> = []; + readonly event = (l: (a: A) => void): { dispose: () => void } => { + this.listeners.push(l); + return { dispose: () => { this.listeners = this.listeners.filter((x) => x !== l); } }; + }; + fire(a: A): void { + for (const l of [...this.listeners]) { + l(a); + } + } +} + +/** fake service:可变 repoRoot + shortlog 空结果 + 可手动 fire 的生命周期事件。 */ +function makeService(root: string | null) { + const repoChanged = new FakeEmitter<{ previousRoot: string | null; root: string | null }>(); + const changed = new FakeEmitter(); + const service = { + repoRoot: root, + execGit: async () => '', + onDidChange: changed.event, + onDidChangeRepository: repoChanged.event, + fireRepoChange(next: string | null): void { + const previousRoot = this.repoRoot; + this.repoRoot = next; + repoChanged.fire({ previousRoot, root: next }); + }, + }; + return service; +} + +const FISH: AuthorTerm = { kind: 'exact', name: 'ThreeFish', email: 'fish@example.com' }; +const BOB: AuthorTerm = { kind: 'exact', name: 'Bob', email: 'bob@example.com' }; + +/** 真实 Provider(持久化真链路)+ 注册全部命令,返回触发入口。 */ +function makeWiring(root: string, memento: MemMemento) { + const service = makeService(root); + const provider = new LogWebviewProvider( + service as unknown as GitRepositoryService, + {} as GitHubCiService, + memento as unknown as Memento, + ); + registerHistoryCommands( + service as unknown as GitRepositoryService, + provider, + {} as never, + {} as never, + ); + const openPicker = () => (h.handlers.get('hyperGit.logFilterAuthor') as () => Promise)(); + const showHistory = (relativePath: string) => + (h.handlers.get('hyperGit.showHistory') as (c?: ChangeItem) => Promise)( + { relativePath } as unknown as ChangeItem, + ); + const accept = (items: readonly { term: AuthorTerm }[]) => { + // selectedItems 由 onDidAccept 闭包读取(() => settle(qp.selectedItems.map(...))), + // 真实 quick input 由框架维护该字段,此处先注入再触发 accept。 + h.qp!.selectedItems = [...items]; + h.qpListeners.get('accept')!(); + }; + return { provider, service, openPicker, showHistory, accept }; +} + +describe('history-commands 过滤 wiring(真实 Provider 持久化链路)', () => { + it('showHistory 增量式:保留持久化 authors 并叠加 path,不删 memento key', async () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-a', JSON.stringify([FISH])); + const { provider, showHistory } = makeWiring('/repo-a', memento); + + await showHistory('src/a.ts'); + expect(provider.getFilter()).toEqual({ authors: [FISH], path: 'src/a.ts' }); + expect(memento.get('hyperGit.log.authors:/repo-a')).toBe(JSON.stringify([FISH])); + provider.dispose(); + }); + + it('pickAuthors 正常路径:确认勾选写入 filter 与持久化 key', async () => { + const memento = new MemMemento(); + const { provider, openPicker, accept } = makeWiring('/repo-a', memento); + + await openPicker(); + accept([{ term: FISH }]); + expect(provider.getFilter()).toEqual({ authors: [FISH] }); + expect(memento.get('hyperGit.log.authors:/repo-a')).toBe(JSON.stringify([FISH])); + provider.dispose(); + }); + + it('pickAuthors settle 切库守卫:取数完成后切库,确认不再把旧仓库勾选误写新仓库 key', async () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-b', JSON.stringify([BOB])); + const { provider, service, openPicker, accept } = makeWiring('/repo-a', memento); + + await openPicker(); + // 取数守卫已通过(await 完成);此后环境驱动切库(不夺焦点、QuickPick 仍开着)再确认。 + service.fireRepoChange('/repo-b'); + accept([{ term: FISH }]); + expect(provider.getFilter()).toEqual({ authors: [BOB] }); + expect(memento.get('hyperGit.log.authors:/repo-b')).toBe(JSON.stringify([BOB])); + provider.dispose(); + }); +}); From b2d6b1f7c8c9084cfc5b36109905827a6c21ea39 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 23 Oct 2026 20:21:43 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(Log):=20logFilterPath=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=A1=86=E6=9C=9F=E9=97=B4=E5=88=87=E5=BA=93=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=EF=BC=8C=E9=98=B2=E8=B7=A8=E4=BB=93=E5=BA=93=E5=9E=83?= =?UTF-8?q?=E5=9C=BE=20path=20=E8=BF=87=E6=BB=A4;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootAtStart 同范式守卫:showOpenDialog await 期间切库后确认,不再按新仓库 root 求相对路径; - wiring 测试补 logFilterPath 正常路径与切库守卫两例(含 authors 增量叠加断言,去守卫即红); - CHANGELOG 与 issue.md #20 同步记录; 🤖 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 --- CHANGELOG.md | 2 +- docs/.agents/issue.md | 2 +- src/adapter/history-commands.ts | 5 ++- .../history-commands-filter-wiring.test.ts | 40 ++++++++++++++++++- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62e8a36..5c1dffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- **Graph 视图「Filter by Author…」选择不再随重启丢失(按仓库持久化)**:authors 过滤此前仅存于内存,重启 VS Code 或切换仓库后即被清空。现在沿用 scope/dmode 的按仓库 workspaceState 范式持久化(`hyperGit.log.authors:`),重启与切库往返后自动恢复为上次设置;重开选择器即可见已勾选清单并继续增删;空选确认与 Clear Filter 的「清除」同样持久化,重启不复活。脏持久化数据经 `parsePersistedAuthors` 元素级校验免疫(engine 纯函数,全量单测)。**附带行为变更**:SCM 变更列表右键「Show History」由整替过滤器改为与其余过滤命令一致的增量式——文件历史现与活跃的作者过滤相交,而非静默清空后者(M3 期遗留不一致,亦是持久化被破坏的唯一硬冲突点)。 +- **Graph 视图「Filter by Author…」选择不再随重启丢失(按仓库持久化)**:authors 过滤此前仅存于内存,重启 VS Code 或切换仓库后即被清空。现在沿用 scope/dmode 的按仓库 workspaceState 范式持久化(`hyperGit.log.authors:`),重启与切库往返后自动恢复为上次设置;重开选择器即可见已勾选清单并继续增删;空选确认与 Clear Filter 的「清除」同样持久化,重启不复活。脏持久化数据经 `parsePersistedAuthors` 元素级校验免疫(engine 纯函数,全量单测)。**附带行为变更**:SCM 变更列表右键「Show History」由整替过滤器改为与其余过滤命令一致的增量式——文件历史现与活跃的作者过滤相交,而非静默清空后者(M3 期遗留不一致,亦是持久化被破坏的唯一硬冲突点)。另:选择器或「Filter by Path…」对话框打开期间发生切库后确认,不再把旧仓库语境的选择误写新仓库(同构 rootAtStart 切库守卫)。 ## [0.0.21] - 2026-10-23 — Filter by Author 候选建议 · 多选过滤 · 元字符作者修复 diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 6ed5176..ec7397a 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -177,6 +177,6 @@ - **表因**:Graph 视图「Filter Graph… → Filter by Author…」多选作者后,重启 VS Code(或切换仓库再切回)过滤被清空,需从头重选。 - **根因**:过滤状态仅存于 `LogWebviewProvider` 内存字段 `private filter: LogFilter = {}`,`setFilter/clearFilter` 只改内存不落盘;切库 handler 还会显式 `this.filter = {}`。同文件的 scope/dmode 早有按仓库 workspaceState 持久化范式(`loadRepoScopedPrefs`),filter 维度从未接入。另有一处硬冲突:`hyperGit.showHistory` 用 `setFilter({ path })` 整替 filter 对象(M3 期遗留,早于其余 5 个过滤命令统一的 `{ ...f, <维度> }` 增量范式),若接入持久化会静默清空已保存的 authors。 -- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构;④ Review 收敛补充:`pickAuthors` settle 增切库守卫(rootAtStart 同范式——环境驱动切库不夺焦点、QuickPick 不隐藏,取数窗口外确认会把旧仓库勾选经 persistAuthors 误写新仓库持久化 key),`multi-root-repo-selection.md` 已知限制同步新切库语义,wiring 级测试(`history-commands-filter-wiring.test.ts`)钉住 showHistory 增量式与 settle 守卫。 +- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构;④ Review 收敛补充:`pickAuthors` settle 增切库守卫(rootAtStart 同范式——环境驱动切库不夺焦点、QuickPick 不隐藏,取数窗口外确认会把旧仓库勾选经 persistAuthors 误写新仓库持久化 key),`multi-root-repo-selection.md` 已知限制同步新切库语义,wiring 级测试(`history-commands-filter-wiring.test.ts`)钉住 showHistory 增量式与 settle 守卫;同构守卫同步应用于 `logFilterPath`(对话框 await 期间切库会按新仓库 root 求出跨仓库垃圾相对路径——预存竞态,收敛门范围外观察,经用户裁定收口)。 - **后续防范**:① 视图 UI 状态凡期望跨会话存活,一律挂到既有的按仓库 memento 范式(key 带 repoRoot、读时校验、写时 undefined 删 key),勿新造存储;② 新增过滤维度命令必须沿用 `{ ...getFilter(), <维度> }` 增量式,整替 filter 会静默摧毁其他维度(可 grep `setFilter({` 检查是否带 spread);③ 持久化读写要过脏数据校验纯函数(engine 层),adapter 只做编排;④ 跨 await 的确认回调(QuickPick settle 等)写仓库语境数据前复核 repoRoot 未变——持久化把瞬态竞态升级为跨重启的粘性污染。 - **同类问题影响**:其余 6 个过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)仍未持久化(本次范围裁定不动,如需按同范式扩展);所有「内存态 + 仓库语境」的视图组件在接入持久化前都存在同样的重启丢失。 diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index 6a13d17..398ee1f 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -36,9 +36,12 @@ export function registerHistoryCommands( subs.push( vscode.commands.registerCommand('hyperGit.logFilterPath', async () => { + // 切库竞态守卫(pickAuthors rootAtStart 同范式):对话框 await 期间切库时,所选文件 + // 会按切换后的仓库 root 求相对路径,得到跨仓库垃圾 path 过滤 → 仓库已变即放弃。 + const rootAtStart = service.repoRoot; const uri = await vscode.window.showOpenDialog({ canSelectMany: false }); const repo = service.repo; - if (uri?.[0] && repo) { + if (uri?.[0] && repo && service.repoRoot === rootAtStart) { const rel = path.relative(repo.rootUri.fsPath, uri[0].fsPath).split(path.sep).join('/'); const f = logTree.getFilter(); logTree.setFilter({ ...f, path: rel }); diff --git a/tests/unit/history-commands-filter-wiring.test.ts b/tests/unit/history-commands-filter-wiring.test.ts index b8898a2..6d00800 100644 --- a/tests/unit/history-commands-filter-wiring.test.ts +++ b/tests/unit/history-commands-filter-wiring.test.ts @@ -16,6 +16,7 @@ vi.mock('vscode', () => ({ executeCommand: vi.fn(() => Promise.resolve()), }, window: { + showOpenDialog: vi.fn(), createQuickPick: () => { const qp = { canSelectMany: false, @@ -52,6 +53,7 @@ vi.mock('vscode', () => ({ }, })); +import * as vscode from 'vscode'; import type { Memento } from 'vscode'; import type { ChangeItem, GitRepositoryService } from '../../src/adapter/git-repository-service'; import type { GitHubCiService } from '../../src/adapter/ci/github-ci-service'; @@ -96,6 +98,10 @@ function makeService(root: string | null) { const service = { repoRoot: root, execGit: async () => '', + /** repo 跟随 repoRoot(logFilterPath 以 repo.rootUri 求相对路径)。 */ + get repo(): { rootUri: { fsPath: string } } | undefined { + return this.repoRoot ? { rootUri: { fsPath: this.repoRoot } } : undefined; + }, onDidChange: changed.event, onDidChangeRepository: repoChanged.event, fireRepoChange(next: string | null): void { @@ -135,7 +141,18 @@ function makeWiring(root: string, memento: MemMemento) { h.qp!.selectedItems = [...items]; h.qpListeners.get('accept')!(); }; - return { provider, service, openPicker, showHistory, accept }; + /** 触发 logFilterPath:对话框挂起期间可注入事件(切库),再以所选文件路径放行。 */ + const filterPathWithDialog = (fsPath: string, during?: () => void) => { + let resolveDialog!: (v: unknown) => void; + vi.mocked(vscode.window.showOpenDialog).mockImplementationOnce( + () => new Promise((r) => { resolveDialog = r; }), + ); + const p = (h.handlers.get('hyperGit.logFilterPath') as () => Promise)(); + during?.(); + resolveDialog([{ fsPath }]); + return p; + }; + return { provider, service, openPicker, showHistory, accept, filterPathWithDialog }; } describe('history-commands 过滤 wiring(真实 Provider 持久化链路)', () => { @@ -174,4 +191,25 @@ describe('history-commands 过滤 wiring(真实 Provider 持久化链路)', expect(memento.get('hyperGit.log.authors:/repo-b')).toBe(JSON.stringify([BOB])); provider.dispose(); }); + + it('logFilterPath 正常路径:所选文件按当前仓库求相对路径,增量叠加不覆盖 authors', async () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-a', JSON.stringify([FISH])); + const { provider, filterPathWithDialog } = makeWiring('/repo-a', memento); + + await filterPathWithDialog('/repo-a/src/a.ts'); + expect(provider.getFilter()).toEqual({ authors: [FISH], path: 'src/a.ts' }); + provider.dispose(); + }); + + it('logFilterPath 切库守卫:对话框期间切库,不再按新仓库 root 求出跨仓库垃圾 path', async () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-b', JSON.stringify([BOB])); + const { provider, service, filterPathWithDialog } = makeWiring('/repo-a', memento); + + await filterPathWithDialog('/repo-a/src/a.ts', () => service.fireRepoChange('/repo-b')); + // 无守卫时:path.relative('/repo-b', '/repo-a/src/a.ts') = '../repo-a/src/a.ts'(垃圾过滤)。 + expect(provider.getFilter()).toEqual({ authors: [BOB] }); + provider.dispose(); + }); }); From b308e10fae29a280fe93e45f276c0072f32c6b55 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 23 Oct 2026 20:53:56 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(Log):=20=E5=85=B6=E4=BD=99=206=20?= =?UTF-8?q?=E4=B8=AA=E8=BF=87=E6=BB=A4=E7=BB=B4=E5=BA=A6=E6=8C=89=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=8C=E5=88=87=E5=BA=93?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E5=85=A8=E8=A6=86=E7=9B=96=E6=94=B6=E5=8F=A3?= =?UTF-8?q?;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine 新增 serializePersistableFilter/parsePersistedFilter:组合 key hyperGit.log.filter: 单 key 原子读写,dateFrom/dateTo ISO 串往返,逐字段脏数据剔除(空串、mergeMode all 归一化不落盘); - setFilter/clearFilter 双写点(authors key + 组合 key),loadRepoScopedPrefs 恢复时合并整体重赋 filter;issue #107 切库清空过滤限制消除; - 同范式切库守卫补齐 grep/mergeMode/date/regex 四命令与 commit 文件菜单(handleFileMenu 单点覆盖全部 7 个动作——菜单 await 期间切库时 change 属旧仓库语境,不派发); - 测试:log-webview-authors-persist 改名 log-webview-filter-persist 并扩展组合 key 套件;wiring +6 例(grep 正常/守卫、mergeMode 守卫、文件菜单正常/守卫,变异验证去守卫即红);CHANGELOG/issue.md/multi-root 文档同步; 🤖 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 --- CHANGELOG.md | 2 +- docs/.agents/issue.md | 4 +- docs/features/multi-root-repo-selection.md | 1 - src/adapter/history-commands.ts | 16 ++- src/adapter/webview/commit-webview.ts | 5 +- src/adapter/webview/log-webview.ts | 32 +++-- src/engine/log/log-filter.ts | 82 +++++++++++++ .../history-commands-filter-wiring.test.ts | 116 ++++++++++++++++++ tests/unit/log-filter.test.ts | 51 ++++++++ ....ts => log-webview-filter-persist.test.ts} | 69 ++++++++++- 10 files changed, 360 insertions(+), 18 deletions(-) rename tests/unit/{log-webview-authors-persist.test.ts => log-webview-filter-persist.test.ts} (64%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1dffe..897ff11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- **Graph 视图「Filter by Author…」选择不再随重启丢失(按仓库持久化)**:authors 过滤此前仅存于内存,重启 VS Code 或切换仓库后即被清空。现在沿用 scope/dmode 的按仓库 workspaceState 范式持久化(`hyperGit.log.authors:`),重启与切库往返后自动恢复为上次设置;重开选择器即可见已勾选清单并继续增删;空选确认与 Clear Filter 的「清除」同样持久化,重启不复活。脏持久化数据经 `parsePersistedAuthors` 元素级校验免疫(engine 纯函数,全量单测)。**附带行为变更**:SCM 变更列表右键「Show History」由整替过滤器改为与其余过滤命令一致的增量式——文件历史现与活跃的作者过滤相交,而非静默清空后者(M3 期遗留不一致,亦是持久化被破坏的唯一硬冲突点)。另:选择器或「Filter by Path…」对话框打开期间发生切库后确认,不再把旧仓库语境的选择误写新仓库(同构 rootAtStart 切库守卫)。 +- **Graph 视图过滤条件不再随重启 / 切库丢失(全部 7 个维度按仓库持久化)**:authors、File Path、Grep、Merge Mode、Date Filter、Message Regex 此前仅存于内存,重启 VS Code 或切换仓库后即被清空。现在沿用 scope/dmode 的按仓库 workspaceState 范式持久化——authors 独立 key(`hyperGit.log.authors:`),其余 6 维组合 key(`hyperGit.log.filter:`,单 key 原子读写防半恢复态,dateFrom/dateTo 经 ISO 串往返),重启与切库往返后自动恢复为上次设置;重开「Filter by Author…」即可见已勾选清单并继续增删;空选确认与 Clear Filter 的「清除」同样持久化,重启不复活;脏持久化数据经 engine 纯函数逐字段校验免疫。**附带行为变更**:① SCM 变更列表右键「Show History」由整替过滤器改为与其余过滤命令一致的增量式——文件历史现与活跃过滤相交,而非静默清空后者(M3 期遗留不一致);② 任何过滤对话框(Author / Path / Grep / Merge Mode / Date / Message Regex)或 Commit 文件右键菜单打开期间发生切库后确认,不再把旧仓库语境的选择误写新仓库(rootAtStart 切库守卫全覆盖);③ issue #107 的「切库清空 host 级过滤」限制消除——每个仓库记忆自己的完整过滤条件。 ## [0.0.21] - 2026-10-23 — Filter by Author 候选建议 · 多选过滤 · 元字符作者修复 diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index ec7397a..92b4c31 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -177,6 +177,6 @@ - **表因**:Graph 视图「Filter Graph… → Filter by Author…」多选作者后,重启 VS Code(或切换仓库再切回)过滤被清空,需从头重选。 - **根因**:过滤状态仅存于 `LogWebviewProvider` 内存字段 `private filter: LogFilter = {}`,`setFilter/clearFilter` 只改内存不落盘;切库 handler 还会显式 `this.filter = {}`。同文件的 scope/dmode 早有按仓库 workspaceState 持久化范式(`loadRepoScopedPrefs`),filter 维度从未接入。另有一处硬冲突:`hyperGit.showHistory` 用 `setFilter({ path })` 整替 filter 对象(M3 期遗留,早于其余 5 个过滤命令统一的 `{ ...f, <维度> }` 增量范式),若接入持久化会静默清空已保存的 authors。 -- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构;④ Review 收敛补充:`pickAuthors` settle 增切库守卫(rootAtStart 同范式——环境驱动切库不夺焦点、QuickPick 不隐藏,取数窗口外确认会把旧仓库勾选经 persistAuthors 误写新仓库持久化 key),`multi-root-repo-selection.md` 已知限制同步新切库语义,wiring 级测试(`history-commands-filter-wiring.test.ts`)钉住 showHistory 增量式与 settle 守卫;同构守卫同步应用于 `logFilterPath`(对话框 await 期间切库会按新仓库 root 求出跨仓库垃圾相对路径——预存竞态,收敛门范围外观察,经用户裁定收口)。 +- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构;④ Review 收敛补充:`pickAuthors` settle 增切库守卫(rootAtStart 同范式——环境驱动切库不夺焦点、QuickPick 不隐藏,取数窗口外确认会把旧仓库勾选经 persistAuthors 误写新仓库持久化 key),`multi-root-repo-selection.md` 已知限制同步新切库语义,wiring 级测试(`history-commands-filter-wiring.test.ts`)钉住 showHistory 增量式与 settle 守卫;同构守卫同步应用于 `logFilterPath`(对话框 await 期间切库会按新仓库 root 求出跨仓库垃圾相对路径——预存竞态,收敛门范围外观察,经用户裁定收口);⑤ 其余 6 个过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)经用户决策按同范式扩展持久化:组合 key `hyperGit.log.filter:`(单 key 原子读写防半恢复态),dateFrom/dateTo ISO 串往返,engine 层 `serializePersistableFilter/parsePersistedFilter` 逐字段校验(归一化:空串、mergeMode 'all' 不落盘),issue #107 的「切库清空 host 级过滤」限制随之消除(multi-root 文档已知限制同步移除);增量 Review 复核后同范式守卫补齐 grep/mergeMode/date/regex 四命令与 commit 文件菜单(`handleFileMenu`——菜单 await 期间切库时 change 属旧仓库语境,任何动作不派发),全部对话框/菜单 await 窗口至此收口。 - **后续防范**:① 视图 UI 状态凡期望跨会话存活,一律挂到既有的按仓库 memento 范式(key 带 repoRoot、读时校验、写时 undefined 删 key),勿新造存储;② 新增过滤维度命令必须沿用 `{ ...getFilter(), <维度> }` 增量式,整替 filter 会静默摧毁其他维度(可 grep `setFilter({` 检查是否带 spread);③ 持久化读写要过脏数据校验纯函数(engine 层),adapter 只做编排;④ 跨 await 的确认回调(QuickPick settle 等)写仓库语境数据前复核 repoRoot 未变——持久化把瞬态竞态升级为跨重启的粘性污染。 -- **同类问题影响**:其余 6 个过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)仍未持久化(本次范围裁定不动,如需按同范式扩展);所有「内存态 + 仓库语境」的视图组件在接入持久化前都存在同样的重启丢失。 +- **同类问题影响**:其余 6 个过滤维度已随后续决策按同范式扩展持久化(见处理方式⑤);所有「内存态 + 仓库语境」的视图组件在接入持久化前都存在同样的重启丢失。 diff --git a/docs/features/multi-root-repo-selection.md b/docs/features/multi-root-repo-selection.md index 344c749..2364902 100644 --- a/docs/features/multi-root-repo-selection.md +++ b/docs/features/multi-root-repo-selection.md @@ -69,6 +69,5 @@ ## 已知限制 -- 仓库切换时 Graph 的非持久过滤维度(path/grep/日期等)清空——过滤条件是旧仓库语境的产物,跨仓库无意义;authors 例外:按仓库持久化(`hyperGit.log.authors:`),切库即恢复目标仓库的记忆值(无记忆回退不过滤); - 多仓库历史混仓 Shelf 数据归属迁移时的当前活跃仓库(无法按仓库归因); - CI 状态按 commit hash 缓存(跨仓库理论碰撞概率可忽略,行为正确)。 diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index 398ee1f..4b402b7 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -683,7 +683,13 @@ export function registerHistoryCommands( subs.push( vscode.commands.registerCommand('hyperGit.logFilterGrep', async () => { + // 切库竞态守卫(logFilterPath/settle 同范式,grep/mergeMode/date/regex 四命令同): + // 对话框 await 期间切库即放弃,防旧仓库输入经 persistFilter 误写新仓库持久化 key。 + const rootAtStart = service.repoRoot; const grep = await vscode.window.showInputBox({ prompt: 'Filter by message text/regex (--grep)', placeHolder: 'e.g. fix|bug' }); + if (service.repoRoot !== rootAtStart) { + return; + } const f = logTree.getFilter(); logTree.setFilter({ ...f, grep: grep && grep.trim() ? grep.trim() : undefined }); }), @@ -691,6 +697,7 @@ export function registerHistoryCommands( subs.push( vscode.commands.registerCommand('hyperGit.logFilterMergeMode', async () => { + const rootAtStart = service.repoRoot; const pick = await vscode.window.showQuickPick( [ { label: 'All Commits', mode: 'all' as MergeMode }, @@ -699,7 +706,7 @@ export function registerHistoryCommands( ], { placeHolder: 'Merge commit filter mode' }, ); - if (!pick) { + if (!pick || service.repoRoot !== rootAtStart) { return; } const f = logTree.getFilter(); @@ -709,6 +716,7 @@ export function registerHistoryCommands( subs.push( vscode.commands.registerCommand('hyperGit.logFilterDate', async () => { + const rootAtStart = service.repoRoot; const now = Date.now(); const pick = await vscode.window.showQuickPick( [ @@ -719,7 +727,7 @@ export function registerHistoryCommands( ], { placeHolder: 'Commit date range' }, ); - if (!pick) { + if (!pick || service.repoRoot !== rootAtStart) { return; } const f = logTree.getFilter(); @@ -729,7 +737,11 @@ export function registerHistoryCommands( subs.push( vscode.commands.registerCommand('hyperGit.logFilterRegex', async () => { + const rootAtStart = service.repoRoot; const re = await vscode.window.showInputBox({ prompt: 'Filter by message regex (client-side)', placeHolder: 'e.g. ^feat:' }); + if (service.repoRoot !== rootAtStart) { + return; + } const f = logTree.getFilter(); logTree.setFilter({ ...f, messageRegex: re && re.trim() ? re.trim() : undefined }); }), diff --git a/src/adapter/webview/commit-webview.ts b/src/adapter/webview/commit-webview.ts index 8f6b980..800582d 100644 --- a/src/adapter/webview/commit-webview.ts +++ b/src/adapter/webview/commit-webview.ts @@ -170,8 +170,11 @@ export class CommitWebviewProvider implements vscode.WebviewViewProvider, vscode { label: '$(diff-ignored) Add to .gitignore', command: 'hyperGit.ignorePath' }, { label: '$(discard) Discard Changes', command: 'hyperGit.discardChanges' }, ]; + // 切库守卫(rootAtStart 范式):菜单 await 期间切库时 change 属旧仓库语境(showHistory 的 + // path 随持久化落盘会污染新仓库),任何动作都放弃派发。 + const rootAtStart = this.service.repoRoot; const pick = await vscode.window.showQuickPick(actions.slice(), { placeHolder: relativePath }); - if (!pick) { + if (!pick || this.service.repoRoot !== rootAtStart) { return; } await vscode.commands.executeCommand(pick.command, change); diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index ed24d28..5dcb6ab 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -4,6 +4,8 @@ import { parseNameStatus, statusLabel, parseShortStat } from '../../engine/log/c import { applyClientFilters, parsePersistedAuthors, + parsePersistedFilter, + serializePersistableFilter, toClientFilter, type AuthorTerm, type LogFilter, @@ -120,6 +122,10 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter private static authorsKey(root: string): string { return `hyperGit.log.authors:${root}`; } + /** 非 authors 过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)组合持久化 key(整体 JSON 串,单 key 原子读写防半恢复态)。 */ + private static filterKey(root: string): string { + return `hyperGit.log.filter:${root}`; + } /** context key 同步(标题栏按钮显隐 / 勾选态由 when 子句驱动)。 */ private static setCtx(key: string, value: string | boolean): void { void vscode.commands.executeCommand('setContext', key, value); @@ -138,10 +144,10 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter t = setTimeout(() => this.refresh(), 400); }), ); - // 激活即恢复当前仓库的 scope/dmode/authors 记忆并同步 context key(标题栏控件先于视图解析渲染)。 + // 激活即恢复当前仓库的 scope/dmode/过滤记忆并同步 context key(标题栏控件先于视图解析渲染)。 this.loadRepoScopedPrefs(); - // 活跃仓库切换(issue #107):非持久过滤维度是旧仓库语境的产物,跨仓库无意义 → 清空; - // authors 恢复新仓库的持久值(无记忆回退不过滤);图数据重拉由上方 onDidChange 订阅驱动 + // 活跃仓库切换(issue #107):过滤条件是旧仓库语境的产物,跨仓库无意义 → 整体重载为 + // 新仓库的持久过滤(无记忆回退不过滤);图数据重拉由上方 onDidChange 订阅驱动 // (service 契约保证本事件先于 onDidChange fire)。 this.disposables.push( service.onDidChangeRepository(() => { @@ -150,15 +156,16 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter ); } - /** 装载当前仓库的 scope / dmode / authors 偏好(memento → 内存 + context key;authors 整体重赋 filter)。 */ + /** 装载当前仓库的 scope / dmode / 过滤器偏好(memento → 内存 + context key;filter 整体重赋)。 */ private loadRepoScopedPrefs(): void { const root = this.service.repoRoot; this.scope = (root ? this.workspaceState.get(LogWebviewProvider.scopeKey(root)) : undefined) ?? 'all'; this.detailMode = (root ? this.workspaceState.get<'flat' | 'tree'>(LogWebviewProvider.dmodeKey(root)) : undefined) ?? 'flat'; LogWebviewProvider.setCtx('hyperGit.log.scope', this.scope); LogWebviewProvider.setCtx('hyperGit.log.tree', this.detailMode === 'tree'); + const persisted = root ? parsePersistedFilter(this.workspaceState.get(LogWebviewProvider.filterKey(root))) : undefined; const authors = root ? parsePersistedAuthors(this.workspaceState.get(LogWebviewProvider.authorsKey(root))) : undefined; - this.filter = authors ? { authors } : {}; + this.filter = { ...(persisted ?? {}), ...(authors ? { authors } : {}) }; } /** 标题栏 Scope 子菜单选中项(hyperGit.log.scopeAll/Current/Checkpointer 命令入口)。 */ @@ -188,20 +195,22 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter setFilter(filter: LogFilter): void { this.filter = filter; + this.persistFilter(filter); this.persistAuthors(filter.authors); this.refresh(); } clearFilter(): void { this.filter = {}; + this.persistFilter(undefined); this.persistAuthors(undefined); this.refresh(); } /** - * authors 维度按仓库持久化(唯一写入点,setFilter/clearFilter 汇聚于此):重启与切库后经 + * authors 维度按仓库持久化(写入点汇聚于 setFilter/clearFilter):重启与切库后经 * {@link loadRepoScopedPrefs} 恢复;repoRoot 未知时仅内存(激活后切库事件自然覆盖)。 - * `update(key, undefined)` 即删除条目 —— 空选确认与 Clear Filter 的「清除」同样持久化,重启不复活。 + * `update(key, undefined)` 即删除条目 —— 清除同样持久化,重启不复活。 */ private persistAuthors(authors: readonly AuthorTerm[] | undefined): void { const root = this.service.repoRoot; @@ -211,6 +220,15 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter void this.workspaceState.update(LogWebviewProvider.authorsKey(root), authors?.length ? JSON.stringify(authors) : undefined); } + /** 非 authors 过滤维度组合持久化(同 {@link persistAuthors} 范式;归一化后全空即删 key)。 */ + private persistFilter(filter: LogFilter | undefined): void { + const root = this.service.repoRoot; + if (!root) { + return; + } + void this.workspaceState.update(LogWebviewProvider.filterKey(root), serializePersistableFilter(filter)); + } + getFilter(): LogFilter { return this.filter; } diff --git a/src/engine/log/log-filter.ts b/src/engine/log/log-filter.ts index 4c9ca9b..a647e99 100644 --- a/src/engine/log/log-filter.ts +++ b/src/engine/log/log-filter.ts @@ -67,6 +67,88 @@ export function parsePersistedAuthors(raw: unknown): readonly AuthorTerm[] | und return terms.length ? terms : undefined; } +/** 非空字符串维度(空串规约为未设置)。 */ +function nonEmptyString(x: unknown): string | undefined { + return typeof x === 'string' && x.length > 0 ? x : undefined; +} + +/** ISO 串 → 合法 Date(非串 / 空 / 不可解析返回 undefined)。 */ +function validDate(x: unknown): Date | undefined { + if (typeof x !== 'string' || x.length === 0) { + return undefined; + } + const d = new Date(x); + return Number.isNaN(d.getTime()) ? undefined : d; +} + +/** + * 非 authors 过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)的持久化序列化: + * 归一化后全空(undefined 维度、空串、mergeMode 'all')返回 undefined 即删 key;Date 经 ISO 串 + * 落盘。authors 独立成 key,不属本层(见 {@link parsePersistedAuthors})。 + */ +export function serializePersistableFilter(filter: LogFilter | undefined): string | undefined { + if (!filter) { + return undefined; + } + const out: { path?: string; grep?: string; mergeMode?: MergeMode; dateFrom?: string; dateTo?: string; messageRegex?: string } = {}; + if (filter.path) { + out.path = filter.path; + } + if (filter.grep) { + out.grep = filter.grep; + } + if (filter.mergeMode && filter.mergeMode !== 'all') { + out.mergeMode = filter.mergeMode; + } + if (filter.dateFrom) { + out.dateFrom = filter.dateFrom.toISOString(); + } + if (filter.dateTo) { + out.dateTo = filter.dateTo.toISOString(); + } + if (filter.messageRegex) { + out.messageRegex = filter.messageRegex; + } + const s = JSON.stringify(out); + return s === '{}' ? undefined : s; +} + +/** + * 解析持久化的非 authors 过滤维度({@link serializePersistableFilter} 的逆):非字符串 / 非法 + * JSON / 非对象返回 undefined;逐字段校验剔除脏项(字符串非空、mergeMode 合法、日期可解析), + * 全空 / 全脏返回 undefined(与「无过滤」规约一致)。脏数据免疫,永不抛异常。 + */ +export function parsePersistedFilter(raw: unknown): LogFilter | undefined { + if (typeof raw !== 'string' || raw.length === 0) { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) { + return undefined; + } + const t = parsed as Record; + const path = nonEmptyString(t.path); + const grep = nonEmptyString(t.grep); + const messageRegex = nonEmptyString(t.messageRegex); + const dateFrom = validDate(t.dateFrom); + const dateTo = validDate(t.dateTo); + const mergeMode = t.mergeMode === 'merge-only' || t.mergeMode === 'no-merge' ? t.mergeMode : undefined; + const filter: LogFilter = { + ...(path ? { path } : {}), + ...(grep ? { grep } : {}), + ...(mergeMode ? { mergeMode } : {}), + ...(dateFrom ? { dateFrom } : {}), + ...(dateTo ? { dateTo } : {}), + ...(messageRegex ? { messageRegex } : {}), + }; + return Object.keys(filter).length ? filter : undefined; +} + /** BRE 特殊字符集:git `--author` 缺省 POSIX BRE(非 ERE),仅 `\ . [ * ^ $` 有特殊义; * `+ ? | ( ) {` 在 BRE 中是普通字符,加反斜杠反成量词/分组操作符(实测 `\+` 使含 `+` 的邮箱归零命中),勿扩大转义集。 */ const BRE_SPECIAL = /[.*[\]^$\\]/g; diff --git a/tests/unit/history-commands-filter-wiring.test.ts b/tests/unit/history-commands-filter-wiring.test.ts index 6d00800..7b0fd3b 100644 --- a/tests/unit/history-commands-filter-wiring.test.ts +++ b/tests/unit/history-commands-filter-wiring.test.ts @@ -17,6 +17,8 @@ vi.mock('vscode', () => ({ }, window: { showOpenDialog: vi.fn(), + showInputBox: vi.fn(), + showQuickPick: vi.fn(), createQuickPick: () => { const qp = { canSelectMany: false, @@ -60,6 +62,8 @@ import type { GitHubCiService } from '../../src/adapter/ci/github-ci-service'; import type { AuthorTerm } from '../../src/engine/log/log-filter'; import { LogWebviewProvider } from '../../src/adapter/webview/log-webview'; import { registerHistoryCommands } from '../../src/adapter/history-commands'; +import { CommitWebviewProvider } from '../../src/adapter/webview/commit-webview'; +import { parsePersistedFilter } from '../../src/engine/log/log-filter'; /** 内存 Memento(对齐 workspaceState 语义:update(key, undefined) 即删除条目)。 */ class MemMemento { @@ -155,6 +159,36 @@ function makeWiring(root: string, memento: MemMemento) { return { provider, service, openPicker, showHistory, accept, filterPathWithDialog }; } +/** 触发任一「对话框型」命令/流程:对话框挂起期间可注入事件(切库),再以所选值放行。 */ +function withPendingDialog( + mockFn: { mockImplementationOnce: (impl: () => Promise) => void }, + resolveValue: unknown, + run: () => Promise, + during?: () => void, +): Promise { + let resolveDialog!: (v: unknown) => void; + mockFn.mockImplementationOnce(() => new Promise((r) => { resolveDialog = r; })); + const p = run(); + during?.(); + resolveDialog(resolveValue); + return p; +} + +/** commit 文件右键菜单(handleFileMenu 直调,绕过 webview 消息层):getChanges 返回单个文件。 */ +function makeCommitMenu(root: string) { + const service = makeService(root); + (service as unknown as { getChanges: () => unknown[] }).getChanges = () => [{ relativePath: 'src/a.ts' }]; + const provider = new CommitWebviewProvider( + service as unknown as GitRepositoryService, + {} as never, + {} as never, + new MemMemento() as unknown as Memento, + ); + const menu = (path: string) => + (provider as unknown as { handleFileMenu: (p: string) => Promise }).handleFileMenu(path); + return { provider, service, menu }; +} + describe('history-commands 过滤 wiring(真实 Provider 持久化链路)', () => { it('showHistory 增量式:保留持久化 authors 并叠加 path,不删 memento key', async () => { const memento = new MemMemento(); @@ -202,6 +236,19 @@ describe('history-commands 过滤 wiring(真实 Provider 持久化链路)', provider.dispose(); }); + it('重启往返后 showHistory 增量叠加:恢复的完整持久过滤(authors + grep)再叠 path', async () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-a', JSON.stringify([FISH])); + memento.update('hyperGit.log.filter:/repo-a', JSON.stringify({ grep: 'fix' })); + const { provider, showHistory } = makeWiring('/repo-a', memento); + + await showHistory('src/a.ts'); + expect(provider.getFilter()).toEqual({ authors: [FISH], grep: 'fix', path: 'src/a.ts' }); + // path 随组合 key 一并持久化,重启后完整过滤存活 + expect(parsePersistedFilter(memento.get('hyperGit.log.filter:/repo-a'))).toEqual({ grep: 'fix', path: 'src/a.ts' }); + provider.dispose(); + }); + it('logFilterPath 切库守卫:对话框期间切库,不再按新仓库 root 求出跨仓库垃圾 path', async () => { const memento = new MemMemento(); memento.update('hyperGit.log.authors:/repo-b', JSON.stringify([BOB])); @@ -212,4 +259,73 @@ describe('history-commands 过滤 wiring(真实 Provider 持久化链路)', expect(provider.getFilter()).toEqual({ authors: [BOB] }); provider.dispose(); }); + + it('grep 正常路径:确认输入(含 trim)写入 filter 与组合 key', async () => { + const memento = new MemMemento(); + const { provider } = makeWiring('/repo-a', memento); + const grep = h.handlers.get('hyperGit.logFilterGrep') as () => Promise; + + await withPendingDialog(vi.mocked(vscode.window.showInputBox), ' fix ', grep); + expect(provider.getFilter()).toEqual({ grep: 'fix' }); + expect(parsePersistedFilter(memento.get('hyperGit.log.filter:/repo-a'))).toEqual({ grep: 'fix' }); + provider.dispose(); + }); + + it('grep 切库守卫:输入框期间切库,确认不再把旧仓库输入写入新仓库持久化 key', async () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-b', JSON.stringify([BOB])); + const { provider, service } = makeWiring('/repo-a', memento); + const grep = h.handlers.get('hyperGit.logFilterGrep') as () => Promise; + + await withPendingDialog(vi.mocked(vscode.window.showInputBox), 'fix', grep, () => service.fireRepoChange('/repo-b')); + expect(provider.getFilter()).toEqual({ authors: [BOB] }); + expect(memento.get('hyperGit.log.filter:/repo-b')).toBeUndefined(); + provider.dispose(); + }); + + it('mergeMode 切库守卫:选择器期间切库,确认不写入任何仓库的持久化 key', async () => { + const memento = new MemMemento(); + const { provider, service } = makeWiring('/repo-a', memento); + const mergeMode = h.handlers.get('hyperGit.logFilterMergeMode') as () => Promise; + + await withPendingDialog( + vi.mocked(vscode.window.showQuickPick), + { label: 'Merge Commits Only', mode: 'merge-only' }, + mergeMode, + () => service.fireRepoChange('/repo-b'), + ); + expect(provider.getFilter()).toEqual({}); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBeUndefined(); + expect(memento.get('hyperGit.log.filter:/repo-b')).toBeUndefined(); + provider.dispose(); + }); + + it('commit 文件菜单切库守卫:菜单期间切库,不派发携带旧仓库 relativePath 的动作', async () => { + const exec = vi.mocked(vscode.commands.executeCommand); + exec.mockClear(); + const { provider, service, menu } = makeCommitMenu('/repo-a'); + + await withPendingDialog( + vi.mocked(vscode.window.showQuickPick), + { label: '$(history) Show History', command: 'hyperGit.showHistory' }, + () => menu('src/a.ts'), + () => service.fireRepoChange('/repo-b'), + ); + expect(exec).not.toHaveBeenCalledWith('hyperGit.showHistory', expect.anything()); + provider.dispose(); + }); + + it('commit 文件菜单正常路径:确认后以原 change 派发所选命令', async () => { + const exec = vi.mocked(vscode.commands.executeCommand); + exec.mockClear(); + const { provider, menu } = makeCommitMenu('/repo-a'); + + await withPendingDialog( + vi.mocked(vscode.window.showQuickPick), + { label: '$(history) Show History', command: 'hyperGit.showHistory' }, + () => menu('src/a.ts'), + ); + expect(exec).toHaveBeenCalledWith('hyperGit.showHistory', { relativePath: 'src/a.ts' }); + provider.dispose(); + }); }); diff --git a/tests/unit/log-filter.test.ts b/tests/unit/log-filter.test.ts index 80fb1a5..700d9f3 100644 --- a/tests/unit/log-filter.test.ts +++ b/tests/unit/log-filter.test.ts @@ -4,9 +4,12 @@ import { applyClientFilters, isCheckpointSubject, parsePersistedAuthors, + parsePersistedFilter, safeRegex, + serializePersistableFilter, type FilterableCommit, type LogClientFilter, + type LogFilter, } from '../../src/engine/log/log-filter'; const D = (iso: string): Date => new Date(iso); @@ -164,3 +167,51 @@ describe('parsePersistedAuthors', () => { expect(parsePersistedAuthors('[]')).toBeUndefined(); }); }); + +describe('serializePersistableFilter / parsePersistedFilter', () => { + const FULL: LogFilter = { + path: 'src/a.ts', + grep: 'fix', + mergeMode: 'no-merge', + dateFrom: new Date('2026-01-02T03:04:05.000Z'), + dateTo: new Date('2026-02-03T00:00:00.000Z'), + messageRegex: '^feat:', + }; + + it('全维度 round-trip(Date 经 ISO 串往返)', () => { + expect(parsePersistedFilter(serializePersistableFilter(FULL))).toEqual(FULL); + }); + + it('归一化:空对象、空串、mergeMode all、undefined 入参均不落盘', () => { + expect(serializePersistableFilter({})).toBeUndefined(); + expect(serializePersistableFilter(undefined)).toBeUndefined(); + expect(serializePersistableFilter({ path: '', grep: undefined, mergeMode: 'all' })).toBeUndefined(); + }); + + it('authors 不属本层:序列化忽略', () => { + expect(serializePersistableFilter({ authors: [{ kind: 'pattern', pattern: 'x' }] })).toBeUndefined(); + }); + + it('非字符串 / 非法 JSON / 非对象返回 undefined', () => { + expect(parsePersistedFilter(undefined)).toBeUndefined(); + expect(parsePersistedFilter(42)).toBeUndefined(); + expect(parsePersistedFilter('not-json')).toBeUndefined(); + expect(parsePersistedFilter('[]')).toBeUndefined(); + }); + + it('逐字段剔除脏项,仅留合法维度', () => { + const raw = JSON.stringify({ + path: 'ok.ts', + grep: '', + mergeMode: 'bogus', + dateFrom: 'not-a-date', + dateTo: '2026-02-03T00:00:00.000Z', + messageRegex: 7, + }); + expect(parsePersistedFilter(raw)).toEqual({ path: 'ok.ts', dateTo: new Date('2026-02-03T00:00:00.000Z') }); + }); + + it('全脏返回 undefined', () => { + expect(parsePersistedFilter(JSON.stringify({ grep: '', mergeMode: 'x', dateFrom: 'oops' }))).toBeUndefined(); + }); +}); diff --git a/tests/unit/log-webview-authors-persist.test.ts b/tests/unit/log-webview-filter-persist.test.ts similarity index 64% rename from tests/unit/log-webview-authors-persist.test.ts rename to tests/unit/log-webview-filter-persist.test.ts index 75fba3c..85b91b8 100644 --- a/tests/unit/log-webview-authors-persist.test.ts +++ b/tests/unit/log-webview-filter-persist.test.ts @@ -10,6 +10,7 @@ import type { GitRepositoryService } from '../../src/adapter/git-repository-serv import type { GitHubCiService } from '../../src/adapter/ci/github-ci-service'; import type { AuthorTerm } from '../../src/engine/log/log-filter'; import { LogWebviewProvider } from '../../src/adapter/webview/log-webview'; +import { parsePersistedFilter, type LogFilter } from '../../src/engine/log/log-filter'; /** 内存 Memento(模拟 workspaceState 持久化往返;update(key, undefined) 即删除条目,对齐 Memento 语义)。 */ class MemMemento { @@ -90,7 +91,7 @@ describe('LogWebviewProvider authors 持久化', () => { provider.dispose(); const { provider: revived } = makeProvider('/repo-a', memento); - expect(revived.getFilter()).toEqual({ authors: AUTHORS }); + expect(revived.getFilter()).toEqual({ authors: AUTHORS, grep: 'x' }); revived.dispose(); }); @@ -116,7 +117,7 @@ describe('LogWebviewProvider authors 持久化', () => { provider.dispose(); }); - it('切库重绑:非持久维度清空,authors 恢复新仓库持久值', () => { + it('切库重绑:恢复新仓库完整持久过滤(authors + 非 authors 维度各自记忆)', () => { const memento = new MemMemento(); memento.update('hyperGit.log.authors:/repo-b', JSON.stringify([{ kind: 'exact', name: 'B', email: 'b@x.com' }])); const { provider, service } = makeProvider('/repo-a', memento); @@ -125,9 +126,9 @@ describe('LogWebviewProvider authors 持久化', () => { service.fireRepoChange('/repo-b'); expect(provider.getFilter()).toEqual({ authors: [{ kind: 'exact', name: 'B', email: 'b@x.com' }] }); - // 往返切回 A:恢复 A 的 authors + // 往返切回 A:恢复 A 的 authors 与 grep(各自 key 均按仓库存活) service.fireRepoChange('/repo-a'); - expect(provider.getFilter()).toEqual({ authors: AUTHORS }); + expect(provider.getFilter()).toEqual({ authors: AUTHORS, grep: 'stale' }); provider.dispose(); }); @@ -140,3 +141,63 @@ describe('LogWebviewProvider authors 持久化', () => { provider.dispose(); }); }); + +describe('LogWebviewProvider 非 authors 过滤维度持久化', () => { + const DIMS: LogFilter = { + path: 'src/a.ts', + grep: 'fix', + mergeMode: 'no-merge', + dateFrom: new Date('2026-01-02T03:04:05.000Z'), + messageRegex: '^feat:', + }; + + it('setFilter 全维度持久化往返:同 memento 重建 provider 恢复(Date 经 ISO 往返)', () => { + const memento = new MemMemento(); + const { provider } = makeProvider('/repo-a', memento); + provider.setFilter(DIMS); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBeDefined(); + provider.dispose(); + + const { provider: revived } = makeProvider('/repo-a', memento); + expect(revived.getFilter()).toEqual(DIMS); + revived.dispose(); + }); + + it('清除维度只删组合 key 中对应内容,authors key 独立存活', () => { + const memento = new MemMemento(); + const { provider } = makeProvider('/repo-a', memento); + provider.setFilter({ ...DIMS, authors: AUTHORS }); + // 空选确认(清 grep):组合 key 仅剩其余维度,authors key 不动 + provider.setFilter({ ...provider.getFilter(), grep: undefined }); + expect(memento.get('hyperGit.log.authors:/repo-a')).toBe(JSON.stringify(AUTHORS)); + const revived0 = parsePersistedFilter(memento.get('hyperGit.log.filter:/repo-a')); + expect(revived0).toEqual({ path: DIMS.path, mergeMode: DIMS.mergeMode, dateFrom: DIMS.dateFrom, messageRegex: DIMS.messageRegex }); + + // clearFilter:两 key 全删 + provider.clearFilter(); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBeUndefined(); + expect(memento.get('hyperGit.log.authors:/repo-a')).toBeUndefined(); + provider.dispose(); + }); + + it('切库恢复新仓库组合 key,与 authors 合并', () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.filter:/repo-b', JSON.stringify({ grep: 'bot', mergeMode: 'merge-only' })); + memento.update('hyperGit.log.authors:/repo-b', JSON.stringify(AUTHORS)); + const { provider, service } = makeProvider('/repo-a', memento); + provider.setFilter({ path: 'old.ts' }); + + service.fireRepoChange('/repo-b'); + expect(provider.getFilter()).toEqual({ grep: 'bot', mergeMode: 'merge-only', authors: AUTHORS }); + provider.dispose(); + }); + + it('脏组合数据免疫:视为未设置,不崩溃', () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.filter:/repo-a', 'not-json'); + memento.update('hyperGit.log.authors:/repo-a', JSON.stringify(AUTHORS)); + const { provider } = makeProvider('/repo-a', memento); + expect(provider.getFilter()).toEqual({ authors: AUTHORS }); + provider.dispose(); + }); +}); From f5ad4064a53e6f5672f8567ece508aeffebefd7c Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 24 Oct 2026 00:23:32 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(Log):=20Review=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E8=B6=8A=E4=BB=93=20path?= =?UTF-8?q?=20=E6=AE=B5=E7=BA=A7=E6=A0=A1=E9=AA=8C=E3=80=81logFilter=20?= =?UTF-8?q?=E8=81=9A=E5=90=88=E5=AE=88=E5=8D=AB=E3=80=81pickAuthors=20ghos?= =?UTF-8?q?t=20=E9=A1=B9=E4=B8=8E=20date/regex=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A1=A5=E9=BD=90;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- CHANGELOG.md | 2 +- docs/.agents/issue.md | 2 +- docs/features/multi-root-repo-selection.md | 3 +- src/adapter/history-commands.ts | 29 ++- .../history-commands-filter-wiring.test.ts | 166 ++++++++++++++++++ 5 files changed, 195 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 897ff11..78559be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- **Graph 视图过滤条件不再随重启 / 切库丢失(全部 7 个维度按仓库持久化)**:authors、File Path、Grep、Merge Mode、Date Filter、Message Regex 此前仅存于内存,重启 VS Code 或切换仓库后即被清空。现在沿用 scope/dmode 的按仓库 workspaceState 范式持久化——authors 独立 key(`hyperGit.log.authors:`),其余 6 维组合 key(`hyperGit.log.filter:`,单 key 原子读写防半恢复态,dateFrom/dateTo 经 ISO 串往返),重启与切库往返后自动恢复为上次设置;重开「Filter by Author…」即可见已勾选清单并继续增删;空选确认与 Clear Filter 的「清除」同样持久化,重启不复活;脏持久化数据经 engine 纯函数逐字段校验免疫。**附带行为变更**:① SCM 变更列表右键「Show History」由整替过滤器改为与其余过滤命令一致的增量式——文件历史现与活跃过滤相交,而非静默清空后者(M3 期遗留不一致);② 任何过滤对话框(Author / Path / Grep / Merge Mode / Date / Message Regex)或 Commit 文件右键菜单打开期间发生切库后确认,不再把旧仓库语境的选择误写新仓库(rootAtStart 切库守卫全覆盖);③ issue #107 的「切库清空 host 级过滤」限制消除——每个仓库记忆自己的完整过滤条件。 +- **Graph 视图过滤条件不再随重启 / 切库丢失(全部 7 个维度按仓库持久化)**:authors、File Path、Grep、Merge Mode、Date Filter、Message Regex 此前仅存于内存,重启 VS Code 或切换仓库后即被清空。现在沿用 scope/dmode 的按仓库 workspaceState 范式持久化——authors 独立 key(`hyperGit.log.authors:`),其余 6 维组合 key(`hyperGit.log.filter:`,单 key 原子读写防半恢复态,dateFrom/dateTo 经 ISO 串往返),重启与切库往返后自动恢复为上次设置;重开「Filter by Author…」即预勾选已选 exact 作者并继续增删(pattern 项不参与预勾选、重开后不可见,确认新选择会将其移除);空选确认与 Clear Filter 的「清除」同样持久化,重启不复活;脏持久化数据经 engine 纯函数逐字段校验免疫。**附带行为变更**:① SCM 变更列表右键「Show History」由整替过滤器改为与其余过滤命令一致的增量式——文件历史现与活跃过滤相交,而非静默清空后者(M3 期遗留不一致);② 任何过滤对话框(Filter Graph 聚合入口 / Author / Path / Grep / Merge Mode / Date / Message Regex)或 Commit 文件右键菜单打开期间发生切库后确认,不再把旧仓库语境的选择误写新仓库(rootAtStart 切库守卫全覆盖);③ issue #107 的「切库清空 host 级过滤」限制消除——每个仓库记忆自己的完整过滤条件;④ Filter by Path 选中当前仓库外的文件(多根工作区选他仓文件 / 单仓导航到仓外)时以告警拒绝,不再落入 `../…` 垃圾 path 过滤——该垃圾 path 会随持久化成为跨重启的图加载 fatal 错误态。 ## [0.0.21] - 2026-10-23 — Filter by Author 候选建议 · 多选过滤 · 元字符作者修复 diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 92b4c31..48a63d4 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -177,6 +177,6 @@ - **表因**:Graph 视图「Filter Graph… → Filter by Author…」多选作者后,重启 VS Code(或切换仓库再切回)过滤被清空,需从头重选。 - **根因**:过滤状态仅存于 `LogWebviewProvider` 内存字段 `private filter: LogFilter = {}`,`setFilter/clearFilter` 只改内存不落盘;切库 handler 还会显式 `this.filter = {}`。同文件的 scope/dmode 早有按仓库 workspaceState 持久化范式(`loadRepoScopedPrefs`),filter 维度从未接入。另有一处硬冲突:`hyperGit.showHistory` 用 `setFilter({ path })` 整替 filter 对象(M3 期遗留,早于其余 5 个过滤命令统一的 `{ ...f, <维度> }` 增量范式),若接入持久化会静默清空已保存的 authors。 -- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构;④ Review 收敛补充:`pickAuthors` settle 增切库守卫(rootAtStart 同范式——环境驱动切库不夺焦点、QuickPick 不隐藏,取数窗口外确认会把旧仓库勾选经 persistAuthors 误写新仓库持久化 key),`multi-root-repo-selection.md` 已知限制同步新切库语义,wiring 级测试(`history-commands-filter-wiring.test.ts`)钉住 showHistory 增量式与 settle 守卫;同构守卫同步应用于 `logFilterPath`(对话框 await 期间切库会按新仓库 root 求出跨仓库垃圾相对路径——预存竞态,收敛门范围外观察,经用户裁定收口);⑤ 其余 6 个过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)经用户决策按同范式扩展持久化:组合 key `hyperGit.log.filter:`(单 key 原子读写防半恢复态),dateFrom/dateTo ISO 串往返,engine 层 `serializePersistableFilter/parsePersistedFilter` 逐字段校验(归一化:空串、mergeMode 'all' 不落盘),issue #107 的「切库清空 host 级过滤」限制随之消除(multi-root 文档已知限制同步移除);增量 Review 复核后同范式守卫补齐 grep/mergeMode/date/regex 四命令与 commit 文件菜单(`handleFileMenu`——菜单 await 期间切库时 change 属旧仓库语境,任何动作不派发),全部对话框/菜单 await 窗口至此收口。 +- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构;④ Review 收敛补充:`pickAuthors` settle 增切库守卫(rootAtStart 同范式——环境驱动切库不夺焦点、QuickPick 不隐藏,取数窗口外确认会把旧仓库勾选经 persistAuthors 误写新仓库持久化 key),候选集外的持久化 exact 作者以 ghost 项预勾选呈现(selectedItems 只能来自 items,scope 已切换 / shortlog 失败降级时缺项者确认即被静默丢出持久化 key),`multi-root-repo-selection.md` 已知限制同步新切库语义,wiring 级测试(`history-commands-filter-wiring.test.ts`)钉住 showHistory 增量式与 settle 守卫;同构守卫同步应用于 `logFilterPath`(对话框 await 期间切库会按新仓库 root 求出跨仓库垃圾相对路径——预存竞态,收敛门范围外观察,经用户裁定收口;越仓选择同产 `../…` 垃圾 path 且经持久化升级为跨重启粘性污染——增 rel 路径段级越仓校验(`'..'` / `'../'` 前缀;较 showBlame 的 `startsWith('..')` 不误杀仓内 `..` 前缀文件)+ 告警拦截,wiring 测试补越仓与不误杀用例);⑤ 其余 6 个过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)经用户决策按同范式扩展持久化:组合 key `hyperGit.log.filter:`(单 key 原子读写防半恢复态),dateFrom/dateTo ISO 串往返,engine 层 `serializePersistableFilter/parsePersistedFilter` 逐字段校验(归一化:空串、mergeMode 'all' 不落盘),issue #107 的「切库清空 host 级过滤」限制随之消除(multi-root 文档已知限制同步移除,按仓库状态表与导语同步补录过滤 key);增量 Review 复核后同范式守卫补齐 grep/mergeMode/date/regex 四命令、commit 文件菜单(`handleFileMenu`——菜单 await 期间切库时 change 属旧仓库语境,任何动作不派发)与聚合入口 `logFilter`(其 Clear Filter 项直接执行,await 期间切库会删切换后仓库的持久化 key),全部过滤对话框与 Commit 文件菜单的 await 窗口至此收口(Graph 提交行右键菜单 `handleCommitMenu` 为预存未守卫路径,不在本条收口范围);wiring 测试同步补齐 date/regex(正常 + 切库守卫 + 清除分支)与 mergeMode 正常路径用例。 - **后续防范**:① 视图 UI 状态凡期望跨会话存活,一律挂到既有的按仓库 memento 范式(key 带 repoRoot、读时校验、写时 undefined 删 key),勿新造存储;② 新增过滤维度命令必须沿用 `{ ...getFilter(), <维度> }` 增量式,整替 filter 会静默摧毁其他维度(可 grep `setFilter({` 检查是否带 spread);③ 持久化读写要过脏数据校验纯函数(engine 层),adapter 只做编排;④ 跨 await 的确认回调(QuickPick settle 等)写仓库语境数据前复核 repoRoot 未变——持久化把瞬态竞态升级为跨重启的粘性污染。 - **同类问题影响**:其余 6 个过滤维度已随后续决策按同范式扩展持久化(见处理方式⑤);所有「内存态 + 仓库语境」的视图组件在接入持久化前都存在同样的重启丢失。 diff --git a/docs/features/multi-root-repo-selection.md b/docs/features/multi-root-repo-selection.md index 2364902..7e2fb4b 100644 --- a/docs/features/multi-root-repo-selection.md +++ b/docs/features/multi-root-repo-selection.md @@ -1,6 +1,6 @@ # 多根工作区仓库切换(Multi-root Repository Selection) -> 多根工作区(multi-root)含多个 Git 仓库时,**Graph 标题栏出现「切换仓库」图标按钮**(`$(repo)`,对标 Git Graph 的 repo selector;仓库路径常驻 `WebviewView.description` 副标题),Command Palette 亦可直达。切换的是**全局活跃仓库**:Graph / Branches / Commit(changelist)/ Stash / Shelf / Worktrees / 未提交角标**全部视图跟随**,changelist 分配、分支收藏、分支分组偏好、Shelf 存储、最近提交消息**按仓库隔离记忆**,重开窗口回到上次操作的仓库。 +> 多根工作区(multi-root)含多个 Git 仓库时,**Graph 标题栏出现「切换仓库」图标按钮**(`$(repo)`,对标 Git Graph 的 repo selector;仓库路径常驻 `WebviewView.description` 副标题),Command Palette 亦可直达。切换的是**全局活跃仓库**:Graph / Branches / Commit(changelist)/ Stash / Shelf / Worktrees / 未提交角标**全部视图跟随**,changelist 分配、分支收藏、分支分组偏好、Shelf 存储、最近提交消息、Graph 过滤条件**按仓库隔离记忆**,重开窗口回到上次操作的仓库。 ## 交互形态 @@ -51,6 +51,7 @@ | 分支分组偏好 | `hyperGit.branchesGrouping:${repoRoot}` | 同上 + context key 同步 | | Shelf 存储 | `shelves/./` | 目录随 `service.repoRoot` **动态求值**(零快照零双源) | | 最近提交消息 | `hyperGit.recentCommitMessages:${repoRoot}` | 动态 key + 旧全局 key 回落一次(平滑迁移) | +| Graph 过滤条件(authors + path/grep/mergeMode/date/regex) | `hyperGit.log.authors:${repoRoot}`、`hyperGit.log.filter:${repoRoot}` | `loadRepoScopedPrefs` 换 key 重载(构造期 + 切库,engine 纯函数校验脏数据) | | Graph 视图状态(scope/选中/详情模式) | webview `vscode.getState()` v2 `byRepo` 分区 | `graphData` 到达时按 `repoRoot` 换装载 | | Commit 勾选集/视图模式 | 同上 | 同上(勾选是相对路径集合,跨仓库本就错位) | diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index 4b402b7..c7ae759 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -43,6 +43,14 @@ export function registerHistoryCommands( const repo = service.repo; if (uri?.[0] && repo && service.repoRoot === rootAtStart) { const rel = path.relative(repo.rootUri.fsPath, uri[0].fsPath).split(path.sep).join('/'); + // 越仓选择(多根他仓文件 / 单仓外导航)同样产出 '../…' 垃圾 path,且随持久化跨重启 + // 粘性污染(git log fatal)→ 拒绝。按路径段判定:rel 已归一为 '/' 分隔,'..' 或 + // '../' 前缀才是越仓;'..' 仅作文件名前缀(如 '..drafts/note.md')是仓内合法文件,勿误杀 + // (showBlame 的 startsWith('..') 会对后者误杀,属预存差异,不在此处对齐)。 + if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) { + void vscode.window.showWarningMessage('This file is outside the current repository; cannot filter by path'); + return; + } const f = logTree.getFilter(); logTree.setFilter({ ...f, path: rel }); } @@ -54,6 +62,9 @@ export function registerHistoryCommands( subs.push( vscode.commands.registerCommand('hyperGit.logFilter', async () => { // 过滤器聚合入口:QuickPick 分发到既有 6 个过滤命令(复用 handler,零重复实现)。 + // 切库竞态守卫(其余对话框同范式):await 期间切库即放弃——Clear Filter 项直接执行, + // 无守卫会删切换后仓库的持久化 key;过滤子命令也应在新仓库语境下重新发起。 + const rootAtStart = service.repoRoot; const items = [ { label: 'Filter by Author…', description: 'commits by author', action: 'hyperGit.logFilterAuthor' }, { label: 'Filter by Path…', description: 'commits touching a path', action: 'hyperGit.logFilterPath' }, @@ -64,9 +75,10 @@ export function registerHistoryCommands( { label: 'Clear Filter', description: 'remove all active filters', action: 'hyperGit.logClearFilter' }, ]; const pick = await vscode.window.showQuickPick(items, { placeHolder: 'Filter commit log' }); - if (pick) { - await vscode.commands.executeCommand(pick.action); + if (!pick || service.repoRoot !== rootAtStart) { + return; } + await vscode.commands.executeCommand(pick.action); }), ); @@ -773,10 +785,19 @@ async function pickAuthors(service: GitRepositoryService, logTree: LogFilterCont // 勾选自维护:重建 items 会重置 selectedItems,须记录并在重建后回写。 // 预勾选仅 exact 项(pattern 项随输入动态生成,无法预置);重建触发的程序化 selection // 事件与用户点击不可区分,以时间窗抑制(程序化事件在赋值后即刻送达)。 - let checked = new Set(logTree.getFilter().authors?.filter((t) => t.kind === 'exact').map(key) ?? []); + const persistedExact = logTree.getFilter().authors?.filter((t) => t.kind === 'exact') ?? []; + let checked = new Set(persistedExact.map(key)); let patternOn = false; let suppressUntil = 0; let authors: readonly AuthorStat[] = []; + // 候选集外的持久化 exact 作者(scope 已切换 / shortlog 失败降级)以 ghost 项呈现: + // selectedItems 只能来自 items,缺项者确认时会被静默丢出持久化 key——ghost 预勾选保留、可取消移除。 + const ghostItems = (): AuthorPickItem[] => { + const present = new Set(authors.map((a) => key(a))); + return persistedExact + .filter((t) => !present.has(key(t))) + .map((t) => ({ label: t.name, description: 'not in current scope', detail: t.email, term: t })); + }; const rebuild = (value: string): void => { const v = value.trim(); const patternItem: AuthorPickItem[] = @@ -784,7 +805,7 @@ async function pickAuthors(service: GitRepositoryService, logTree: LogFilterCont ? [{ label: `Use pattern "${v}"`, alwaysShow: true, description: 'regular expression (OR-ed)', term: { kind: 'pattern', pattern: v } }] : []; suppressUntil = Date.now() + 100; - qp.items = [...patternItem, ...baseItems(authors)]; + qp.items = [...patternItem, ...baseItems(authors), ...ghostItems()]; qp.selectedItems = qp.items.filter((i) => (i.term.kind === 'exact' ? checked.has(key(i.term)) : patternOn)); }; qp.onDidChangeValue(rebuild); diff --git a/tests/unit/history-commands-filter-wiring.test.ts b/tests/unit/history-commands-filter-wiring.test.ts index 7b0fd3b..0282ab4 100644 --- a/tests/unit/history-commands-filter-wiring.test.ts +++ b/tests/unit/history-commands-filter-wiring.test.ts @@ -19,6 +19,7 @@ vi.mock('vscode', () => ({ showOpenDialog: vi.fn(), showInputBox: vi.fn(), showQuickPick: vi.fn(), + showWarningMessage: vi.fn(), createQuickPick: () => { const qp = { canSelectMany: false, @@ -226,6 +227,26 @@ describe('history-commands 过滤 wiring(真实 Provider 持久化链路)', provider.dispose(); }); + it('pickAuthors 候选集外的持久化 exact 作者:ghost 项预勾选,no-op 确认不丢持久化数据', async () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-a', JSON.stringify([FISH])); + const { provider, openPicker, accept } = makeWiring('/repo-a', memento); + + // shortlog 恒返回空(service.execGit → '')→ FISH 不在候选集,须以 ghost 项呈现且预勾选。 + await openPicker(); + const items = h.qp!.items as ReadonlyArray<{ term: AuthorTerm; description?: string }>; + const ghost = items.find((i) => i.term.kind === 'exact' && i.term.email === FISH.email); + expect(ghost).toBeDefined(); + expect(ghost!.description).toBe('not in current scope'); + expect((h.qp!.selectedItems as ReadonlyArray<{ term: AuthorTerm }>).some((i) => i.term === ghost!.term)).toBe(true); + + // no-op 确认(按当前预勾选原样 settle)不丢持久化 authors。 + accept(h.qp!.selectedItems as ReadonlyArray<{ term: AuthorTerm }>); + expect(provider.getFilter()).toEqual({ authors: [FISH] }); + expect(memento.get('hyperGit.log.authors:/repo-a')).toBe(JSON.stringify([FISH])); + provider.dispose(); + }); + it('logFilterPath 正常路径:所选文件按当前仓库求相对路径,增量叠加不覆盖 authors', async () => { const memento = new MemMemento(); memento.update('hyperGit.log.authors:/repo-a', JSON.stringify([FISH])); @@ -260,6 +281,34 @@ describe('history-commands 过滤 wiring(真实 Provider 持久化链路)', provider.dispose(); }); + it('logFilterPath 越仓守卫:所选文件在当前仓库外,告警放弃,不写内存与持久化 key', async () => { + const memento = new MemMemento(); + const { provider, filterPathWithDialog } = makeWiring('/repo-a', memento); + const warn = vi.mocked(vscode.window.showWarningMessage); + warn.mockClear(); + + // path.relative('/repo-a', '/outside/src/a.ts') = '../outside/src/a.ts'(越仓垃圾 path)。 + await filterPathWithDialog('/outside/src/a.ts'); + expect(provider.getFilter()).toEqual({}); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBeUndefined(); + expect(warn).toHaveBeenCalledWith('This file is outside the current repository; cannot filter by path'); + provider.dispose(); + }); + + it('logFilterPath 仓内 .. 前缀文件不误杀:按路径段判定越仓,仅 ../ 前缀拒绝', async () => { + const memento = new MemMemento(); + const { provider, filterPathWithDialog } = makeWiring('/repo-a', memento); + const warn = vi.mocked(vscode.window.showWarningMessage); + warn.mockClear(); + + // '..drafts/note.md' 是仓内合法文件名('..' 仅作前缀,非路径段),应正常写入 path 过滤。 + await filterPathWithDialog('/repo-a/..drafts/note.md'); + expect(provider.getFilter()).toEqual({ path: '..drafts/note.md' }); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBe(JSON.stringify({ path: '..drafts/note.md' })); + expect(warn).not.toHaveBeenCalled(); + provider.dispose(); + }); + it('grep 正常路径:确认输入(含 trim)写入 filter 与组合 key', async () => { const memento = new MemMemento(); const { provider } = makeWiring('/repo-a', memento); @@ -300,6 +349,123 @@ describe('history-commands 过滤 wiring(真实 Provider 持久化链路)', provider.dispose(); }); + it('mergeMode 正常路径:选择 merge-only 写入 filter 与组合 key', async () => { + const memento = new MemMemento(); + const { provider } = makeWiring('/repo-a', memento); + const mergeMode = h.handlers.get('hyperGit.logFilterMergeMode') as () => Promise; + + await withPendingDialog( + vi.mocked(vscode.window.showQuickPick), + { label: 'Merge Commits Only', mode: 'merge-only' }, + mergeMode, + ); + expect(provider.getFilter()).toEqual({ mergeMode: 'merge-only' }); + expect(parsePersistedFilter(memento.get('hyperGit.log.filter:/repo-a'))).toEqual({ mergeMode: 'merge-only' }); + provider.dispose(); + }); + + it('date 正常路径:Last 7 Days 写入 dateFrom ≈ now-7d 并经组合 key ISO 往返', async () => { + const memento = new MemMemento(); + const { provider } = makeWiring('/repo-a', memento); + const date = h.handlers.get('hyperGit.logFilterDate') as () => Promise; + + const t0 = Date.now(); + await withPendingDialog(vi.mocked(vscode.window.showQuickPick), { label: 'Last 7 Days', days: 7 }, date); + const t1 = Date.now(); + const f = provider.getFilter(); + expect(f.dateFrom).toBeInstanceOf(Date); + // handler 内部捕获的 now ∈ [t0, t1](同 tick 执行时取等号),dateFrom = now - 7d。 + expect(f.dateFrom!.getTime()).toBeGreaterThanOrEqual(t0 - 7 * 86_400_000); + expect(f.dateFrom!.getTime()).toBeLessThanOrEqual(t1 - 7 * 86_400_000); + expect(parsePersistedFilter(memento.get('hyperGit.log.filter:/repo-a'))).toEqual({ dateFrom: f.dateFrom }); + provider.dispose(); + }); + + it('date 清除分支:Clear Date Filter 移除 dateFrom 并删组合 key', async () => { + const memento = new MemMemento(); + const { provider } = makeWiring('/repo-a', memento); + const date = h.handlers.get('hyperGit.logFilterDate') as () => Promise; + provider.setFilter({ ...provider.getFilter(), dateFrom: new Date('2026-01-01T00:00:00.000Z') }); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBeDefined(); + + await withPendingDialog(vi.mocked(vscode.window.showQuickPick), { label: 'Clear Date Filter', days: 0 }, date); + expect(provider.getFilter()).toEqual({}); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBeUndefined(); + provider.dispose(); + }); + + it('date 切库守卫:选择器期间切库,确认不写入任何仓库的持久化 key', async () => { + const memento = new MemMemento(); + const { provider, service } = makeWiring('/repo-a', memento); + const date = h.handlers.get('hyperGit.logFilterDate') as () => Promise; + + await withPendingDialog( + vi.mocked(vscode.window.showQuickPick), + { label: 'Last 7 Days', days: 7 }, + date, + () => service.fireRepoChange('/repo-b'), + ); + expect(provider.getFilter()).toEqual({}); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBeUndefined(); + expect(memento.get('hyperGit.log.filter:/repo-b')).toBeUndefined(); + provider.dispose(); + }); + + it('regex 正常路径:确认输入(含 trim)写入 filter 与组合 key', async () => { + const memento = new MemMemento(); + const { provider } = makeWiring('/repo-a', memento); + const regex = h.handlers.get('hyperGit.logFilterRegex') as () => Promise; + + await withPendingDialog(vi.mocked(vscode.window.showInputBox), ' ^feat: ', regex); + expect(provider.getFilter()).toEqual({ messageRegex: '^feat:' }); + expect(parsePersistedFilter(memento.get('hyperGit.log.filter:/repo-a'))).toEqual({ messageRegex: '^feat:' }); + provider.dispose(); + }); + + it('regex 清除分支:空输入确认移除 messageRegex 并删组合 key', async () => { + const memento = new MemMemento(); + const { provider } = makeWiring('/repo-a', memento); + const regex = h.handlers.get('hyperGit.logFilterRegex') as () => Promise; + provider.setFilter({ ...provider.getFilter(), messageRegex: '^feat:' }); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBeDefined(); + + // 空白输入经 re && re.trim() 规约为 undefined(清除维度),组合 key 归一化后全空即删。 + await withPendingDialog(vi.mocked(vscode.window.showInputBox), ' ', regex); + expect(provider.getFilter()).toEqual({}); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBeUndefined(); + provider.dispose(); + }); + + it('regex 切库守卫:输入框期间切库,确认不把旧仓库输入写入新仓库持久化 key', async () => { + const memento = new MemMemento(); + const { provider, service } = makeWiring('/repo-a', memento); + const regex = h.handlers.get('hyperGit.logFilterRegex') as () => Promise; + + await withPendingDialog(vi.mocked(vscode.window.showInputBox), '^feat:', regex, () => service.fireRepoChange('/repo-b')); + expect(provider.getFilter()).toEqual({}); + expect(memento.get('hyperGit.log.filter:/repo-a')).toBeUndefined(); + expect(memento.get('hyperGit.log.filter:/repo-b')).toBeUndefined(); + provider.dispose(); + }); + + it('logFilter 聚合入口切库守卫:选择器期间切库,Clear Filter 不删新仓库的持久化 key', async () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.authors:/repo-b', JSON.stringify([BOB])); + const { provider, service } = makeWiring('/repo-a', memento); + const filter = h.handlers.get('hyperGit.logFilter') as () => Promise; + + await withPendingDialog( + vi.mocked(vscode.window.showQuickPick), + { label: 'Clear Filter', description: 'remove all active filters', action: 'hyperGit.logClearFilter' }, + filter, + () => service.fireRepoChange('/repo-b'), + ); + // 守卫生效:B 的持久化 authors 未被清(无守卫时 clearFilter 会删 hyperGit.log.authors:/repo-b)。 + expect(memento.get('hyperGit.log.authors:/repo-b')).toBe(JSON.stringify([BOB])); + expect(provider.getFilter()).toEqual({ authors: [BOB] }); + provider.dispose(); + }); + it('commit 文件菜单切库守卫:菜单期间切库,不派发携带旧仓库 relativePath 的动作', async () => { const exec = vi.mocked(vscode.commands.executeCommand); exec.mockClear(); From 56a31d1a4ef5c96b2888f3838a3258f872e6d303 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Sat, 24 Oct 2026 06:55:43 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat(Log):=20=E8=BF=87=E6=BB=A4=E6=BF=80?= =?UTF-8?q?=E6=B4=BB=E6=80=81=E5=8F=AF=E8=A7=81=E6=8C=87=E7=A4=BA=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=A0=87=E9=A2=98=E6=A0=8F=E8=BF=87=E6=BB=A4=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E5=B8=B8=E6=80=81/=E5=A1=AB=E5=85=85=E5=8F=8C?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E4=BA=92=E6=96=A5=EF=BC=8CfilterActive=20con?= =?UTF-8?q?text=20key=20=E4=B8=89=E5=A4=84=E5=90=8C=E6=AD=A5;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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 --- CHANGELOG.md | 2 +- docs/.agents/issue.md | 2 +- package.json | 17 ++++++- src/adapter/history-commands.ts | 46 +++++++++-------- src/adapter/webview/log-webview.ts | 4 ++ src/engine/log/log-filter.ts | 12 +++++ tests/unit/codicon-validity.test.ts | 1 + tests/unit/log-filter.test.ts | 18 +++++++ tests/unit/log-webview-filter-persist.test.ts | 51 +++++++++++++++++++ 9 files changed, 128 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 151e702..339509a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Fixed -- **Graph 视图过滤条件不再随重启 / 切库丢失(全部 7 个维度按仓库持久化)**:authors、File Path、Grep、Merge Mode、Date Filter、Message Regex 此前仅存于内存,重启 VS Code 或切换仓库后即被清空。现在沿用 scope/dmode 的按仓库 workspaceState 范式持久化——authors 独立 key(`hyperGit.log.authors:`),其余 6 维组合 key(`hyperGit.log.filter:`,单 key 原子读写防半恢复态,dateFrom/dateTo 经 ISO 串往返),重启与切库往返后自动恢复为上次设置;重开「Filter by Author…」即预勾选已选 exact 作者并继续增删(pattern 项不参与预勾选、重开后不可见,确认新选择会将其移除);空选确认与 Clear Filter 的「清除」同样持久化,重启不复活;脏持久化数据经 engine 纯函数逐字段校验免疫。**附带行为变更**:① SCM 变更列表右键「Show History」由整替过滤器改为与其余过滤命令一致的增量式——文件历史现与活跃过滤相交,而非静默清空后者(M3 期遗留不一致);② 任何过滤对话框(Filter Graph 聚合入口 / Author / Path / Grep / Merge Mode / Date / Message Regex)或 Commit 文件右键菜单打开期间发生切库后确认,不再把旧仓库语境的选择误写新仓库(rootAtStart 切库守卫全覆盖);③ issue #107 的「切库清空 host 级过滤」限制消除——每个仓库记忆自己的完整过滤条件;④ Filter by Path 选中当前仓库外的文件(多根工作区选他仓文件 / 单仓导航到仓外)时以告警拒绝,不再落入 `../…` 垃圾 path 过滤——该垃圾 path 会随持久化成为跨重启的图加载 fatal 错误态。 +- **Graph 视图过滤条件不再随重启 / 切库丢失(全部 7 个维度按仓库持久化)**:authors、File Path、Grep、Merge Mode、Date Filter、Message Regex 此前仅存于内存,重启 VS Code 或切换仓库后即被清空。现在沿用 scope/dmode 的按仓库 workspaceState 范式持久化——authors 独立 key(`hyperGit.log.authors:`),其余 6 维组合 key(`hyperGit.log.filter:`,单 key 原子读写防半恢复态,dateFrom/dateTo 经 ISO 串往返),重启与切库往返后自动恢复为上次设置;过滤生效期间标题栏过滤图标点亮为填充态(Graph Filter Active,`$(filter-filled)`,经 `hyperGit.log.filterActive` context key 与常态图标互斥呈现)——恢复的过滤不再是无声约束;重开「Filter by Author…」即预勾选已选 exact 作者并继续增删(pattern 项不参与预勾选、重开后不可见,确认新选择会将其移除);空选确认与 Clear Filter 的「清除」同样持久化,重启不复活;脏持久化数据经 engine 纯函数逐字段校验免疫。**附带行为变更**:① SCM 变更列表右键「Show History」由整替过滤器改为与其余过滤命令一致的增量式——文件历史现与活跃过滤相交,而非静默清空后者(M3 期遗留不一致);② 任何过滤对话框(Filter Graph 聚合入口 / Author / Path / Grep / Merge Mode / Date / Message Regex)或 Commit 文件右键菜单打开期间发生切库后确认,不再把旧仓库语境的选择误写新仓库(rootAtStart 切库守卫全覆盖);③ issue #107 的「切库清空 host 级过滤」限制消除——每个仓库记忆自己的完整过滤条件;④ Filter by Path 选中当前仓库外的文件(多根工作区选他仓文件 / 单仓导航到仓外)时以告警拒绝,不再落入 `../…` 垃圾 path 过滤——该垃圾 path 会随持久化成为跨重启的图加载 fatal 错误态。 ## [0.0.21] - 2026-10-23 — Filter by Author 候选建议 · 多选过滤 · 元字符作者修复 diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 48a63d4..a6f6b55 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -177,6 +177,6 @@ - **表因**:Graph 视图「Filter Graph… → Filter by Author…」多选作者后,重启 VS Code(或切换仓库再切回)过滤被清空,需从头重选。 - **根因**:过滤状态仅存于 `LogWebviewProvider` 内存字段 `private filter: LogFilter = {}`,`setFilter/clearFilter` 只改内存不落盘;切库 handler 还会显式 `this.filter = {}`。同文件的 scope/dmode 早有按仓库 workspaceState 持久化范式(`loadRepoScopedPrefs`),filter 维度从未接入。另有一处硬冲突:`hyperGit.showHistory` 用 `setFilter({ path })` 整替 filter 对象(M3 期遗留,早于其余 5 个过滤命令统一的 `{ ...f, <维度> }` 增量范式),若接入持久化会静默清空已保存的 authors。 -- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构;④ Review 收敛补充:`pickAuthors` settle 增切库守卫(rootAtStart 同范式——环境驱动切库不夺焦点、QuickPick 不隐藏,取数窗口外确认会把旧仓库勾选经 persistAuthors 误写新仓库持久化 key),候选集外的持久化 exact 作者以 ghost 项预勾选呈现(selectedItems 只能来自 items,scope 已切换 / shortlog 失败降级时缺项者确认即被静默丢出持久化 key),`multi-root-repo-selection.md` 已知限制同步新切库语义,wiring 级测试(`history-commands-filter-wiring.test.ts`)钉住 showHistory 增量式与 settle 守卫;同构守卫同步应用于 `logFilterPath`(对话框 await 期间切库会按新仓库 root 求出跨仓库垃圾相对路径——预存竞态,收敛门范围外观察,经用户裁定收口;越仓选择同产 `../…` 垃圾 path 且经持久化升级为跨重启粘性污染——增 rel 路径段级越仓校验(`'..'` / `'../'` 前缀;较 showBlame 的 `startsWith('..')` 不误杀仓内 `..` 前缀文件)+ 告警拦截,wiring 测试补越仓与不误杀用例);⑤ 其余 6 个过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)经用户决策按同范式扩展持久化:组合 key `hyperGit.log.filter:`(单 key 原子读写防半恢复态),dateFrom/dateTo ISO 串往返,engine 层 `serializePersistableFilter/parsePersistedFilter` 逐字段校验(归一化:空串、mergeMode 'all' 不落盘),issue #107 的「切库清空 host 级过滤」限制随之消除(multi-root 文档已知限制同步移除,按仓库状态表与导语同步补录过滤 key);增量 Review 复核后同范式守卫补齐 grep/mergeMode/date/regex 四命令、commit 文件菜单(`handleFileMenu`——菜单 await 期间切库时 change 属旧仓库语境,任何动作不派发)与聚合入口 `logFilter`(其 Clear Filter 项直接执行,await 期间切库会删切换后仓库的持久化 key),全部过滤对话框与 Commit 文件菜单的 await 窗口至此收口(Graph 提交行右键菜单 `handleCommitMenu` 为预存未守卫路径,不在本条收口范围);wiring 测试同步补齐 date/regex(正常 + 切库守卫 + 清除分支)与 mergeMode 正常路径用例。 +- **处理方式**:① `engine/log/log-filter.ts` 新增纯函数 `parsePersistedAuthors`(JSON 串 → `AuthorTerm[]`,元素级剔除脏项,永不抛异常);② `LogWebviewProvider` 新增 `authorsKey(root)`(`hyperGit.log.authors:`,同 scope/dmode 命名范式)与唯一写入点 `persistAuthors`(`setFilter/clearFilter` 汇聚,`update(key, undefined)` 即删 key,空选清除同样持久化);恢复并入 `loadRepoScopedPrefs`(构造期 + 切库共用),切库语义由「全清」改为「清非持久维度 + 恢复新仓库持久值」;③ `showHistory` 改增量式 `setFilter({ ...getFilter(), path })`,与其余过滤命令同构;④ Review 收敛补充:`pickAuthors` settle 增切库守卫(rootAtStart 同范式——环境驱动切库不夺焦点、QuickPick 不隐藏,取数窗口外确认会把旧仓库勾选经 persistAuthors 误写新仓库持久化 key),候选集外的持久化 exact 作者以 ghost 项预勾选呈现(selectedItems 只能来自 items,scope 已切换 / shortlog 失败降级时缺项者确认即被静默丢出持久化 key),`multi-root-repo-selection.md` 已知限制同步新切库语义,wiring 级测试(`history-commands-filter-wiring.test.ts`)钉住 showHistory 增量式与 settle 守卫;同构守卫同步应用于 `logFilterPath`(对话框 await 期间切库会按新仓库 root 求出跨仓库垃圾相对路径——预存竞态,收敛门范围外观察,经用户裁定收口;越仓选择同产 `../…` 垃圾 path 且经持久化升级为跨重启粘性污染——增 rel 路径段级越仓校验(`'..'` / `'../'` 前缀;较 showBlame 的 `startsWith('..')` 不误杀仓内 `..` 前缀文件)+ 告警拦截,wiring 测试补越仓与不误杀用例);⑤ 其余 6 个过滤维度(path/grep/mergeMode/dateFrom/dateTo/messageRegex)经用户决策按同范式扩展持久化:组合 key `hyperGit.log.filter:`(单 key 原子读写防半恢复态),dateFrom/dateTo ISO 串往返,engine 层 `serializePersistableFilter/parsePersistedFilter` 逐字段校验(归一化:空串、mergeMode 'all' 不落盘),issue #107 的「切库清空 host 级过滤」限制随之消除(multi-root 文档已知限制同步移除,按仓库状态表与导语同步补录过滤 key);增量 Review 复核后同范式守卫补齐 grep/mergeMode/date/regex 四命令、commit 文件菜单(`handleFileMenu`——菜单 await 期间切库时 change 属旧仓库语境,任何动作不派发)与聚合入口 `logFilter`(其 Clear Filter 项直接执行,await 期间切库会删切换后仓库的持久化 key),全部过滤对话框与 Commit 文件菜单的 await 窗口至此收口(Graph 提交行右键菜单 `handleCommitMenu` 为预存未守卫路径,不在本条收口范围);wiring 测试同步补齐 date/regex(正常 + 切库守卫 + 清除分支)与 mergeMode 正常路径用例;⑥ 经用户决策采纳 Review 改进建议:过滤激活态可见指示——engine 纯函数 `hasActiveFilter`(与持久化归一化同口径:空串 / mergeMode 'all' / 空 authors 不算生效)驱动 `hyperGit.log.filterActive` context key(setFilter/clearFilter/loadRepoScopedPrefs 三处同步),标题栏过滤图标常态(`$(filter)`)/填充(`$(filter-filled)`)双命令互斥呈现(detailTree/detailFlat 同范式,`logFilterOn` 与 `logFilter` 共用聚合 handler)。 - **后续防范**:① 视图 UI 状态凡期望跨会话存活,一律挂到既有的按仓库 memento 范式(key 带 repoRoot、读时校验、写时 undefined 删 key),勿新造存储;② 新增过滤维度命令必须沿用 `{ ...getFilter(), <维度> }` 增量式,整替 filter 会静默摧毁其他维度(可 grep `setFilter({` 检查是否带 spread);③ 持久化读写要过脏数据校验纯函数(engine 层),adapter 只做编排;④ 跨 await 的确认回调(QuickPick settle 等)写仓库语境数据前复核 repoRoot 未变——持久化把瞬态竞态升级为跨重启的粘性污染。 - **同类问题影响**:其余 6 个过滤维度已随后续决策按同范式扩展持久化(见处理方式⑤);所有「内存态 + 仓库语境」的视图组件在接入持久化前都存在同样的重启丢失。 diff --git a/package.json b/package.json index 3f66138..793e0e9 100644 --- a/package.json +++ b/package.json @@ -332,6 +332,12 @@ "category": "Hyper Git", "icon": "$(filter)" }, + { + "command": "hyperGit.logFilterOn", + "title": "Graph Filter Active — Filter Graph…", + "category": "Hyper Git", + "icon": "$(filter-filled)" + }, { "command": "hyperGit.logFilterAuthor", "title": "Filter by Author…", @@ -949,7 +955,12 @@ }, { "command": "hyperGit.logFilter", - "when": "view == hyperGit.log", + "when": "view == hyperGit.log && !hyperGit.log.filterActive", + "group": "navigation@2" + }, + { + "command": "hyperGit.logFilterOn", + "when": "view == hyperGit.log && hyperGit.log.filterActive", "group": "navigation@2" }, { @@ -1436,6 +1447,10 @@ "command": "hyperGit.log.detailFlat", "when": "view == hyperGit.log" }, + { + "command": "hyperGit.logFilterOn", + "when": "view == hyperGit.log" + }, { "command": "hyperGit.commit.detailTree", "when": "view == hyperGit.commit" diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index c7ae759..9acdc52 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -59,28 +59,30 @@ export function registerHistoryCommands( subs.push(vscode.commands.registerCommand('hyperGit.logClearFilter', () => logTree.clearFilter())); - subs.push( - vscode.commands.registerCommand('hyperGit.logFilter', async () => { - // 过滤器聚合入口:QuickPick 分发到既有 6 个过滤命令(复用 handler,零重复实现)。 - // 切库竞态守卫(其余对话框同范式):await 期间切库即放弃——Clear Filter 项直接执行, - // 无守卫会删切换后仓库的持久化 key;过滤子命令也应在新仓库语境下重新发起。 - const rootAtStart = service.repoRoot; - const items = [ - { label: 'Filter by Author…', description: 'commits by author', action: 'hyperGit.logFilterAuthor' }, - { label: 'Filter by Path…', description: 'commits touching a path', action: 'hyperGit.logFilterPath' }, - { label: 'Filter by Message (grep)…', description: 'substring match', action: 'hyperGit.logFilterGrep' }, - { label: 'Filter by Message (regex)…', description: 'regular expression', action: 'hyperGit.logFilterRegex' }, - { label: 'Filter Merge Commits…', description: 'show or hide merge commits', action: 'hyperGit.logFilterMergeMode' }, - { label: 'Filter by Date…', description: 'commits within recent days', action: 'hyperGit.logFilterDate' }, - { label: 'Clear Filter', description: 'remove all active filters', action: 'hyperGit.logClearFilter' }, - ]; - const pick = await vscode.window.showQuickPick(items, { placeHolder: 'Filter commit log' }); - if (!pick || service.repoRoot !== rootAtStart) { - return; - } - await vscode.commands.executeCommand(pick.action); - }), - ); + // 过滤器聚合入口:QuickPick 分发到既有 6 个过滤命令(复用 handler,零重复实现)。 + // 切库竞态守卫(其余对话框同范式):await 期间切库即放弃——Clear Filter 项直接执行, + // 无守卫会删切换后仓库的持久化 key;过滤子命令也应在新仓库语境下重新发起。 + // 双命令(logFilter/logFilterOn,detailTree/detailFlat 同范式):常态与「过滤激活」图标经 + // hyperGit.log.filterActive context key 互斥呈现——持久化恢复的过滤不再是无声约束。 + const openFilterPicker = async (): Promise => { + const rootAtStart = service.repoRoot; + const items = [ + { label: 'Filter by Author…', description: 'commits by author', action: 'hyperGit.logFilterAuthor' }, + { label: 'Filter by Path…', description: 'commits touching a path', action: 'hyperGit.logFilterPath' }, + { label: 'Filter by Message (grep)…', description: 'substring match', action: 'hyperGit.logFilterGrep' }, + { label: 'Filter by Message (regex)…', description: 'regular expression', action: 'hyperGit.logFilterRegex' }, + { label: 'Filter Merge Commits…', description: 'show or hide merge commits', action: 'hyperGit.logFilterMergeMode' }, + { label: 'Filter by Date…', description: 'commits within recent days', action: 'hyperGit.logFilterDate' }, + { label: 'Clear Filter', description: 'remove all active filters', action: 'hyperGit.logClearFilter' }, + ]; + const pick = await vscode.window.showQuickPick(items, { placeHolder: 'Filter commit log' }); + if (!pick || service.repoRoot !== rootAtStart) { + return; + } + await vscode.commands.executeCommand(pick.action); + }; + subs.push(vscode.commands.registerCommand('hyperGit.logFilter', () => openFilterPicker())); + subs.push(vscode.commands.registerCommand('hyperGit.logFilterOn', () => openFilterPicker())); subs.push( vscode.commands.registerCommand('hyperGit.copyCommitHash', (node: LogNode) => { diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index 5dcb6ab..bc51465 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -3,6 +3,7 @@ import type { GitRepositoryService } from '../git-repository-service'; import { parseNameStatus, statusLabel, parseShortStat } from '../../engine/log/commit-files'; import { applyClientFilters, + hasActiveFilter, parsePersistedAuthors, parsePersistedFilter, serializePersistableFilter, @@ -166,6 +167,7 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter const persisted = root ? parsePersistedFilter(this.workspaceState.get(LogWebviewProvider.filterKey(root))) : undefined; const authors = root ? parsePersistedAuthors(this.workspaceState.get(LogWebviewProvider.authorsKey(root))) : undefined; this.filter = { ...(persisted ?? {}), ...(authors ? { authors } : {}) }; + LogWebviewProvider.setCtx('hyperGit.log.filterActive', hasActiveFilter(this.filter)); } /** 标题栏 Scope 子菜单选中项(hyperGit.log.scopeAll/Current/Checkpointer 命令入口)。 */ @@ -197,6 +199,7 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter this.filter = filter; this.persistFilter(filter); this.persistAuthors(filter.authors); + LogWebviewProvider.setCtx('hyperGit.log.filterActive', hasActiveFilter(filter)); this.refresh(); } @@ -204,6 +207,7 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter this.filter = {}; this.persistFilter(undefined); this.persistAuthors(undefined); + LogWebviewProvider.setCtx('hyperGit.log.filterActive', false); this.refresh(); } diff --git a/src/engine/log/log-filter.ts b/src/engine/log/log-filter.ts index a647e99..8623fc0 100644 --- a/src/engine/log/log-filter.ts +++ b/src/engine/log/log-filter.ts @@ -149,6 +149,18 @@ export function parsePersistedFilter(raw: unknown): LogFilter | undefined { return Object.keys(filter).length ? filter : undefined; } +/** + * 判定过滤器是否存在生效维度(authors 非空,或经 {@link serializePersistableFilter} 归一化后 + * 仍有可持久化维度)——归一化口径与持久化一致(空串 / mergeMode 'all' / 空 authors 均不算生效), + * 驱动标题栏过滤图标激活态(hyperGit.log.filterActive context key)。 + */ +export function hasActiveFilter(filter: LogFilter | undefined): boolean { + if (!filter) { + return false; + } + return (filter.authors?.length ?? 0) > 0 || serializePersistableFilter(filter) !== undefined; +} + /** BRE 特殊字符集:git `--author` 缺省 POSIX BRE(非 ERE),仅 `\ . [ * ^ $` 有特殊义; * `+ ? | ( ) {` 在 BRE 中是普通字符,加反斜杠反成量词/分组操作符(实测 `\+` 使含 `+` 的邮箱归零命中),勿扩大转义集。 */ const BRE_SPECIAL = /[.*[\]^$\\]/g; diff --git a/tests/unit/codicon-validity.test.ts b/tests/unit/codicon-validity.test.ts index 7ec09f3..2a151be 100644 --- a/tests/unit/codicon-validity.test.ts +++ b/tests/unit/codicon-validity.test.ts @@ -28,6 +28,7 @@ const VALID_CODICONS: ReadonlySet = new Set([ 'edit', 'eye', 'filter', + 'filter-filled', 'folder-opened', 'gear', 'git-branch', diff --git a/tests/unit/log-filter.test.ts b/tests/unit/log-filter.test.ts index 700d9f3..4c69b01 100644 --- a/tests/unit/log-filter.test.ts +++ b/tests/unit/log-filter.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { CHECKPOINT_SUBJECT_RE, applyClientFilters, + hasActiveFilter, isCheckpointSubject, parsePersistedAuthors, parsePersistedFilter, @@ -215,3 +216,20 @@ describe('serializePersistableFilter / parsePersistedFilter', () => { expect(parsePersistedFilter(JSON.stringify({ grep: '', mergeMode: 'x', dateFrom: 'oops' }))).toBeUndefined(); }); }); + +describe('hasActiveFilter', () => { + it('未激活口径与持久化归一化一致:undefined / 空对象 / 空串 / mergeMode all / 空 authors', () => { + expect(hasActiveFilter(undefined)).toBe(false); + expect(hasActiveFilter({})).toBe(false); + expect(hasActiveFilter({ path: '', grep: undefined, mergeMode: 'all' })).toBe(false); + expect(hasActiveFilter({ authors: [] })).toBe(false); + }); + + it('任一维度生效即激活:authors(含 pattern)/ path / mergeMode / date / regex', () => { + expect(hasActiveFilter({ authors: [{ kind: 'pattern', pattern: 'x' }] })).toBe(true); + expect(hasActiveFilter({ path: 'src/a.ts' })).toBe(true); + expect(hasActiveFilter({ mergeMode: 'no-merge' })).toBe(true); + expect(hasActiveFilter({ dateFrom: D('2026-01-01T00:00:00.000Z') })).toBe(true); + expect(hasActiveFilter({ messageRegex: '^feat:' })).toBe(true); + }); +}); diff --git a/tests/unit/log-webview-filter-persist.test.ts b/tests/unit/log-webview-filter-persist.test.ts index 85b91b8..b792d8f 100644 --- a/tests/unit/log-webview-filter-persist.test.ts +++ b/tests/unit/log-webview-filter-persist.test.ts @@ -5,6 +5,7 @@ vi.mock('vscode', () => ({ commands: { executeCommand: vi.fn(() => Promise.resolve()) }, })); +import * as vscode from 'vscode'; import type { Memento } from 'vscode'; import type { GitRepositoryService } from '../../src/adapter/git-repository-service'; import type { GitHubCiService } from '../../src/adapter/ci/github-ci-service'; @@ -201,3 +202,53 @@ describe('LogWebviewProvider 非 authors 过滤维度持久化', () => { provider.dispose(); }); }); + +describe('LogWebviewProvider 过滤激活 context key(标题栏图标双命令互斥)', () => { + it('setFilter / clearFilter 同步 hyperGit.log.filterActive', () => { + const memento = new MemMemento(); + const { provider } = makeProvider('/repo-a', memento); + const exec = vi.mocked(vscode.commands.executeCommand); + exec.mockClear(); + + provider.setFilter({ grep: 'fix' }); + expect(exec).toHaveBeenCalledWith('setContext', 'hyperGit.log.filterActive', true); + exec.mockClear(); + provider.clearFilter(); + expect(exec).toHaveBeenCalledWith('setContext', 'hyperGit.log.filterActive', false); + provider.dispose(); + }); + + it('构造期恢复持久过滤即激活(重启后图标点亮)', () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.filter:/repo-a', JSON.stringify({ grep: 'fix' })); + const { provider } = makeProvider('/repo-a', memento); + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith('setContext', 'hyperGit.log.filterActive', true); + provider.dispose(); + }); + + it('切库重载按新仓库过滤态同步(B 无持久过滤 → false,往返切回 → true)', () => { + const memento = new MemMemento(); + memento.update('hyperGit.log.filter:/repo-a', JSON.stringify({ grep: 'fix' })); + const { provider, service } = makeProvider('/repo-a', memento); + const exec = vi.mocked(vscode.commands.executeCommand); + exec.mockClear(); + + service.fireRepoChange('/repo-b'); + expect(exec).toHaveBeenCalledWith('setContext', 'hyperGit.log.filterActive', false); + exec.mockClear(); + service.fireRepoChange('/repo-a'); + expect(exec).toHaveBeenCalledWith('setContext', 'hyperGit.log.filterActive', true); + provider.dispose(); + }); + + it('归一化未激活态不点亮:空串 / mergeMode all / 空 authors 恢复后仍为 false', () => { + const memento = new MemMemento(); + const { provider } = makeProvider('/repo-a', memento); + const exec = vi.mocked(vscode.commands.executeCommand); + exec.mockClear(); + + provider.setFilter({ path: '', mergeMode: 'all', authors: [] }); + expect(exec).toHaveBeenCalledWith('setContext', 'hyperGit.log.filterActive', false); + provider.dispose(); + }); +});