diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index 85c4891..dee8213 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -214,7 +214,8 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter if (raws.length === 0) { return { rows: [], maxLanes: 0, hasMore: false }; } - // 客户端过滤(mergeMode / date / regex),message 近似取 subject。 + // 客户端过滤(mergeMode / date / regex / checkpoint),message 近似取 subject。 + // keepCheckpoint 由 scope 驱动:仅 Checkpointer 视图保留 checkpoint 自动提交,All/Current 剔除。 const filterable = raws.map((r) => ({ message: r.subject, authorDate: r.authorDate ? new Date(r.authorDate) : undefined, @@ -222,7 +223,7 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter hash: r.hash, raw: r, })); - const survived = applyClientFilters(filterable, toClientFilter(this.filter)); + const survived = applyClientFilters(filterable, { ...toClientFilter(this.filter), keepCheckpoint: this.scope === 'checkpointer' }); const layout = computeGraphLayout(survived.map((s) => ({ hash: s.hash, parents: s.parents }))); const hashSet = new Set(survived.map((s) => s.hash)); const chips = await this.fetchChips(hashSet); @@ -380,6 +381,7 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod + @@ -393,9 +395,11 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod const vscode = acquireVsCodeApi(); const PALETTE = ${palette}; const ROW_H = 24, LANE_W = 14, NODE_R = 4, GUTTER = 10, OVERSCAN = 8, LOAD_THRESHOLD = 40; +/** scope 白名单兜底:仅接受三态,否则回退默认 'all'(兼容未来废弃的持久化值)。 */ +function normalizeScope(v) { return v === 'all' || v === 'current' || v === 'checkpointer' ? v : 'all'; } const persisted = vscode.getState() || {}; let selectedHash = persisted.selectedHash || null; -let scope = persisted.scope || 'all'; +let scope = normalizeScope(persisted.scope); let model = { rows: [], maxLanes: 0, hasMore: false, repoRoot: '' }; let renderedFirst = -1, renderedLast = -1, fetching = false; const viewport = document.getElementById('viewport'); @@ -473,6 +477,7 @@ function render() { emptyEl.style.display = total === 0 ? 'block' : 'none'; document.getElementById('scope-all').classList.toggle('active', scope === 'all'); document.getElementById('scope-current').classList.toggle('active', scope === 'current'); + document.getElementById('scope-checkpointer').classList.toggle('active', scope === 'checkpointer'); if (model.hasMore && !fetching && l >= total - LOAD_THRESHOLD) { fetching = true; spinnerEl.style.display = 'block'; vscode.postMessage({ type: 'log/loadMore', payload: { cursor: total } }); @@ -516,8 +521,10 @@ rowsEl.addEventListener('contextmenu', function (e) { e.preventDefault(); vscode.postMessage({ type: 'log/commitAction', payload: { op: 'menu', hash: r.getAttribute('data-hash') } }); }); -document.getElementById('scope-all').addEventListener('click', function () { if (scope !== 'all') { scope = 'all'; vscode.setState({ selectedHash: selectedHash, scope: scope }); vscode.postMessage({ type: 'log/setScope', payload: { scope: 'all' } }); } }); -document.getElementById('scope-current').addEventListener('click', function () { if (scope !== 'current') { scope = 'current'; vscode.setState({ selectedHash: selectedHash, scope: scope }); vscode.postMessage({ type: 'log/setScope', payload: { scope: 'current' } }); } }); +function setScope(next) { if (scope !== next) { scope = next; vscode.setState({ selectedHash: selectedHash, scope: scope }); vscode.postMessage({ type: 'log/setScope', payload: { scope: next } }); } } +document.getElementById('scope-all').addEventListener('click', function () { setScope('all'); }); +document.getElementById('scope-current').addEventListener('click', function () { setScope('current'); }); +document.getElementById('scope-checkpointer').addEventListener('click', function () { setScope('checkpointer'); }); viewport.addEventListener('scroll', scheduleRender, { passive: true }); viewport.addEventListener('keydown', function (e) { if (e.key === 'ArrowDown') { e.preventDefault(); moveSel(1); } diff --git a/src/engine/log/log-filter.ts b/src/engine/log/log-filter.ts index 2734c73..5d6456d 100644 --- a/src/engine/log/log-filter.ts +++ b/src/engine/log/log-filter.ts @@ -16,6 +16,12 @@ export interface LogClientFilter { /** 截止日期(含),按 authorDate 过滤。 */ readonly dateTo?: Date; readonly messageRegex?: RegExp; + /** + * 是否保留 Conductor 等 Agent 工具自动创建的 checkpoint 提交(subject 以 `checkpoint:` 开头,见 + * {@link isCheckpointSubject})。`false` 时剔除;缺省或 `true` 时保留。由 Log 视图 scope 驱动 + * (Checkpointer 视图保留完整历史,All/Current 剔除以呈现干净的人类提交历史)。 + */ + readonly keepCheckpoint?: boolean; } /** @@ -65,9 +71,27 @@ export function safeRegex(pattern: string): RegExp | undefined { } } +/** + * Conductor 等 Agent 工具自动创建的「状态快照」提交前缀(LangGraph Checkpointer 语义)。 + * 已知模式:`checkpoint:session--turn--start/end`、`checkpoint:conductor-archive-`、 + * `checkpoint:conductor-getdiff`。锚定 subject 行首、大小写不敏感;集中于此为单一事实源, + * adapter / 测试一律引用本常量或谓词,杜绝识别规则分散。 + */ +export const CHECKPOINT_SUBJECT_RE = /^checkpoint:/i; + +/** 判定提交 subject 是否为 checkpoint 自动提交({@link CHECKPOINT_SUBJECT_RE} 的谓词封装)。 */ +export function isCheckpointSubject(subject: string): boolean { + return CHECKPOINT_SUBJECT_RE.test(subject); +} + /** 对已取回的提交施加客户端过滤(不可变,返回新数组)。 */ export function applyClientFilters(commits: readonly T[], filter: LogClientFilter): T[] { let result: T[] = [...commits]; + // checkpoint 剔除置链首:其高密度特性可减少后续 mergeMode/date/regex 的计算量; + // 与其他维度同为 AND 语义、可交换,顺序不影响结果正确性。 + if (filter.keepCheckpoint === false) { + result = result.filter((c) => !isCheckpointSubject(c.message)); + } if (filter.mergeMode === 'merge-only') { result = result.filter((c) => c.parents.length > 1); } else if (filter.mergeMode === 'no-merge') { diff --git a/src/engine/log/log-query.ts b/src/engine/log/log-query.ts index 2175127..6134437 100644 --- a/src/engine/log/log-query.ts +++ b/src/engine/log/log-query.ts @@ -10,8 +10,15 @@ import type { LogFilter } from './log-filter'; import { LOG_GRAPH_FORMAT } from './log-line'; -/** 提交范围:`all` = 全部分支(`--all`,IDEA 默认);`current` = 当前分支(HEAD)。 */ -export type LogScope = 'all' | 'current'; +/** + * 提交范围(工具栏互斥单选): + * - `all` = 全分支(`--all`),剔除 checkpoint 自动提交(干净的人类历史,默认); + * - `current` = 当前分支(HEAD),剔除 checkpoint 自动提交; + * - `checkpointer` = 全分支(`--all`),保留 checkpoint 自动提交(原始完整视图)。 + * checkpoint 的保留/剔除由 adapter 层据 scope 注入 `LogClientFilter.keepCheckpoint`, + * engine query 层仅据本类型决定 `--all` 分支范围,对 checkpoint 概念无感知(职责单一)。 + */ +export type LogScope = 'all' | 'current' | 'checkpointer'; /** 分页参数。 */ export interface LogQueryOptions { @@ -26,7 +33,7 @@ export interface LogQueryOptions { */ export function buildLogArgs(filter: LogFilter | undefined, scope: LogScope, opts: LogQueryOptions): string[] { const args: string[] = ['--topo-order']; - if (scope === 'all') { + if (scope === 'all' || scope === 'checkpointer') { args.push('--all'); } args.push(`--max-count=${opts.maxCount}`); diff --git a/tests/unit/log-filter.test.ts b/tests/unit/log-filter.test.ts index e48ed06..2952075 100644 --- a/tests/unit/log-filter.test.ts +++ b/tests/unit/log-filter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { applyClientFilters, safeRegex, type FilterableCommit, type LogClientFilter } from '../../src/engine/log/log-filter'; +import { CHECKPOINT_SUBJECT_RE, applyClientFilters, isCheckpointSubject, 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 }); @@ -11,6 +11,13 @@ const COMMITS: FilterableCommit[] = [ C('docs: readme', ['p1', 'p2', 'p3'], D('2026-07-01')), ]; +/** 含 Conductor 自动 checkpoint 提交的样本(用于 keepCheckpoint 维度测试,与 COMMITS 隔离以保护既有断言)。 */ +const COMMITS_WITH_CKPT: FilterableCommit[] = [ + ...COMMITS, + C('checkpoint:session-x-turn-y-start', ['p1'], D('2026-06-05')), + C('checkpoint:conductor-archive-uuid', ['p1'], D('2026-06-15')), +]; + describe('log-filter', () => { it('merge-only 仅保留 parents>1', () => { const f: LogClientFilter = { mergeMode: 'merge-only' }; @@ -58,6 +65,44 @@ describe('log-filter', () => { }); }); +describe('isCheckpointSubject / CHECKPOINT_SUBJECT_RE', () => { + it('识别 session checkpoint 起/止', () => { + expect(isCheckpointSubject('checkpoint:session-abc-turn-def-start')).toBe(true); + expect(isCheckpointSubject('checkpoint:session-abc-turn-def-end')).toBe(true); + }); + it('识别 conductor-archive / conductor-getdiff', () => { + expect(isCheckpointSubject('checkpoint:conductor-archive-uuid')).toBe(true); + expect(isCheckpointSubject('checkpoint:conductor-getdiff')).toBe(true); + }); + it('不误伤非 checkpoint 前缀(含正文出现 checkpoint 字样)', () => { + expect(isCheckpointSubject('docs: checkpoint notes')).toBe(false); + expect(isCheckpointSubject('feat: add checkpoint api')).toBe(false); + expect(isCheckpointSubject('')).toBe(false); + }); + it('大小写不敏感(容错 Agent 工具配置差异)', () => { + expect(isCheckpointSubject('Checkpoint:session-x')).toBe(true); + expect(CHECKPOINT_SUBJECT_RE.test('CHECKPOINT:foo')).toBe(true); + }); +}); + +describe('applyClientFilters — keepCheckpoint 维度', () => { + it('keepCheckpoint=false 剔除 checkpoint 提交(保留正常提交)', () => { + const out = applyClientFilters(COMMITS_WITH_CKPT, { keepCheckpoint: false }).map((c) => c.message); + expect(out).toEqual(['feat: a', 'merge: branch', 'fix: bug [urgent]', 'docs: readme']); + }); + it('keepCheckpoint=undefined 不过滤 checkpoint(向后兼容)', () => { + expect(applyClientFilters(COMMITS_WITH_CKPT, {})).toHaveLength(6); + }); + it('keepCheckpoint=true 保留 checkpoint', () => { + expect(applyClientFilters(COMMITS_WITH_CKPT, { keepCheckpoint: true })).toHaveLength(6); + }); + it('组合:keepCheckpoint=false + no-merge + dateFrom(顺序正交)', () => { + const f: LogClientFilter = { keepCheckpoint: false, mergeMode: 'no-merge', dateFrom: D('2026-06-15') }; + // 剔 checkpoint(2 条) + 剔 merge(docs) + 剔早于 06-15(feat:a) → 仅 fix: bug [urgent] + expect(applyClientFilters(COMMITS_WITH_CKPT, f).map((c) => c.message)).toEqual(['fix: bug [urgent]']); + }); +}); + describe('safeRegex', () => { it('合法模式返回 RegExp', () => { expect(safeRegex('feat')).toEqual(/feat/); diff --git a/tests/unit/log-query.test.ts b/tests/unit/log-query.test.ts index 90e02fc..5a8407b 100644 --- a/tests/unit/log-query.test.ts +++ b/tests/unit/log-query.test.ts @@ -14,6 +14,10 @@ describe('buildLogArgs — 顺序与必选项', () => { expect(buildLogArgs(noFilter, 'current', { maxCount: 300 })).not.toContain('--all'); }); + it('scope=checkpointer 与 all 同为全分支(含 --all)', () => { + expect(buildLogArgs(noFilter, 'checkpointer', { maxCount: 300 })).toContain('--all'); + }); + it('max-count 与 skip', () => { expect(buildLogArgs(noFilter, 'all', { maxCount: 500 })).toContain('--max-count=500'); expect(buildLogArgs(noFilter, 'all', { maxCount: 500, skip: 500 })).toContain('--skip=500');