实战:自定义 SCM 插件
本文实现一个完整的"备份快照"版本控制系统:在源码管理视图显示文件修改状态,点击提交生成快照备份,时间线视图查看历史版本。
设计目标
| 能力 | 实现方式 |
|---|---|
| 文件状态展示 | SCM 资源组(修改/新增/删除) |
| 提交 | 生成快照目录 + 输入框提交信息 |
| 提交历史 | Timeline Provider 展示快照 |
| 版本回滚 | 右键菜单恢复快照 |
text
项目目录
└── .myscm/
├── snapshots/ 快照数据
│ └── <hash>.json 每个快照的元数据
└── files/ 快照文件内容
└── <hash>/ 按快照哈希分目录
└── src/main.js第一步:注册贡献点
package.json 声明 SCM 提供者、命令与时间线:
json
{
"contributes": {
"scmProviders": [
{ "scmId": "myscm", "label": "MySCM", "rootUri": true }
],
"timeline": [
{
"id": "myscm.history",
"label": "MySCM 历史",
"itemLabel": "快照",
"enablement": "workspaceFolderCount > 0",
"when": "resourceScheme == file"
}
],
"commands": [
{ "command": "myscm.commit", "title": "提交", "category": "MySCM" },
{ "command": "myscm.restore", "title": "恢复快照", "category": "MySCM" }
],
"menus": {
"scm/resourceState/context": [
{
"command": "myscm.restore",
"when": "scmProvider == myscm",
"group": "2_actions",
"title": "恢复为快照版本"
}
],
"timeline/item/context": [
{
"command": "myscm.restore",
"when": "timelineItem.contextValue == myscm.snapshot",
"group": "1_actions",
"title": "恢复此快照"
}
]
}
}
}第二步:快照服务
快照服务负责扫描文件状态、生成与加载快照:
typescript
// snapshotService.ts
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import * as vscode from 'vscode';
export interface FileChange {
uri: vscode.Uri;
status: 'modified' | 'added' | 'deleted';
}
export interface Snapshot {
hash: string;
message: string;
timestamp: number;
changes: { path: string; status: string }[];
}
export class SnapshotService {
private repoRoot: string;
private storageDir: string;
constructor(repoRoot: string, storageDir: string) {
this.repoRoot = repoRoot;
this.storageDir = storageDir;
fs.mkdirSync(path.join(this.storageDir, 'files'), { recursive: true });
}
// 记录提交时的基准文件哈希
private baseline = new Map<string, string>();
// 初始化基准:记录当前所有文件哈希
async initBaseline() {
const files = this.walk(this.repoRoot);
for (const f of files) {
this.baseline.set(f, await this.hashFile(f));
}
}
// 扫描文件状态
async scanChanges(): Promise<FileChange[]> {
const changes: FileChange[] = [];
const currentFiles = this.walk(this.repoRoot);
// 检测新增/修改
for (const file of currentFiles) {
const rel = path.relative(this.repoRoot, file);
const hash = await this.hashFile(file);
if (!this.baseline.has(rel)) {
changes.push({ uri: vscode.Uri.file(file), status: 'added' });
} else if (this.baseline.get(rel) !== hash) {
changes.push({ uri: vscode.Uri.file(file), status: 'modified' });
}
}
// 检测删除
const known = new Set(currentFiles.map((f) => path.relative(this.repoRoot, f)));
for (const [rel] of this.baseline) {
if (!known.has(rel)) {
changes.push({
uri: vscode.Uri.file(path.join(this.repoRoot, rel)),
status: 'deleted'
});
}
}
return changes;
}
// 提交快照
async commit(message: string): Promise<Snapshot> {
const changes = await this.scanChanges();
const hash = crypto
.createHash('sha1')
.update(message + Date.now())
.digest('hex')
.slice(0, 10);
const snapshot: Snapshot = {
hash,
message,
timestamp: Date.now(),
changes: changes.map((c) => ({
path: path.relative(this.repoRoot, c.uri.fsPath),
status: c.status
}))
};
// 保存快照元数据
fs.writeFileSync(
path.join(this.storageDir, `${hash}.json`),
JSON.stringify(snapshot, null, 2)
);
// 备份修改/新增文件内容
const fileDir = path.join(this.storageDir, 'files', hash);
fs.mkdirSync(fileDir, { recursive: true });
for (const c of changes) {
if (c.status !== 'deleted') {
const rel = path.relative(this.repoRoot, c.uri.fsPath);
fs.copyFileSync(c.uri.fsPath, path.join(fileDir, rel));
}
}
// 更新基准
await this.initBaseline();
return snapshot;
}
// 加载文件历史
loadHistory(filePath: string): Snapshot[] {
const files = fs.readdirSync(this.storageDir)
.filter((f) => f.endsWith('.json'))
.sort((a, b) => b.localeCompare(a));
const result: Snapshot[] = [];
for (const f of files) {
const snap = JSON.parse(
fs.readFileSync(path.join(this.storageDir, f), 'utf8')
) as Snapshot;
// 只保留涉及该文件的历史
if (snap.changes.some((c) => c.path === filePath || c.status === 'added')) {
result.push(snap);
}
}
return result;
}
// 恢复快照中的文件版本
restore(hash: string, filePath: string) {
const source = path.join(this.storageDir, 'files', hash, filePath);
if (fs.existsSync(source)) {
fs.copyFileSync(source, path.join(this.repoRoot, filePath));
} else {
// 快照中不存在说明该版本文件被删除
fs.rmSync(path.join(this.repoRoot, filePath), { force: true });
}
}
private walk(dir: string): string[] {
const result: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.myscm') || entry.name === 'node_modules') {
continue;
}
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
result.push(...this.walk(full));
} else {
result.push(full);
}
}
return result;
}
private hashFile(file: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(file);
stream.on('data', (d) => hash.update(d));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
}第三步:SCM 集成
创建 SCM 实例并绑定资源组:
typescript
// scmManager.ts
import * as vscode from 'vscode';
import { SnapshotService, FileChange } from './snapshotService';
export class ScmManager {
private scm: vscode.SourceControl;
private modifiedGroup: vscode.SourceControlResourceGroup;
private untrackedGroup: vscode.SourceControlResourceGroup;
private deletedGroup: vscode.SourceControlResourceGroup;
constructor(
private service: SnapshotService,
private repoRoot: string,
context: vscode.ExtensionContext
) {
this.scm = vscode.scm.createSourceControl(
'myscm',
'MySCM',
vscode.Uri.file(repoRoot)
);
this.scm.inputBox.placeholder = '快照说明(Enter 提交)';
this.scm.inputBox.validateInput = (v) => v.trim() ? undefined : '请输入说明';
this.modifiedGroup = this.scm.createResourceGroup('modified', '更改');
this.untrackedGroup = this.scm.createResourceGroup('untracked', '新增');
this.deletedGroup = this.scm.createResourceGroup('deleted', '删除');
this.modifiedGroup.hideWhenEmpty = true;
this.untrackedGroup.hideWhenEmpty = true;
this.deletedGroup.hideWhenEmpty = true;
// 提交按钮绑定
this.scm.acceptInputCommand = {
command: 'myscm.commit',
title: '提交'
};
context.subscriptions.push(this.scm);
}
private toState(change: FileChange): vscode.SourceControlResourceState {
const letter = { modified: 'M', added: 'A', deleted: 'D' }[change.status];
const colorKey = {
modified: 'gitDecoration.modifiedResourceForeground',
added: 'gitDecoration.addedResourceForeground',
deleted: 'gitDecoration.deletedResourceForeground'
}[change.status];
return {
resourceUri: change.uri,
contextValue: `myscm.${change.status}`,
command: {
command: 'myscm.diff',
title: '查看',
arguments: [change.uri]
},
decorations: {
letter,
tooltip: { modified: '已修改', added: '已新增', deleted: '已删除' }[change.status],
strikeThrough: change.status === 'deleted',
color: new vscode.ThemeColor(colorKey)
}
};
}
async refresh() {
const changes = await this.service.scanChanges();
this.modifiedGroup.resourceStates = changes
.filter((c) => c.status === 'modified')
.map((c) => this.toState(c));
this.untrackedGroup.resourceStates = changes
.filter((c) => c.status === 'added')
.map((c) => this.toState(c));
this.deletedGroup.resourceStates = changes
.filter((c) => c.status === 'deleted')
.map((c) => this.toState(c));
this.scm.count = changes.length;
this.scm.description = '备份快照模式';
}
}第四步:命令与差异
typescript
// commands.ts
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
export function registerCommands(
context: vscode.ExtensionContext,
service: SnapshotService,
scm: ScmManager
) {
// 提交
context.subscriptions.push(
vscode.commands.registerCommand('myscm.commit', async () => {
const message = scm.inputBoxValue();
if (!message) return;
try {
await service.commit(message);
scm.clearInput();
await scm.refresh();
vscode.window.showInformationMessage(`已创建快照: ${message}`);
} catch (err) {
vscode.window.showErrorMessage(`提交失败: ${err.message}`);
}
})
);
// 查看差异
context.subscriptions.push(
vscode.commands.registerCommand('myscm.diff', async (uri: vscode.Uri) => {
// 找最近一个包含该文件的快照作对比
const rel = path.relative(scm.repoRoot, uri.fsPath);
const history = service.loadHistory(rel);
const last = history[0];
if (!last) {
vscode.window.showInformationMessage('该文件还没有历史快照');
return;
}
const originalPath = path.join(
service.storageDir, 'files', last.hash, rel
);
const original = fs.existsSync(originalPath)
? vscode.Uri.file(originalPath)
: vscode.Uri.file('/dev/null');
await vscode.commands.executeCommand('vscode.diff', original, uri, rel);
})
);
// 恢复快照版本
context.subscriptions.push(
vscode.commands.registerCommand('myscm.restore', async (
arg: vscode.SourceControlResourceState | vscode.TimelineItem
) => {
// 处理来自两种入口的参数
let filePath: string;
let hash: string;
if ((arg as vscode.TimelineItem).timestamp) {
// 时间线入口:需从 command arguments 传快照信息
return; // 实际实现中通过 arguments 传递
}
const state = arg as vscode.SourceControlResourceState;
filePath = path.relative(scm.repoRoot, state.resourceUri.fsPath);
const history = service.loadHistory(filePath);
hash = history[0]?.hash;
if (!hash) {
vscode.window.showWarningMessage('没有可恢复的历史版本');
return;
}
const answer = await vscode.window.showWarningMessage(
`确定恢复 ${filePath} 到最近快照?`,
{ modal: true },
'恢复'
);
if (answer === '恢复') {
service.restore(hash, filePath);
await scm.refresh();
vscode.window.showInformationMessage(`已恢复 ${filePath}`);
}
})
);
}第五步:Timeline Provider
typescript
// timelineProvider.ts
import * as vscode from 'vscode';
import * as path from 'path';
import { SnapshotService, Snapshot } from './snapshotService';
export class SnapshotTimelineProvider implements vscode.TimelineProvider {
readonly id = 'myscm.history';
readonly label = 'MySCM 历史';
private _onDidChange = new vscode.EventEmitter<vscode.Uri | undefined>();
readonly onDidChange = this._onDidChange.event;
constructor(private service: SnapshotService, private repoRoot: string) {}
fireChange(uri?: vscode.Uri) {
this._onDidChange.fire(uri);
}
async provideTimeline(
uri: vscode.Uri,
options: vscode.TimelineOptions,
token: vscode.CancellationToken
): Promise<vscode.TimelineResult> {
const rel = path.relative(this.repoRoot, uri.fsPath);
const history = this.service.loadHistory(rel);
const items = history.map((s) => this.toItem(s));
// 按日期分组
const groups = this.groupByDay(items);
return { items, groups };
}
private toItem(snapshot: Snapshot): vscode.TimelineItem {
const changeSummary = snapshot.changes
.map((c) => `${c.status === 'deleted' ? '删除' : c.status === 'added' ? '新增' : '修改'} ${c.path}`)
.join('\n');
return {
id: snapshot.hash,
label: snapshot.message,
description: new Date(snapshot.timestamp).toLocaleString(),
timestamp: snapshot.timestamp,
iconPath: new vscode.ThemeIcon('versions'),
contextValue: 'myscm.snapshot',
tooltip: new vscode.MarkdownString(
`**${snapshot.message}**\n\n${changeSummary}`
),
command: {
command: 'myscm.restoreSnapshot',
title: '恢复快照',
arguments: [snapshot]
}
};
}
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, list]) => ({
id: day,
label: day,
items: list
}));
}
}第六步:激活入口
typescript
// extension.ts
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import { SnapshotService } from './snapshotService';
import { ScmManager } from './scmManager';
import { registerCommands } from './commands';
import { SnapshotTimelineProvider } from './timelineProvider';
export async function activate(context: vscode.ExtensionContext) {
const folder = vscode.workspace.workspaceFolders?.[0];
if (!folder) return;
const repoRoot = folder.uri.fsPath;
const storageDir = path.join(context.globalStorageUri.fsPath, 'myscm');
// 快照服务
const service = new SnapshotService(repoRoot, storageDir);
await service.initBaseline();
// SCM 管理
const scm = new ScmManager(service, repoRoot, context);
// 命令
registerCommands(context, service, scm);
// Timeline
const timeline = new SnapshotTimelineProvider(service, repoRoot);
context.subscriptions.push(
vscode.window.registerTimelineProvider('myscm.history', timeline)
);
// 文件变化时刷新 SCM 与时间线(防抖)
let timer: NodeJS.Timeout | undefined;
const schedule = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
scm.refresh();
timeline.fireChange();
}, 300);
};
const watcher = vscode.workspace.createFileSystemWatcher('**/*');
watcher.onDidChange(schedule);
watcher.onDidCreate(schedule);
watcher.onDidDelete(schedule);
vscode.workspace.onDidSaveTextDocument(schedule);
context.subscriptions.push(watcher);
// 初始刷新
await scm.refresh();
}使用流程验证
text
1. 打开工作区 → 插件自动扫描文件,建立基线
2. 修改文件 → SCM 视图显示"M"标记,图标显示计数
3. 输入快照说明 → 点击提交 → 生成快照目录
4. 再次修改 → SCM 显示新的更改
5. 底部时间线 → 查看历史快照,点击恢复
6. 右键文件 → 恢复为快照版本能力对照
| 特性 | 实现 |
|---|---|
| 文件状态(修改/新增/删除) | scanChanges + 资源组 |
| 提交快照 | commit 生成哈希目录 |
| 提交信息输入框 | scm.inputBox + acceptInputCommand |
| 提交历史时间线 | Timeline Provider + 分组 |
| 版本回滚 | restore 复制快照文件 |
| 差异查看 | vscode.diff 对比快照与当前 |
至此,一个完整的自定义版本控制插件落地:SCM 视图负责状态展示与提交,时间线视图负责历史浏览与恢复,两条 API 在快照存储上无缝协作。