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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/adapter/webview/log-webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,15 +214,16 @@ 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,
parents: r.parents,
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);
Expand Down Expand Up @@ -380,6 +381,7 @@ body { margin: 0; font-family: var(--vscode-font-family); font-size: var(--vscod
<span class="seg">
<button id="scope-all" class="active">All</button>
<button id="scope-current">Current</button>
<button id="scope-checkpointer">Checkpointer</button>
</span>
<span class="repo" id="repo"></span>
</div>
Expand All @@ -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');
Expand Down Expand Up @@ -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 } });
Expand Down Expand Up @@ -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); }
Expand Down
24 changes: 24 additions & 0 deletions src/engine/log/log-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -65,9 +71,27 @@ export function safeRegex(pattern: string): RegExp | undefined {
}
}

/**
* Conductor 等 Agent 工具自动创建的「状态快照」提交前缀(LangGraph Checkpointer 语义)。
* 已知模式:`checkpoint:session-<uuid>-turn-<uuid>-start/end`、`checkpoint:conductor-archive-<uuid>`、
* `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<T extends FilterableCommit>(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') {
Expand Down
13 changes: 10 additions & 3 deletions src/engine/log/log-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}`);
Expand Down
47 changes: 46 additions & 1 deletion tests/unit/log-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand All @@ -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' };
Expand Down Expand Up @@ -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/);
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/log-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading