import * as fs from "node:fs"; import * as path from "node:path"; import { applyTreeFileCheckout, ensureDir, MAX_STACK, readTextFile, type FileSnapshot, type SessionUndoState, type UndoOperation, } from "./reverse-last-state.ts"; export const MAX_HISTORY = MAX_STACK * 4; export function currentSessionLeafId(sessionManager: { getLeafId(): string | null }): string | undefined { return sessionManager.getLeafId() ?? undefined; } export function captureTurnLeafId( sessionManager: { getLeafId(): string | null }, cached?: string, ): string | undefined { return cached ?? currentSessionLeafId(sessionManager); } export function ensureLegacyHistory(state: SessionUndoState): void { if (state.history.length === 0 && state.stack.length > 0) { state.history = state.stack.map((legacyOperation) => ({ type: "capture" as const, operation: legacyOperation, })); } } export function recordCapturedOperation( state: SessionUndoState, operation: UndoOperation, sessionEntryId: string | undefined, ): UndoOperation { const captured: UndoOperation = { ...operation, sessionEntryId }; state.stack.push(captured); state.stack = state.stack.slice(-MAX_STACK); state.history.push({ type: "capture", entryId: sessionEntryId, operation: captured }); state.history = state.history.slice(-MAX_HISTORY); // A new capture means the file changed again: its "leave deleted" pin no longer applies. if (state.pins) { const touched = new Set(operation.files.map((file) => file.absPath)); state.pins = state.pins.filter((absPath) => !touched.has(absPath)); } return captured; } export function recordUndoHistory( state: SessionUndoState, entryId: string | undefined, operationIds: string[], ): void { state.history.push({ type: "undo", entryId, operationIds }); state.history = state.history.slice(-MAX_HISTORY); } export function snapshotChangedFiles(files: FileSnapshot[]): FileSnapshot[] { return files.flatMap((file) => { const after = readTextFile(file.absPath); const changed = file.previousExists ? after !== file.previousContent : after !== undefined; if (!changed) { return []; } return [{ ...file, afterExists: after !== undefined, afterContent: after, }]; }); } export function restoreOperationFiles(cwd: string, op: UndoOperation, restored: string[]): void { for (const file of op.files) { if (file.previousExists) { ensureDir(path.dirname(file.absPath)); fs.writeFileSync(file.absPath, file.previousContent ?? "", "utf8"); restored.push(path.relative(cwd, file.absPath) || file.absPath); } else { if (fs.existsSync(file.absPath)) { fs.unlinkSync(file.absPath); } restored.push(`${path.relative(cwd, file.absPath) || file.absPath} (deleted)`); } } } export function undoSelectedOperations( state: SessionUndoState, cwd: string, selectedIds: Set, sessionEntryId: string | undefined, ): { steps: number; restored: string[] } { if (state.stack.length === 0 || selectedIds.size === 0) { return { steps: 0, restored: [] }; } const selected = state.stack.filter((operation) => selectedIds.has(operation.id)); if (selected.length === 0) { return { steps: 0, restored: [] }; } const remaining = state.stack.filter((operation) => !selectedIds.has(operation.id)); const restored = applyTreeFileCheckout(state.stack, remaining, cwd, { skipUntracked: false, pins: state.pins, }); state.stack = remaining; recordUndoHistory(state, sessionEntryId, selected.map((operation) => operation.id)); return { steps: selected.length, restored }; } export function undoRecentOperations( state: SessionUndoState, cwd: string, count: number, sessionEntryId: string | undefined, ): { steps: number; restored: string[] } { if (state.stack.length === 0) { return { steps: 0, restored: [] }; } const steps = Math.min(count, state.stack.length); const restored: string[] = []; const undoneIds: string[] = []; for (let index = 0; index < steps; index++) { const operation = state.stack.pop(); if (!operation) { break; } restoreOperationFiles(cwd, operation, restored); undoneIds.push(operation.id); } if (undoneIds.length > 0) { recordUndoHistory(state, sessionEntryId, undoneIds); } return { steps: undoneIds.length, restored }; }