diff --git a/CHANGELOG.md b/CHANGELOG.md index 318420d..339509a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - **Branches 视图分支就地更新(不 checkout)**:右键单个分支 **Update Branch**、多选批量更新、标题栏 Pull 按钮升级为选区感知(无选区保持原 Pull 语义,Commit 视图 Pull 不受影响)。底层原语 `git fetch refs/heads/:refs/heads/`——git 原生 fast-forward-only,diverged / ahead-only 分支被安全拒绝(附真实 stderr 原因),**永不改写本地提交**(src 侧全限定防 `+` 前缀上游名被解析为 force);当前分支自动改走 `repo.pull()`(含冲突处理通道);无上游分支透明跳过;无 remote 配置提示 `No remote repository configured` 提前返回(不误报 no upstream)。五桶对账汇总(updated / already up to date / pulled / skipped / failed,选中数 = 各桶之和),up-to-date 经前后 `objectname` 短 sha 前缀比对判定(fetch 静默 no-op 无输出;缩写长度随对象数伸缩,全等比对会误报)。upstream 解析不信任树节点数据(API 路径 `refToRaw` 不填 upstream/head),统一以一次 `for-each-ref` 取新鲜快照;`origin/x` 拆分复用 `resolveRemoteBranch` 最长前缀匹配(remote 名可含 `/`)。engine 纯逻辑 `planBranchUpdates` / `formatBranchUpdateResult` / `classifyFetchOutcome` 落于 `engine/ref/branch-update.ts`,全量单测覆盖。 +### 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 串往返),重启与切库往返后自动恢复为上次设置;过滤生效期间标题栏过滤图标点亮为填充态(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 候选建议 · 多选过滤 · 元字符作者修复 ### Added diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index 465848f..a6f6b55 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 })`,与其余过滤命令同构;④ 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/docs/features/multi-root-repo-selection.md b/docs/features/multi-root-repo-selection.md index 0c51898..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 勾选集/视图模式 | 同上 | 同上(勾选是相对路径集合,跨仓库本就错位) | @@ -69,6 +70,5 @@ ## 已知限制 -- 仓库切换时 Graph 的 host 级过滤条件(author/path/grep 等)清空——过滤条件是旧仓库语境的产物,跨仓库无意义; - 多仓库历史混仓 Shelf 数据归属迁移时的当前活跃仓库(无法按仓库归因); - CI 状态按 commit hash 缓存(跨仓库理论碰撞概率可忽略,行为正确)。 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 883b7e1..9acdc52 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -36,10 +36,21 @@ 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('/'); + // 越仓选择(多根他仓文件 / 单仓外导航)同样产出 '../…' 垃圾 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 }); } @@ -48,24 +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,零重复实现)。 - 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) { - 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) => { @@ -79,7 +96,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'); }), @@ -679,7 +697,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 }); }), @@ -687,6 +711,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 }, @@ -695,7 +720,7 @@ export function registerHistoryCommands( ], { placeHolder: 'Merge commit filter mode' }, ); - if (!pick) { + if (!pick || service.repoRoot !== rootAtStart) { return; } const f = logTree.getFilter(); @@ -705,6 +730,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( [ @@ -715,7 +741,7 @@ export function registerHistoryCommands( ], { placeHolder: 'Commit date range' }, ); - if (!pick) { + if (!pick || service.repoRoot !== rootAtStart) { return; } const f = logTree.getFilter(); @@ -725,7 +751,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 }); }), @@ -757,10 +787,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[] = @@ -768,7 +807,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); @@ -781,6 +820,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) { @@ -788,16 +830,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/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 929977f..bc51465 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -1,7 +1,16 @@ 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, + hasActiveFilter, + parsePersistedAuthors, + parsePersistedFilter, + serializePersistableFilter, + 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 +119,14 @@ 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}`; + } + /** 非 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); @@ -128,25 +145,29 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter t = setTimeout(() => this.refresh(), 400); }), ); - // 激活即恢复当前仓库的 scope/dmode 记忆并同步 context key(标题栏控件先于视图解析渲染)。 + // 激活即恢复当前仓库的 scope/dmode/过滤记忆并同步 context key(标题栏控件先于视图解析渲染)。 this.loadRepoScopedPrefs(); - // 活跃仓库切换(issue #107):host 级过滤条件是旧仓库语境的产物,跨仓库无意义 → 清空; - // scope/dmode 恢复该仓库的记忆值(无记忆回退 'all'/'flat');图数据重拉由上方 onDidChange 订阅驱动。 + // 活跃仓库切换(issue #107):过滤条件是旧仓库语境的产物,跨仓库无意义 → 整体重载为 + // 新仓库的持久过滤(无记忆回退不过滤);图数据重拉由上方 onDidChange 订阅驱动 + // (service 契约保证本事件先于 onDidChange fire)。 this.disposables.push( service.onDidChangeRepository(() => { - this.filter = {}; this.loadRepoScopedPrefs(); }), ); } - /** 装载当前仓库的 scope / dmode 偏好(memento → 内存 + context key)。 */ + /** 装载当前仓库的 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 = { ...(persisted ?? {}), ...(authors ? { authors } : {}) }; + LogWebviewProvider.setCtx('hyperGit.log.filterActive', hasActiveFilter(this.filter)); } /** 标题栏 Scope 子菜单选中项(hyperGit.log.scopeAll/Current/Checkpointer 命令入口)。 */ @@ -176,14 +197,42 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter setFilter(filter: LogFilter): void { this.filter = filter; + this.persistFilter(filter); + this.persistAuthors(filter.authors); + LogWebviewProvider.setCtx('hyperGit.log.filterActive', hasActiveFilter(filter)); this.refresh(); } clearFilter(): void { this.filter = {}; + this.persistFilter(undefined); + this.persistAuthors(undefined); + LogWebviewProvider.setCtx('hyperGit.log.filterActive', false); this.refresh(); } + /** + * authors 维度按仓库持久化(写入点汇聚于 setFilter/clearFilter):重启与切库后经 + * {@link loadRepoScopedPrefs} 恢复;repoRoot 未知时仅内存(激活后切库事件自然覆盖)。 + * `update(key, undefined)` 即删除条目 —— 清除同样持久化,重启不复活。 + */ + 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); + } + + /** 非 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 9af9420..8623fc0 100644 --- a/src/engine/log/log-filter.ts +++ b/src/engine/log/log-filter.ts @@ -33,6 +33,134 @@ 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; +} + +/** 非空字符串维度(空串规约为未设置)。 */ +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; +} + +/** + * 判定过滤器是否存在生效维度(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/history-commands-filter-wiring.test.ts b/tests/unit/history-commands-filter-wiring.test.ts new file mode 100644 index 0000000..0282ab4 --- /dev/null +++ b/tests/unit/history-commands-filter-wiring.test.ts @@ -0,0 +1,497 @@ +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: { + showOpenDialog: vi.fn(), + showInputBox: vi.fn(), + showQuickPick: vi.fn(), + showWarningMessage: vi.fn(), + 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 * 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'; +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 { + 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 () => '', + /** 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 { + 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')!(); + }; + /** 触发 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 }; +} + +/** 触发任一「对话框型」命令/流程:对话框挂起期间可注入事件(切库),再以所选值放行。 */ +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(); + 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(); + }); + + 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])); + 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('重启往返后 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])); + 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(); + }); + + 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); + 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('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(); + 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 2952075..4c69b01 100644 --- a/tests/unit/log-filter.test.ts +++ b/tests/unit/log-filter.test.ts @@ -1,5 +1,17 @@ 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, + hasActiveFilter, + 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); const C = (message: string, parents: string[], authorDate?: Date): FilterableCommit => ({ message, parents, authorDate }); @@ -114,3 +126,110 @@ 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(); + }); +}); + +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(); + }); +}); + +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 new file mode 100644 index 0000000..b792d8f --- /dev/null +++ b/tests/unit/log-webview-filter-persist.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('vscode', () => ({ + // 构造路径仅触 setCtx(commands.executeCommand);webview 未 resolve 时 pushState 早退,无需其余 API。 + 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'; +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 { + 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, grep: 'x' }); + 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 + 非 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 与 grep(各自 key 均按仓库存活) + service.fireRepoChange('/repo-a'); + expect(provider.getFilter()).toEqual({ authors: AUTHORS, grep: 'stale' }); + 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(); + }); +}); + +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(); + }); +}); + +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(); + }); +});