import * as vscode from "vscode"; import { ActorSnapshot, formatSnapshot } from "./protocol"; import { canEditActor, parseSnapshotEdit, SnapshotEdit } from "./snapshotEdits"; interface DocumentSnapshot { session: number; endpoint: string; actor: ActorSnapshot; capturedAt: string; connected: boolean; removed: boolean; mtime: number; } export class SnapshotDocuments implements vscode.FileSystemProvider { private readonly changes = new vscode.EventEmitter(); readonly onDidChangeFile = this.changes.event; private readonly documents = new Map(); private readonly baselines = new Map(); constructor( private readonly save: (session: number, actor: string, edit: SnapshotEdit) => Promise, private readonly warn: (message: string) => void, ) {} set(session: number, endpoint: string, actor: ActorSnapshot): vscode.Uri { const uri = vscode.Uri.from({ scheme: "runa", authority: String(session), path: `/actors/${actor.actor_ref}.json`, }); const previous = this.documents.get(uri.toString()); this.documents.set(uri.toString(), { session, endpoint, actor, capturedAt: new Date().toISOString(), connected: true, removed: false, mtime: Math.max(Date.now(), (previous?.mtime ?? 0) + 1), }); this.changed(uri); return uri; } refresh(session: number, endpoint: string, actors: ActorSnapshot[]): void { const byRef = new Map(actors.map((actor) => [actor.actor_ref, actor])); for (const [key, snapshot] of this.documents) { const actor = byRef.get(snapshot.actor.actor_ref); if (snapshot.session !== session) continue; if (actor) this.set(session, endpoint, actor); else if (!snapshot.removed) { snapshot.removed = true; snapshot.mtime++; this.changed(vscode.Uri.parse(key)); } } } disconnect(session: number): void { for (const [key, snapshot] of this.documents) { if (snapshot.session === session) { snapshot.connected = false; snapshot.mtime++; this.changed(vscode.Uri.parse(key)); } } } private changed(uri: vscode.Uri): void { this.changes.fire([{ type: vscode.FileChangeType.Changed, uri }]); } private snapshot(uri: vscode.Uri): DocumentSnapshot { const snapshot = this.documents.get(uri.toString()); if (!snapshot) throw vscode.FileSystemError.FileNotFound(uri); return snapshot; } private text(snapshot: DocumentSnapshot): string { return formatSnapshot( snapshot.actor, snapshot.endpoint, snapshot.capturedAt, snapshot.connected, snapshot.removed, ); } stat(uri: vscode.Uri): vscode.FileStat { if (this.isDirectory(uri)) return { type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0 }; const snapshot = this.snapshot(uri); return { type: vscode.FileType.File, ctime: 0, mtime: snapshot.mtime, size: Buffer.byteLength(this.text(snapshot)), permissions: snapshot.connected && !snapshot.removed && canEditActor(snapshot.actor) ? undefined : vscode.FilePermission.Readonly, }; } readFile(uri: vscode.Uri): Uint8Array { const text = this.text(this.snapshot(uri)); const dirty = vscode.workspace.textDocuments.some( (document) => document.uri.toString() === uri.toString() && document.isDirty, ); if (!dirty) this.baselines.set(uri.toString(), text); return Buffer.from(text); } async writeFile(uri: vscode.Uri, content: Uint8Array): Promise { const snapshot = this.snapshot(uri); if (!snapshot.connected || snapshot.removed || !canEditActor(snapshot.actor)) throw vscode.FileSystemError.NoPermissions( "Actor must be paused or failed with no active invocation, and not removed or disconnected", ); const baseline = this.baselines.get(uri.toString()); if (baseline === undefined) throw vscode.FileSystemError.Unavailable("Inspect the actor before editing"); const edit = parseSnapshotEdit(Buffer.from(content).toString("utf8"), baseline); await this.save(snapshot.session, snapshot.actor.actor_ref, edit); if (edit.discarded.length) this.warn(`Discarded read-only inspection fields: ${edit.discarded.join(", ")}`); this.changed(uri); } didSave(document: vscode.TextDocument): void { if (document.uri.scheme !== "runa" || document.isDirty) return; const snapshot = this.snapshot(document.uri); // Notify after the save completes so VS Code reloads the now-clean editor. snapshot.mtime = Math.max(Date.now(), snapshot.mtime + 1); this.changed(document.uri); } watch(): vscode.Disposable { return new vscode.Disposable(() => {}); } private isDirectory(uri: vscode.Uri): boolean { return ( uri.scheme === "runa" && (uri.path === "/" || uri.path === "/actors") && [...this.documents.values()].some((snapshot) => String(snapshot.session) === uri.authority) ); } readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { if (!this.isDirectory(uri)) throw vscode.FileSystemError.FileNotADirectory(uri); if (uri.path === "/") return [["actors", vscode.FileType.Directory]]; return [...this.documents.values()] .filter((snapshot) => String(snapshot.session) === uri.authority) .map((snapshot) => [`${snapshot.actor.actor_ref}.json`, vscode.FileType.File]); } createDirectory(uri: vscode.Uri): void { if (!this.isDirectory(uri)) throw vscode.FileSystemError.NoPermissions("Actor snapshot directories cannot be created"); } delete(): never { throw vscode.FileSystemError.NoPermissions("Actor snapshots cannot be deleted"); } rename(): never { throw vscode.FileSystemError.NoPermissions("Actor snapshots cannot be renamed"); } dispose(): void { this.changes.dispose(); this.documents.clear(); this.baselines.clear(); } }