chocolatito-code 1.6.14 → 1.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -122,6 +122,12 @@ JavaScript de Node. Por el navegador no hace falta instalar nada.
122
122
  terminal queda de monitor. Es a propósito — dos entradas peleándose por el mismo teclado
123
123
  es de donde salen los cuelgues raros.
124
124
 
125
+ **Desde fuera de tu red**, con un túnel: `cloudflared tunnel --url http://127.0.0.1:4700` y
126
+ después `chocolatito --servir --tunel <el enlace que te dé>`. Esa bandera autoriza **ese**
127
+ dominio y solo ese; sin ella el servidor lo rechaza, que es lo que tiene que hacer con un
128
+ nombre de dominio cualquiera. Ojo: ese enlace sale a internet y lo único que lo protege es
129
+ el token. Ciérralo al acabar.
130
+
125
131
  **Lo que NO hace, a propósito:** no hay HTTPS ni túnel propio. Para entrar desde fuera de
126
132
  tu casa, usa Tailscale o `cloudflared`, que ya existen y están auditados. Un túnel casero
127
133
  es la parte fácil de una responsabilidad muy grande.
@@ -346,7 +352,20 @@ No hace falta esperar a que termine. Lo que escribas durante una generación apa
346
352
  el spinner y, al pulsar Enter, queda en cola: el agente acaba su turno y lo toma como
347
353
  siguiente orden, sin que tengas que volver a teclearlo.
348
354
 
349
- `Esc` pausa el turno en curso. `Ctrl+C` una vez interrumpe; dos veces cierra la sesión.
355
+ `Esc` pausa el turno en curso. `Ctrl+C` una vez interrumpe; dos veces cierra la sesión.
356
+
357
+ ---
358
+
359
+ ## ⏪ Volver atrás (`/undo` y `/rewind`)
360
+
361
+ Antes de tocar un archivo, Chocolatito guarda una copia. `/undo` deshace el último turno y
362
+ `/rewind` enseña la lista para elegir hasta dónde volver.
363
+
364
+ **Y sobrevive a cerrar la sesión**, que es cuando de verdad hace falta: uno no deshace treinta
365
+ segundos después, sino al día siguiente, al abrir el proyecto y ver que falta algo. Se guarda
366
+ en `.chocolatito/cambios/` de tu proyecto, con los últimos 20 turnos, y con un `.gitignore`
367
+ dentro para que no acabe en tu repositorio. Los archivos de más de 25 MB no se copian: se te
368
+ avisa en el momento, en vez de prometerte una vuelta atrás que no existe.
350
369
 
351
370
  ---
352
371
 
