singularity-forge/vscode-extension/src/change-tracker.ts
ace-pm 9d739dfa5d Rename GSD→SF: complete rebrand from fork origin
- All gsdDir/gsdRoot/gsdHome → sfDir/sfRootDir/sfHome
- GSDWorkspace* → SFWorkspace* interfaces
- bootstrapGsdProject → bootstrapProject
- runGSDDoctor → runSFDoctor
- GsdClient → SfClient, gsd-client.ts → sf-client.ts
- .gsd/ → .sf/ in all tests, docs, docker, native, vscode
- Auto-migration: headless detects .gsd/ → renames to .sf/
- Deleted gsd-phase-state.ts backward-compat re-export
- Renamed bin/gsd-from-source → bin/sf-from-source
- Updated mintlify docs, github workflows, docker configs
2026-04-15 18:33:47 +02:00

295 lines
8.3 KiB
TypeScript

import * as vscode from "vscode";
import * as fs from "node:fs";
import type { SfClient, AgentEvent } from "./sf-client.js";
export interface FileSnapshot {
uri: vscode.Uri;
originalContent: string;
timestamp: number;
}
export interface Checkpoint {
id: number;
label: string;
timestamp: number;
/** Map of file path → original content at checkpoint creation time */
snapshots: Map<string, string>;
}
/**
* Tracks file changes made by the SF agent. Stores original file content
* before the agent modifies it, enabling diff views, SCM integration,
* and checkpoint/rollback functionality.
*/
export class GsdChangeTracker implements vscode.Disposable {
/** file path → original content (before first agent modification this session) */
private originals = new Map<string, string>();
/** Set of file paths modified in the current agent turn */
private currentTurnFiles = new Set<string>();
/** Ordered list of checkpoints */
private _checkpoints: Checkpoint[] = [];
private nextCheckpointId = 1;
/** toolUseId → file path for in-flight tool executions */
private pendingTools = new Map<string, string>();
/** Whether the current turn has been described in the checkpoint label */
private turnDescribed = false;
private readonly _onDidChange = new vscode.EventEmitter<string[]>();
/** Fires when the set of tracked files changes. Payload is array of changed file paths. */
readonly onDidChange = this._onDidChange.event;
private readonly _onCheckpointChange = new vscode.EventEmitter<void>();
readonly onCheckpointChange = this._onCheckpointChange.event;
private disposables: vscode.Disposable[] = [];
constructor(private readonly client: SfClient) {
this.disposables.push(this._onDidChange, this._onCheckpointChange);
this.disposables.push(
client.onEvent((evt) => this.handleEvent(evt)),
client.onConnectionChange((connected) => {
if (!connected) {
this.reset();
}
}),
);
}
/** All file paths that have been modified by the agent */
get modifiedFiles(): string[] {
return [...this.originals.keys()];
}
/** Get the original content of a file (before agent first modified it) */
getOriginal(filePath: string): string | undefined {
return this.originals.get(filePath);
}
/** Whether the tracker has any modifications */
get hasChanges(): boolean {
return this.originals.size > 0;
}
/** Current checkpoints (newest first) */
get checkpoints(): readonly Checkpoint[] {
return this._checkpoints;
}
/**
* Discard agent changes to a single file — restore original content.
* Returns true if the file was restored.
*/
async discardFile(filePath: string): Promise<boolean> {
const original = this.originals.get(filePath);
if (original === undefined) return false;
try {
await fs.promises.writeFile(filePath, original, "utf8");
this.originals.delete(filePath);
this._onDidChange.fire([filePath]);
return true;
} catch {
return false;
}
}
/**
* Discard all agent changes — restore all files to their original state.
*/
async discardAll(): Promise<number> {
let count = 0;
const paths = [...this.originals.keys()];
for (const filePath of paths) {
if (await this.discardFile(filePath)) {
count++;
}
}
return count;
}
/**
* Accept changes to a file — remove from tracking (keep the current content).
*/
acceptFile(filePath: string): void {
if (this.originals.delete(filePath)) {
this._onDidChange.fire([filePath]);
}
}
/**
* Accept all changes — clear all tracking.
*/
acceptAll(): void {
const paths = [...this.originals.keys()];
this.originals.clear();
if (paths.length > 0) {
this._onDidChange.fire(paths);
}
}
/**
* Restore all files to a checkpoint state.
*/
async restoreCheckpoint(checkpointId: number): Promise<number> {
const idx = this._checkpoints.findIndex((c) => c.id === checkpointId);
if (idx === -1) return 0;
const checkpoint = this._checkpoints[idx];
let count = 0;
for (const [filePath, content] of checkpoint.snapshots) {
try {
await fs.promises.writeFile(filePath, content, "utf8");
count++;
} catch {
// skip files that can't be restored
}
}
// Reset originals to the checkpoint state
this.originals = new Map(checkpoint.snapshots);
// Remove all checkpoints after this one
this._checkpoints = this._checkpoints.slice(0, idx);
this._onDidChange.fire([...checkpoint.snapshots.keys()]);
this._onCheckpointChange.fire();
return count;
}
/** Clear all tracking state */
reset(): void {
const paths = [...this.originals.keys()];
this.originals.clear();
this.currentTurnFiles.clear();
this.pendingTools.clear();
this._checkpoints = [];
this.nextCheckpointId = 1;
if (paths.length > 0) {
this._onDidChange.fire(paths);
}
this._onCheckpointChange.fire();
}
dispose(): void {
for (const d of this.disposables) {
d.dispose();
}
}
private handleEvent(evt: AgentEvent): void {
switch (evt.type) {
case "agent_start":
this.createCheckpoint();
this.currentTurnFiles.clear();
this.turnDescribed = false;
break;
case "tool_execution_start": {
const toolName = String(evt.toolName ?? "");
const toolInput = (evt.toolInput ?? {}) as Record<string, unknown>;
const toolUseId = String(evt.toolUseId ?? "");
// Update checkpoint label with first action description
if (!this.turnDescribed) {
this.turnDescribed = true;
this.updateLatestCheckpointLabel(describeAction(toolName, toolInput));
}
if (toolName !== "Write" && toolName !== "Edit") break;
const filePath = String(toolInput.file_path ?? toolInput.path ?? "");
if (!filePath) break;
// Store the original content before the agent modifies it
// Only capture on FIRST modification (don't overwrite)
if (!this.originals.has(filePath)) {
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, "utf8");
this.originals.set(filePath, content);
} else {
// File doesn't exist yet — original is "empty" (new file)
this.originals.set(filePath, "");
}
} catch {
// Can't read file, skip tracking
}
}
if (toolUseId) {
this.pendingTools.set(toolUseId, filePath);
}
break;
}
case "tool_execution_end": {
const toolUseId = String(evt.toolUseId ?? "");
const filePath = this.pendingTools.get(toolUseId);
if (filePath) {
this.pendingTools.delete(toolUseId);
this.currentTurnFiles.add(filePath);
this._onDidChange.fire([filePath]);
}
break;
}
}
}
private createCheckpoint(): void {
const now = Date.now();
const time = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const fileCount = this.originals.size;
const label = fileCount > 0
? `${time} (${fileCount} file${fileCount !== 1 ? "s" : ""} tracked)`
: `${time} (start)`;
const checkpoint: Checkpoint = {
id: this.nextCheckpointId++,
label,
timestamp: now,
snapshots: new Map(this.originals),
};
this._checkpoints.push(checkpoint);
this._onCheckpointChange.fire();
}
/**
* Update the label of the latest checkpoint with a description
* of the first action taken (called after first tool execution in a turn).
*/
private updateLatestCheckpointLabel(description: string): void {
if (this._checkpoints.length === 0) return;
const latest = this._checkpoints[this._checkpoints.length - 1];
const time = new Date(latest.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
latest.label = `${time}${description}`;
this._onCheckpointChange.fire();
}
}
function describeAction(toolName: string, input: Record<string, unknown>): string {
switch (toolName) {
case "Read": {
const p = String(input.file_path ?? input.path ?? "");
return `Read ${p.split(/[\\/]/).pop() ?? p}`;
}
case "Write": {
const p = String(input.file_path ?? "");
return `Write ${p.split(/[\\/]/).pop() ?? p}`;
}
case "Edit": {
const p = String(input.file_path ?? "");
return `Edit ${p.split(/[\\/]/).pop() ?? p}`;
}
case "Bash":
return `$ ${String(input.command ?? "").slice(0, 40)}`;
case "Grep":
return `Grep: ${String(input.pattern ?? "").slice(0, 30)}`;
case "Glob":
return `Glob: ${String(input.pattern ?? "").slice(0, 30)}`;
default:
return toolName;
}
}