Timeline Provider 实现
时间线提供者是时间线视图的数据引擎:它按文件返回有序的历史事件。本文从接口到集成完整实现一个可用的 Timeline Provider。
TimelineProvider 接口
typescript
interface TimelineProvider {
// 提供者 ID(与 contributes.timeline 一致)
readonly id: string;
// 显示名称
readonly label: string;
// 数据变化时触发(参数为需要刷新的文件 URI)
readonly onDidChange: vscode.Event<vscode.Uri | undefined>;
// 核心方法:为文件提供时间线数据
provideTimeline(
uri: vscode.Uri,
options: vscode.TimelineOptions,
token: vscode.CancellationToken
): vscode.ProviderResult<vscode.TimelineResult>;
}provideTimeline 实现
为当前文件加载快照历史并返回:
typescript
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
interface Snapshot {
hash: string;
message: string;
author: string;
timestamp: number;
filePath: string;
}
export class MyTimelineProvider implements vscode.TimelineProvider {
readonly id = 'myscm.history';
readonly label = 'MySCM 历史';
private _onDidChange = new vscode.EventEmitter<vscode.Uri | undefined>();
readonly onDidChange = this._onDidChange.event;
private snapshotDir: string;
constructor(snapshotDir: string) {
this.snapshotDir = snapshotDir;
}
async provideTimeline(
uri: vscode.Uri,
options: vscode.TimelineOptions,
token: vscode.CancellationToken
): Promise<vscode.TimelineResult> {
// 取消检查
if (token.isCancellationRequested) {
return { items: [] };
}
// 1. 加载该文件的历史快照
const snapshots = await this.loadSnapshots(uri);
// 2. 分页
const cursor = options.cursor;
const limit = options.limit ?? 100;
let startIndex = 0;
if (cursor) {
startIndex = snapshots.findIndex((s) => s.hash === cursor);
if (startIndex < 0) startIndex = 0;
}
const page = snapshots.slice(startIndex, startIndex + limit);
// 3. 转换为 TimelineItem 并分组
const items = page.map((s) => this.toTimelineItem(uri, s));
// 4. 分组(按日期)
const groups = this.groupByDay(items);
return {
items,
groups,
cursor: startIndex + limit < snapshots.length
? page[page.length - 1]?.hash
: undefined
};
}
}加载快照
typescript
private async loadSnapshots(uri: vscode.Uri): Promise<Snapshot[]> {
const relPath = uri.fsPath.replace(this.snapshotDir.replace('/snapshots', ''), '');
const snapshotFolder = path.join(this.snapshotDir, relPath);
if (!fs.existsSync(snapshotFolder)) {
return [];
}
const files = fs.readdirSync(snapshotFolder)
.filter((f) => f.endsWith('.json'))
.sort((a, b) => b.localeCompare(a)); // 时间倒序
const snapshots: Snapshot[] = [];
for (const file of files) {
try {
const raw = fs.readFileSync(path.join(snapshotFolder, file), 'utf8');
snapshots.push(JSON.parse(raw));
} catch {
// 跳过损坏的快照
}
}
return snapshots;
}时间线项转换
typescript
private toTimelineItem(uri: vscode.Uri, snapshot: Snapshot): vscode.TimelineItem {
return {
id: snapshot.hash,
label: snapshot.message,
description: `${snapshot.author} · ${formatTime(snapshot.timestamp)}`,
timestamp: snapshot.timestamp,
iconPath: new vscode.ThemeIcon('history'),
contextValue: 'myscm.snapshot',
tooltip: new vscode.MarkdownString(
`**${snapshot.message}**\n\n作者: ${snapshot.author}\n时间: ${new Date(snapshot.timestamp).toLocaleString()}`
),
command: {
command: 'myscm.openSnapshot',
title: '打开快照',
arguments: [snapshot, uri]
}
};
}
function formatTime(ts: number): string {
const d = new Date(ts);
const now = Date.now();
const diff = now - ts;
if (diff < 60 * 1000) return '刚刚';
if (diff < 3600 * 1000) return `${Math.floor(diff / 60000)} 分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
return `${d.getMonth() + 1}月${d.getDate()}日`;
}分组与过滤
按日期分组
typescript
private groupByDay(items: vscode.TimelineItem[]): vscode.TimelineItemGroup[] {
const map = new Map<string, vscode.TimelineItem[]>();
for (const item of items) {
const day = new Date(item.timestamp).toISOString().slice(0, 10);
if (!map.has(day)) map.set(day, []);
map.get(day)!.push(item);
}
return [...map.entries()].map(([day, dayItems]) => ({
id: `group:${day}`,
label: this.formatDay(day),
items: dayItems,
collapsibleState: vscode.TreeItemCollapsibleState.Expanded
}));
}过滤机制
TimelineOptions 中的过滤由 VS Code 的过滤框触发,通过 filters 参数:
typescript
// provideTimeline 增加过滤处理
if (options.filters && options.filters.length > 0) {
// 只返回包含过滤关键词的项
const keyword = options.filters[0].toLowerCase();
const filtered = snapshots.filter((s) =>
s.message.toLowerCase().includes(keyword) ||
s.author.toLowerCase().includes(keyword)
);
snapshots = filtered;
}TimelineOptions 配置
options 提供分页与过滤参数:
| 字段 | 类型 | 说明 |
|---|---|---|
limit | number | 每页最大条数 |
cursor | string | 上次返回的游标(继续分页) |
filters | string[] | 过滤关键词 |
VS Code 会在滚动到底部时自动用上次返回的 cursor 再次调用,实现无限滚动。
数据变更通知
时间线需要在新快照产生后刷新:
typescript
export class SnapshotService {
private provider: MyTimelineProvider;
// 每次创建快照后调用
async commitSnapshot(uri: vscode.Uri, message: string) {
await createSnapshot(uri, message);
// 通知时间线刷新(可指定文件,undefined 表示全部)
this.provider.fireChange(uri);
}
}
// 在 provider 中暴露刷新方法
fireChange(uri: vscode.Uri | undefined) {
this._onDidChange.fire(uri);
}注册与激活
typescript
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
const snapshotDir = path.join(
context.globalStorageUri.fsPath,
'snapshots'
);
const provider = new MyTimelineProvider(snapshotDir);
// 注册时间线提供者
context.subscriptions.push(
vscode.window.registerTimelineProvider('myscm.history', provider)
);
}package.json 中配套声明:
json
{
"contributes": {
"timeline": [
{
"id": "myscm.history",
"label": "MySCM 历史",
"itemLabel": "快照",
"enablement": "workspaceFolderCount > 0",
"when": "resourceScheme == file && resourceExtname == .txt || resourceExtname == .js"
}
]
}
}when 条件示例
| 表达式 | 效果 |
|---|---|
resourceScheme == file | 仅本地文件显示 |
resourceExtname == .js | 仅 JS 文件显示 |
resourceFilename == package.json | 特定文件显示 |
内置 Git 时间线集成
VS Code 内置的 Git 时间线也是通过 Timeline API 实现。自定义时间线可以与内置时间线共存,用户可在时间线视图中切换。
关联内置时间线的场景
typescript
// 当 Git 时间线存在时,自定义时间线可以增强而非替代
function isGitRepo(uri: vscode.Uri): boolean {
return fs.existsSync(path.join(uri.fsPath.split('/').slice(0, -1).join('/'), '.git'));
}
// 在 provideTimeline 中混合两种来源
async provideTimeline(uri, options, token) {
const snapshots = await this.loadSnapshots(uri);
// 尝试读取 Git 提交历史(若存在)
const gitTimeline = await vscode.commands.executeCommand(
'vscode.timeline.refresh'
);
// 返回合并后的时间线
return { items: snapshots.map(...) };
}时间线排序
多个时间线提供者时,时间线视图按 label 排序展示切换入口。when 条件精确控制每个文件的可用时间线,避免无关提供者干扰。
性能优化
时间线可能频繁触发,需控制开销:
typescript
// 缓存文件的时间线结果
private cache = new Map<string, { time: number; items: vscode.TimelineItem[] }>();
async provideTimeline(uri, options, token) {
const key = uri.fsPath;
// 5 秒内命中缓存
const cached = this.cache.get(key);
if (cached && Date.now() - cached.time < 5000) {
return { items: cached.items };
}
const snapshots = await this.loadSnapshots(uri);
const items = snapshots.map((s) => this.toTimelineItem(uri, s));
this.cache.set(key, { time: Date.now(), items });
return { items };
}时间线提供者让"文件历史"可编程化,下一篇把 SCM 与时间线整合成完整的自定义版本控制系统。