@@ -0,0 +1,77 @@
1
+ import type { Checkpoint, FileSnapshot } from "./undoManager.js";
2
+ /**
3
+ * EL HISTORIAL DE CAMBIOS, EN DISCO
4
+ *
5
+ * DE DONDE SALE
6
+ *
7
+ * `/undo` estaba entero en memoria: `private checkpoints: Checkpoint[] = []`.
8
+ * O sea que la red de seguridad de un agente que edita y borra archivos solo
9
+ * duraba lo que durase la ventana del terminal. Cierras, o se cierra sola, y ya
10
+ * no hay vuelta atras de nada de lo que hizo.
11
+ *
12
+ * Y es al reves de como se usa: el momento en que uno quiere deshacer no es
13
+ * treinta segundos despues -ahi todavia estas mirando-, es al dia siguiente,
14
+ * cuando abres el proyecto y ves que falta algo.
15
+ *
16
+ * DONDE SE GUARDA
17
+ *
18
+ * En `<proyecto>/.chocolatito/cambios/`, al lado de la memoria del proyecto y
19
+ * no en el home: lo que se guarda son archivos DE ESE proyecto, y si el
20
+ * proyecto se borra o se mueve, su historial se va con el. Mezclarlos todos en
21
+ * el home dejaria copias de codigo de clientes distintos en el mismo cajon.
22
+ *
23
+ * QUE SE GUARDA, Y QUE NO
24
+ *
25
+ * Un turno es una carpeta: `meta.json` con la lista de rutas y un archivo por
26
+ * cada copia, numerado. Los bytes se guardan CRUDOS, sin codificar: por aqui
27
+ * pasan imagenes y videos, y un mp4 convertido a texto vuelve corrupto -ya pasó
28
+ * una vez con /undo y utf-8-.
29
+ *
30
+ * Con dos topes, porque esto crece en el disco de otro:
31
+ *
32
+ * - TAMANO POR ARCHIVO. Copiar un video de 500 MB en cada turno que lo toque
33
+ * llena el disco sin que nadie lo pida. Por encima del tope consta como
34
+ * "sin copia", que es exactamente lo que es, y se avisa en el momento.
35
+ * - CUANTOS TURNOS SE GUARDAN. Los mas viejos se tiran enteros. Veinte es lo
36
+ * que cabe en la cabeza de alguien que intenta acordarse de que hizo ayer.
37
+ */
38
+ /** Por encima de esto no se copia: se anota que no hay copia y se dice. */
39
+ export declare const TOPE_POR_ARCHIVO: number;
40
+ /** Turnos que se conservan. Mas atras, nadie se acuerda de que paso. */
41
+ export declare const TURNOS_GUARDADOS = 20;
42
+ export declare const CARPETA: string;
43
+ export interface TurnoGuardado {
44
+ id: string;
45
+ /** ISO, no la hora local: esto se lee al dia siguiente y desde otra zona. */
46
+ cuando: string;
47
+ descripcion: string;
48
+ archivos: Array<{
49
+ ruta: string;
50
+ tipo: FileSnapshot["kind"];
51
+ /** Nombre del blob dentro de la carpeta del turno, si hay copia. */
52
+ copia?: string;
53
+ motivo?: string;
54
+ bytes?: number;
55
+ }>;
56
+ }
57
+ export declare function carpetaDeCambios(cwd: string): string;
58
+ /**
59
+ * Guarda un turno. Devuelve lo que NO se pudo copiar, para poder decirlo.
60
+ *
61
+ * No lanza nunca: si el disco esta lleno o la carpeta es de solo lectura, el
62
+ * turno del usuario no se puede caer por culpa del historial.
63
+ */
64
+ export declare function guardarTurno(cwd: string, punto: Checkpoint): {
65
+ guardado: boolean;
66
+ sinCopia: string[];
67
+ };
68
+ /** Los turnos guardados, del mas viejo al mas nuevo. Solo las fichas. */
69
+ export declare function turnosGuardados(cwd: string): TurnoGuardado[];
70
+ /** Recupera un turno con sus bytes, listo para que UndoManager lo deshaga. */
71
+ export declare function leerTurno(cwd: string, id: string): Checkpoint | null;
72
+ /** Un turno ya deshecho no tiene por que quedarse ocupando sitio. */
73
+ export declare function olvidarTurno(cwd: string, id: string): void;
74
+ /** Tira los turnos mas viejos que sobren del tope. */
75
+ export declare function podar(cwd: string, tope?: number): void;
76
+ /** Lo que ocupa el historial, para poder decirlo sin que nadie lo mida a mano. */
77
+ export declare function bytesOcupados(cwd: string): number;
@@ -0,0 +1,220 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * EL HISTORIAL DE CAMBIOS, EN DISCO
5
+ *
6
+ * DE DONDE SALE
7
+ *
8
+ * `/undo` estaba entero en memoria: `private checkpoints: Checkpoint[] = []`.
9
+ * O sea que la red de seguridad de un agente que edita y borra archivos solo
10
+ * duraba lo que durase la ventana del terminal. Cierras, o se cierra sola, y ya
11
+ * no hay vuelta atras de nada de lo que hizo.
12
+ *
13
+ * Y es al reves de como se usa: el momento en que uno quiere deshacer no es
14
+ * treinta segundos despues -ahi todavia estas mirando-, es al dia siguiente,
15
+ * cuando abres el proyecto y ves que falta algo.
16
+ *
17
+ * DONDE SE GUARDA
18
+ *
19
+ * En `<proyecto>/.chocolatito/cambios/`, al lado de la memoria del proyecto y
20
+ * no en el home: lo que se guarda son archivos DE ESE proyecto, y si el
21
+ * proyecto se borra o se mueve, su historial se va con el. Mezclarlos todos en
22
+ * el home dejaria copias de codigo de clientes distintos en el mismo cajon.
23
+ *
24
+ * QUE SE GUARDA, Y QUE NO
25
+ *
26
+ * Un turno es una carpeta: `meta.json` con la lista de rutas y un archivo por
27
+ * cada copia, numerado. Los bytes se guardan CRUDOS, sin codificar: por aqui
28
+ * pasan imagenes y videos, y un mp4 convertido a texto vuelve corrupto -ya pasó
29
+ * una vez con /undo y utf-8-.
30
+ *
31
+ * Con dos topes, porque esto crece en el disco de otro:
32
+ *
33
+ * - TAMANO POR ARCHIVO. Copiar un video de 500 MB en cada turno que lo toque
34
+ * llena el disco sin que nadie lo pida. Por encima del tope consta como
35
+ * "sin copia", que es exactamente lo que es, y se avisa en el momento.
36
+ * - CUANTOS TURNOS SE GUARDAN. Los mas viejos se tiran enteros. Veinte es lo
37
+ * que cabe en la cabeza de alguien que intenta acordarse de que hizo ayer.
38
+ */
39
+ /** Por encima de esto no se copia: se anota que no hay copia y se dice. */
40
+ export const TOPE_POR_ARCHIVO = 25 * 1024 * 1024;
41
+ /** Turnos que se conservan. Mas atras, nadie se acuerda de que paso. */
42
+ export const TURNOS_GUARDADOS = 20;
43
+ export const CARPETA = path.join(".chocolatito", "cambios");
44
+ /**
45
+ * Que esto no acabe en el repositorio de nadie.
46
+ *
47
+ * Son copias de archivos del proyecto: commitearlas seria subir el trabajo dos
48
+ * veces, y en un repositorio compartido, ensenarle a todo el equipo lo que
49
+ * habia antes de cada cambio.
50
+ *
51
+ * Se pone un .gitignore DENTRO de la carpeta, con un asterisco, en vez de tocar
52
+ * el .gitignore del usuario: editar un archivo suyo para instalar nuestra basura
53
+ * es justo lo que no se hace. Y va solo en cambios/, no en .chocolatito/ entero:
54
+ * la memoria del proyecto de al lado a lo mejor si la quiere commitear.
55
+ */
56
+ function asegurarQueGitLoIgnora(cwd) {
57
+ const marca = path.join(carpetaDeCambios(cwd), ".gitignore");
58
+ try {
59
+ if (!fs.existsSync(marca))
60
+ fs.writeFileSync(marca, "*" + String.fromCharCode(10), "utf-8");
61
+ }
62
+ catch { }
63
+ }
64
+ export function carpetaDeCambios(cwd) {
65
+ return path.join(cwd, CARPETA);
66
+ }
67
+ function carpetaDelTurno(cwd, id) {
68
+ return path.join(carpetaDeCambios(cwd), id);
69
+ }
70
+ /** Un nombre de archivo que no puede salirse de su carpeta ni chocar. */
71
+ function nombreDeCopia(indice, ruta) {
72
+ const ext = path.extname(ruta).replace(/[^\w.]/g, "").slice(0, 10);
73
+ return `${String(indice).padStart(3, "0")}${ext}`;
74
+ }
75
+ /**
76
+ * Guarda un turno. Devuelve lo que NO se pudo copiar, para poder decirlo.
77
+ *
78
+ * No lanza nunca: si el disco esta lleno o la carpeta es de solo lectura, el
79
+ * turno del usuario no se puede caer por culpa del historial.
80
+ */
81
+ export function guardarTurno(cwd, punto) {
82
+ const sinCopia = [];
83
+ try {
84
+ const dir = carpetaDelTurno(cwd, punto.id);
85
+ fs.mkdirSync(dir, { recursive: true });
86
+ asegurarQueGitLoIgnora(cwd);
87
+ const meta = {
88
+ id: punto.id,
89
+ cuando: new Date().toISOString(),
90
+ descripcion: punto.description,
91
+ archivos: [],
92
+ };
93
+ let indice = 0;
94
+ for (const foto of punto.snapshots) {
95
+ indice++;
96
+ if (foto.kind === "absent") {
97
+ meta.archivos.push({ ruta: foto.filePath, tipo: "absent" });
98
+ continue;
99
+ }
100
+ if (foto.kind === "unrestorable") {
101
+ meta.archivos.push({ ruta: foto.filePath, tipo: "unrestorable", motivo: foto.reason });
102
+ sinCopia.push(`${path.basename(foto.filePath)}: ${foto.reason}`);
103
+ continue;
104
+ }
105
+ if (foto.previousContent.length > TOPE_POR_ARCHIVO) {
106
+ const motivo = `ocupa ${Math.round(foto.previousContent.length / 1024 / 1024)} MB y el historial no guarda archivos tan grandes`;
107
+ meta.archivos.push({ ruta: foto.filePath, tipo: "unrestorable", motivo });
108
+ sinCopia.push(`${path.basename(foto.filePath)}: ${motivo}`);
109
+ continue;
110
+ }
111
+ const copia = nombreDeCopia(indice, foto.filePath);
112
+ fs.writeFileSync(path.join(dir, copia), foto.previousContent);
113
+ meta.archivos.push({
114
+ ruta: foto.filePath,
115
+ tipo: "file",
116
+ copia,
117
+ bytes: foto.previousContent.length,
118
+ });
119
+ }
120
+ fs.writeFileSync(path.join(dir, "meta.json"), JSON.stringify(meta, null, 2), "utf-8");
121
+ podar(cwd);
122
+ return { guardado: true, sinCopia };
123
+ }
124
+ catch {
125
+ return { guardado: false, sinCopia };
126
+ }
127
+ }
128
+ /** Los turnos guardados, del mas viejo al mas nuevo. Solo las fichas. */
129
+ export function turnosGuardados(cwd) {
130
+ const dir = carpetaDeCambios(cwd);
131
+ const turnos = [];
132
+ try {
133
+ for (const entrada of fs.readdirSync(dir)) {
134
+ try {
135
+ const meta = JSON.parse(fs.readFileSync(path.join(dir, entrada, "meta.json"), "utf-8"));
136
+ if (meta?.id)
137
+ turnos.push(meta);
138
+ }
139
+ catch {
140
+ // Una carpeta a medias -se corto la luz al guardar- no puede tumbar la
141
+ // lista entera: se salta y ya.
142
+ }
143
+ }
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ return turnos.sort((a, b) => (a.cuando || "").localeCompare(b.cuando || ""));
149
+ }
150
+ /** Recupera un turno con sus bytes, listo para que UndoManager lo deshaga. */
151
+ export function leerTurno(cwd, id) {
152
+ const dir = carpetaDelTurno(cwd, id);
153
+ let meta;
154
+ try {
155
+ meta = JSON.parse(fs.readFileSync(path.join(dir, "meta.json"), "utf-8"));
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ const snapshots = [];
161
+ for (const a of meta.archivos || []) {
162
+ if (a.tipo === "absent") {
163
+ snapshots.push({ filePath: a.ruta, kind: "absent" });
164
+ continue;
165
+ }
166
+ if (a.tipo === "unrestorable" || !a.copia) {
167
+ snapshots.push({ filePath: a.ruta, kind: "unrestorable", reason: a.motivo || "sin copia" });
168
+ continue;
169
+ }
170
+ try {
171
+ snapshots.push({ filePath: a.ruta, kind: "file", previousContent: fs.readFileSync(path.join(dir, a.copia)) });
172
+ }
173
+ catch {
174
+ // La copia existia al guardarla y ahora no se puede leer: se dice, no se
175
+ // inventa un archivo vacio encima del que hay.
176
+ snapshots.push({
177
+ filePath: a.ruta,
178
+ kind: "unrestorable",
179
+ reason: "la copia del historial no se pudo leer",
180
+ });
181
+ }
182
+ }
183
+ return { id: meta.id, timestamp: meta.cuando, description: meta.descripcion, snapshots };
184
+ }
185
+ /** Un turno ya deshecho no tiene por que quedarse ocupando sitio. */
186
+ export function olvidarTurno(cwd, id) {
187
+ try {
188
+ fs.rmSync(carpetaDelTurno(cwd, id), { recursive: true, force: true });
189
+ }
190
+ catch { }
191
+ }
192
+ /** Tira los turnos mas viejos que sobren del tope. */
193
+ export function podar(cwd, tope = TURNOS_GUARDADOS) {
194
+ const turnos = turnosGuardados(cwd);
195
+ if (turnos.length <= tope)
196
+ return;
197
+ for (const viejo of turnos.slice(0, turnos.length - tope))
198
+ olvidarTurno(cwd, viejo.id);
199
+ }
200
+ /** Lo que ocupa el historial, para poder decirlo sin que nadie lo mida a mano. */
201
+ export function bytesOcupados(cwd) {
202
+ let total = 0;
203
+ const dir = carpetaDeCambios(cwd);
204
+ try {
205
+ for (const turno of fs.readdirSync(dir)) {
206
+ const sub = path.join(dir, turno);
207
+ try {
208
+ for (const archivo of fs.readdirSync(sub)) {
209
+ try {
210
+ total += fs.statSync(path.join(sub, archivo)).size;
211
+ }
212
+ catch { }
213
+ }
214
+ }
215
+ catch { }
216
+ }
217
+ }
218
+ catch { }
219
+ return total;
220
+ }
@@ -30,6 +30,14 @@ export interface SalidaDelAgente {
30
30
  /** Escritura sin salto, para separar bloques. Equivale a process.stdout.write. */
31
31
  crudo(texto: string): void;
32
32
  }
33
+ /**
34
+ * La copia al navegador, sin que este modulo dependa del servidor web.
35
+ *
36
+ * Se registra desde fuera (index.ts, al arrancar --servir). Asi una sesion
37
+ * normal de terminal no carga nada del servidor ni paga por tenerlo.
38
+ */
39
+ type Copia = (texto: string) => void;
40
+ export declare function copiarSalidaAlWeb(fn: Copia | null): void;
33
41
  /** Lo que usa el bucle. No sabe ni le importa quien esta al otro lado. */
34
42
  export declare const salida: SalidaDelAgente;
35
43
  /**
@@ -41,3 +49,4 @@ export declare const salida: SalidaDelAgente;
41
49
  export declare function dirigirSalidaDelAgente(destino: SalidaDelAgente | null): void;
42
50
  /** Quita los codigos de color, para quien pinte el texto a su manera. */
43
51
  export declare function sinColores(texto: string): string;
52
+ export {};
@@ -13,11 +13,30 @@ import { escribirEncimaDelPie } from "../ui/pieFijo.js";
13
13
  const A_LA_TERMINAL = {
14
14
  linea: (texto) => {
15
15
  escribirEncimaDelPie(`${texto}\n`);
16
+ // Y copia para el navegador, si hay sesion web abierta. Sin esto, lo que
17
+ // contesta el agente no sale del terminal: en el movil la sesion parece
18
+ // viva y muda. Ver servidor/captura.ts.
19
+ copiarAlWeb(`${texto}\n`);
16
20
  },
17
21
  crudo: (texto) => {
18
22
  escribirEncimaDelPie(texto);
23
+ copiarAlWeb(texto);
19
24
  },
20
25
  };
26
+ let copia = null;
27
+ export function copiarSalidaAlWeb(fn) {
28
+ copia = fn;
29
+ }
30
+ function copiarAlWeb(texto) {
31
+ if (!copia)
32
+ return;
33
+ try {
34
+ copia(texto);
35
+ }
36
+ catch {
37
+ // El navegador no puede tumbar la salida del terminal.
38
+ }
39
+ }
21
40
  let actual = A_LA_TERMINAL;
22
41
  /** Lo que usa el bucle. No sabe ni le importa quien esta al otro lado. */
23
42
  export const salida = {
@@ -1,3 +1,4 @@
1
+ import { type TurnoGuardado } from "./historialDeCambios.js";
1
2
  /**
2
3
  * Estado de una ruta justo antes de que el turno la tocara.
3
4
  *
@@ -40,6 +41,15 @@ export interface UndoResult {
40
41
  export declare class UndoManager {
41
42
  private checkpoints;
42
43
  private currentTurnSnapshots;
44
+ /**
45
+ * La carpeta del proyecto, para saber DONDE vive el historial en disco.
46
+ *
47
+ * Se fija desde fuera (index.ts) y se actualiza con /cd: el historial es de
48
+ * cada proyecto, asi que al cambiar de carpeta se cambia de historial. Si
49
+ * nadie la fija, esto funciona como siempre y solo en memoria.
50
+ */
51
+ private carpeta;
52
+ fijarCarpeta(cwd: string | null): void;
43
53
  /**
44
54
  * Fotografia una ruta antes de modificarla o borrarla.
45
55
  *
@@ -52,6 +62,17 @@ export declare class UndoManager {
52
62
  * Finalizes the current turn and saves a rollback checkpoint
53
63
  */
54
64
  commitTurn(description: string): void;
65
+ /** Los turnos que se pueden deshacer, del mas viejo al mas nuevo. */
66
+ turnos(): TurnoGuardado[];
67
+ /**
68
+ * Vuelve a como estaba ANTES de un turno concreto.
69
+ *
70
+ * Deshace ese turno y todos los de despues, del mas nuevo al mas viejo. El
71
+ * orden no es un detalle: si un archivo se toco en tres turnos seguidos, la
72
+ * copia buena es la del turno MAS VIEJO, y solo se llega a ella deshaciendo
73
+ * los de encima primero. Al reves quedaria el archivo como estaba a mitad.
74
+ */
75
+ volverAntesDe(id: string): UndoResult;
55
76
  /**
56
77
  * Deshace el ultimo turno de modificaciones.
57
78
  *
@@ -1,8 +1,20 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { guardarTurno, leerTurno, olvidarTurno, turnosGuardados, } from "./historialDeCambios.js";
3
4
  export class UndoManager {
4
5
  checkpoints = [];
5
6
  currentTurnSnapshots = new Map();
7
+ /**
8
+ * La carpeta del proyecto, para saber DONDE vive el historial en disco.
9
+ *
10
+ * Se fija desde fuera (index.ts) y se actualiza con /cd: el historial es de
11
+ * cada proyecto, asi que al cambiar de carpeta se cambia de historial. Si
12
+ * nadie la fija, esto funciona como siempre y solo en memoria.
13
+ */
14
+ carpeta = null;
15
+ fijarCarpeta(cwd) {
16
+ this.carpeta = cwd;
17
+ }
6
18
  /**
7
19
  * Fotografia una ruta antes de modificarla o borrarla.
8
20
  *
@@ -71,18 +83,73 @@ export class UndoManager {
71
83
  this.currentTurnSnapshots.forEach((state, filePath) => {
72
84
  snapshots.push({ filePath, ...state });
73
85
  });
74
- this.checkpoints.push({
86
+ const punto = {
75
87
  id: `chk_${Date.now()}`,
76
88
  timestamp: new Date().toLocaleTimeString(),
77
89
  description,
78
90
  snapshots,
79
- });
91
+ };
92
+ this.checkpoints.push(punto);
93
+ // Y a disco, para que siga estando manana. Si falla -disco lleno, carpeta
94
+ // de solo lectura- no se dice nada aqui: el turno del usuario no se puede
95
+ // caer por culpa del historial, y en memoria sigue estando.
96
+ if (this.carpeta)
97
+ guardarTurno(this.carpeta, punto);
80
98
  this.currentTurnSnapshots.clear();
81
99
  // Keep maximum 10 recent checkpoints in memory
82
100
  if (this.checkpoints.length > 10) {
83
101
  this.checkpoints.shift();
84
102
  }
85
103
  }
104
+ /** Los turnos que se pueden deshacer, del mas viejo al mas nuevo. */
105
+ turnos() {
106
+ return this.carpeta ? turnosGuardados(this.carpeta) : [];
107
+ }
108
+ /**
109
+ * Vuelve a como estaba ANTES de un turno concreto.
110
+ *
111
+ * Deshace ese turno y todos los de despues, del mas nuevo al mas viejo. El
112
+ * orden no es un detalle: si un archivo se toco en tres turnos seguidos, la
113
+ * copia buena es la del turno MAS VIEJO, y solo se llega a ella deshaciendo
114
+ * los de encima primero. Al reves quedaria el archivo como estaba a mitad.
115
+ */
116
+ volverAntesDe(id) {
117
+ if (!this.carpeta) {
118
+ return { success: false, message: "No hay historial en disco para este proyecto.", restoredFiles: [], failures: [] };
119
+ }
120
+ const guardados = turnosGuardados(this.carpeta);
121
+ const desde = guardados.findIndex((t) => t.id === id);
122
+ if (desde < 0) {
123
+ return { success: false, message: `No encuentro el turno ${id}.`, restoredFiles: [], failures: [] };
124
+ }
125
+ const restoredFiles = [];
126
+ const failures = [];
127
+ let deshechos = 0;
128
+ for (let i = guardados.length - 1; i >= desde; i--) {
129
+ const punto = leerTurno(this.carpeta, guardados[i].id);
130
+ if (!punto) {
131
+ failures.push(`(Sin copia) el turno ${guardados[i].id} no se pudo leer`);
132
+ continue;
133
+ }
134
+ this.checkpoints.push(punto);
135
+ const res = this.undoLast();
136
+ restoredFiles.push(...res.restoredFiles);
137
+ failures.push(...res.failures);
138
+ if (res.success)
139
+ deshechos++;
140
+ }
141
+ const cuando = new Date(guardados[desde].cuando).toLocaleString();
142
+ return {
143
+ success: failures.length === 0 && restoredFiles.length > 0,
144
+ message: failures.length > 0
145
+ ? `⚠ Vuelta a medias a antes de "${guardados[desde].descripcion}" (${cuando}): ${failures.length} ruta(s) sin restaurar`
146
+ : restoredFiles.length === 0
147
+ ? `Nada que deshacer: los archivos ya estaban como antes de "${guardados[desde].descripcion}".`
148
+ : `✔ Vuelto a antes de "${guardados[desde].descripcion}" (${cuando}): ${deshechos} turno(s) deshechos`,
149
+ restoredFiles,
150
+ failures,
151
+ };
152
+ }
86
153
  /**
87
154
  * Deshace el ultimo turno de modificaciones.
88
155
  *
@@ -91,15 +158,24 @@ export class UndoManager {
91
158
  * peor mensaje posible: el usuario da por recuperado lo que ya no existe.
92
159
  */
93
160
  undoLast() {
94
- if (this.checkpoints.length === 0) {
161
+ // Si en memoria no hay nada, se mira el disco: la sesion de ayer tambien
162
+ // cuenta. Es justo el caso que importa, porque uno no deshace treinta
163
+ // segundos despues -ahi todavia esta mirando- sino al dia siguiente.
164
+ let checkpoint = this.checkpoints.pop();
165
+ if (!checkpoint && this.carpeta) {
166
+ const guardados = turnosGuardados(this.carpeta);
167
+ const ultimo = guardados[guardados.length - 1];
168
+ if (ultimo)
169
+ checkpoint = leerTurno(this.carpeta, ultimo.id) ?? undefined;
170
+ }
171
+ if (!checkpoint) {
95
172
  return {
96
173
  success: false,
97
- message: "No hay acciones previas para deshacer en esta sesión.",
174
+ message: "No hay acciones previas para deshacer.",
98
175
  restoredFiles: [],
99
176
  failures: [],
100
177
  };
101
178
  }
102
- const checkpoint = this.checkpoints.pop();
103
179
  const restoredFiles = [];
104
180
  const failures = [];
105
181
  for (const snap of checkpoint.snapshots) {
@@ -128,6 +204,10 @@ export class UndoManager {
128
204
  failures.push(`(Error restaurando) ${nombre}: ${err?.message}`);
129
205
  }
130
206
  }
207
+ // Deshecho es deshecho: la copia ya no pinta nada y ocupa sitio en el
208
+ // proyecto de otro.
209
+ if (this.carpeta)
210
+ olvidarTurno(this.carpeta, checkpoint.id);
131
211
  const cabecera = `"${checkpoint.description}" (${checkpoint.timestamp})`;
132
212
  if (failures.length > 0) {
133
213
  return {