1use std::io::Write;
16use std::path::{Path, PathBuf};
17
18use crate::buffer::{BufferSnapshot, Position};
19
20pub const IN_RAM_MAX: usize = 50;
22const NO_SPILL_MAX: usize = 500;
24
25#[derive(Clone, Debug)]
27struct EditDelta {
28 start: usize,
30 old: Vec<String>,
32 new: Vec<String>,
34 cursor_old: Position,
35 cursor_new: Position,
36}
37
38#[derive(Clone, Default)]
39pub struct UndoStack {
40 past: Vec<EditDelta>,
41 future: Vec<EditDelta>,
42 last: Option<std::sync::Arc<BufferSnapshot>>,
44 spill_path: Option<PathBuf>,
46 spill_offsets: Vec<u64>,
48}
49
50impl UndoStack {
51 pub fn new() -> Self {
52 Self::default()
53 }
54
55 pub fn push(&mut self, snapshot: BufferSnapshot) {
58 if let Some(prev) = self.last.clone() {
59 if let Some(delta) = diff_snapshots(&prev, &snapshot) {
60 self.past.push(delta);
61 self.future.clear();
62 self.spill_overflow();
63 }
64 }
65 self.last = Some(std::sync::Arc::new(snapshot));
66 }
67
68 pub fn undo(&mut self, current: BufferSnapshot) -> Option<BufferSnapshot> {
70 self.absorb_tail(¤t);
71 let delta = match self.past.pop() {
72 Some(d) => d,
73 None => self.unspill_one()?,
74 };
75 let prev = apply_delta(¤t, &delta, false);
76 self.future.push(delta);
77 self.last = Some(std::sync::Arc::new(prev.clone()));
78 Some(prev)
79 }
80
81 pub fn redo(&mut self, current: BufferSnapshot) -> Option<BufferSnapshot> {
83 let delta = self.future.pop()?;
84 let next = apply_delta(¤t, &delta, true);
85 self.past.push(delta);
86 self.spill_overflow();
87 self.last = Some(std::sync::Arc::new(next.clone()));
88 Some(next)
89 }
90
91 pub fn can_undo(&self) -> bool {
92 !self.past.is_empty() || !self.spill_offsets.is_empty()
93 }
94
95 pub fn can_redo(&self) -> bool {
96 !self.future.is_empty()
97 }
98
99 fn absorb_tail(&mut self, current: &BufferSnapshot) {
102 if let Some(prev) = self.last.clone() {
103 if let Some(delta) = diff_snapshots(&prev, current) {
104 self.past.push(delta);
105 self.future.clear();
106 self.spill_overflow();
107 }
108 }
109 self.last = Some(std::sync::Arc::new(current.clone()));
110 }
111
112 pub fn attach_file(&mut self, path: &Path, caching: bool, text: &str) {
117 let spill = spill_file_for(path);
118 self.spill_path = Some(spill.clone());
119 self.spill_offsets.clear();
120 if caching && meta_matches(&spill, text) {
121 self.spill_offsets = scan_offsets(&spill);
122 } else {
123 let _ = std::fs::remove_file(&spill);
124 let _ = std::fs::remove_file(meta_path(&spill));
125 }
126 }
127
128 fn spill_overflow(&mut self) {
129 if self.past.len() <= IN_RAM_MAX {
130 return;
131 }
132 let Some(path) = self.spill_path.clone() else {
133 while self.past.len() > NO_SPILL_MAX {
135 self.past.remove(0);
136 }
137 return;
138 };
139 let _ = std::fs::create_dir_all(path.parent().unwrap_or(Path::new(".")));
140 while self.past.len() > IN_RAM_MAX {
141 let oldest = self.past.remove(0);
142 if let Some(off) = append_record(&path, &oldest) {
143 self.spill_offsets.push(off);
144 }
145 }
146 }
147
148 fn unspill_one(&mut self) -> Option<EditDelta> {
150 let path = self.spill_path.clone()?;
151 let off = self.spill_offsets.pop()?;
152 let delta = read_record_at(&path, off)?;
153 if let Ok(f) = std::fs::OpenOptions::new().write(true).open(&path) {
155 let _ = f.set_len(off);
156 }
157 Some(delta)
158 }
159
160 pub fn finish(&mut self, caching: bool, text: &str) {
163 let Some(path) = self.spill_path.clone() else {
164 return;
165 };
166 if caching {
167 let _ = std::fs::create_dir_all(path.parent().unwrap_or(Path::new(".")));
168 let drained: Vec<EditDelta> = std::mem::take(&mut self.past);
169 for d in drained {
170 if let Some(off) = append_record(&path, &d) {
171 self.spill_offsets.push(off);
172 }
173 }
174 write_meta(&path, text);
175 } else {
176 let _ = std::fs::remove_file(&path);
177 let _ = std::fs::remove_file(meta_path(&path));
178 }
179 }
180}
181
182fn diff_snapshots(a: &BufferSnapshot, b: &BufferSnapshot) -> Option<EditDelta> {
186 let (al, bl) = (a.lines(), b.lines());
187 let mut start = 0;
188 let max_start = al.len().min(bl.len());
189 while start < max_start && al[start] == bl[start] {
190 start += 1;
191 }
192 if start == al.len() && start == bl.len() {
193 return None; }
195 let mut a_end = al.len();
196 let mut b_end = bl.len();
197 while a_end > start && b_end > start && al[a_end - 1] == bl[b_end - 1] {
198 a_end -= 1;
199 b_end -= 1;
200 }
201 Some(EditDelta {
202 start,
203 old: al[start..a_end].to_vec(),
204 new: bl[start..b_end].to_vec(),
205 cursor_old: a.cursor(),
206 cursor_new: b.cursor(),
207 })
208}
209
210fn apply_delta(current: &BufferSnapshot, d: &EditDelta, forward: bool) -> BufferSnapshot {
212 let (replace_with, expect_len, cursor) = if forward {
213 (&d.new, d.old.len(), d.cursor_new)
214 } else {
215 (&d.old, d.new.len(), d.cursor_old)
216 };
217 let mut lines = current.lines().to_vec();
218 let end = (d.start + expect_len).min(lines.len());
219 let start = d.start.min(lines.len());
220 lines.splice(start..end, replace_with.iter().cloned());
221 if lines.is_empty() {
222 lines.push(String::new());
223 }
224 BufferSnapshot::from_parts(lines, cursor)
225}
226
227fn append_record(path: &Path, d: &EditDelta) -> Option<u64> {
235 let mut f = std::fs::OpenOptions::new()
236 .create(true)
237 .append(true)
238 .open(path)
239 .ok()?;
240 let off = f.metadata().ok()?.len();
241 let mut buf = format!(
242 "@ {} {} {} {} {} {} {}\n",
243 d.start,
244 d.old.len(),
245 d.new.len(),
246 d.cursor_old.row,
247 d.cursor_old.col,
248 d.cursor_new.row,
249 d.cursor_new.col
250 );
251 for l in &d.old {
252 buf.push_str(l);
253 buf.push('\n');
254 }
255 for l in &d.new {
256 buf.push_str(l);
257 buf.push('\n');
258 }
259 f.write_all(buf.as_bytes()).ok()?;
260 Some(off)
261}
262
263fn read_record_at(path: &Path, off: u64) -> Option<EditDelta> {
264 let data = std::fs::read_to_string(path).ok()?;
265 let rec = data.get(off as usize..)?;
266 let mut it = rec.lines();
267 let header = it.next()?;
268 let mut h = header.strip_prefix("@ ")?.split_whitespace();
269 let start: usize = h.next()?.parse().ok()?;
270 let n_old: usize = h.next()?.parse().ok()?;
271 let n_new: usize = h.next()?.parse().ok()?;
272 let cor: usize = h.next()?.parse().ok()?;
273 let coc: usize = h.next()?.parse().ok()?;
274 let cnr: usize = h.next()?.parse().ok()?;
275 let cnc: usize = h.next()?.parse().ok()?;
276 let mut old = Vec::with_capacity(n_old);
277 for _ in 0..n_old {
278 old.push(it.next()?.to_string());
279 }
280 let mut new = Vec::with_capacity(n_new);
281 for _ in 0..n_new {
282 new.push(it.next()?.to_string());
283 }
284 Some(EditDelta {
285 start,
286 old,
287 new,
288 cursor_old: Position::new(cor, coc),
289 cursor_new: Position::new(cnr, cnc),
290 })
291}
292
293fn scan_offsets(path: &Path) -> Vec<u64> {
295 let Ok(data) = std::fs::read_to_string(path) else {
296 return Vec::new();
297 };
298 let mut offsets = Vec::new();
299 let mut off = 0u64;
300 let mut lines = data.split_inclusive('\n');
301 while let Some(header) = lines.next() {
302 let Some(h) = header.trim_end().strip_prefix("@ ") else {
303 break; };
305 let mut parts = h.split_whitespace();
306 let (Some(_), Some(n_old), Some(n_new)) =
307 (parts.next(), parts.next(), parts.next())
308 else {
309 break;
310 };
311 let (Ok(n_old), Ok(n_new)) = (n_old.parse::<usize>(), n_new.parse::<usize>())
312 else {
313 break;
314 };
315 offsets.push(off);
316 off += header.len() as u64;
317 for _ in 0..(n_old + n_new) {
318 match lines.next() {
319 Some(l) => off += l.len() as u64,
320 None => return offsets, }
322 }
323 }
324 offsets
325}
326
327fn fnv64(s: &str) -> u64 {
330 let mut h: u64 = 0xcbf29ce484222325;
331 for b in s.as_bytes() {
332 h ^= *b as u64;
333 h = h.wrapping_mul(0x100000001b3);
334 }
335 h
336}
337
338fn undo_dir() -> PathBuf {
339 let home = std::env::var("HOME")
340 .or_else(|_| std::env::var("USERPROFILE"))
341 .unwrap_or_else(|_| ".".into());
342 PathBuf::from(home).join(".xei").join("undo")
343}
344
345fn spill_file_for(path: &Path) -> PathBuf {
346 let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
347 undo_dir().join(format!("{:016x}.undo", fnv64(&abs.display().to_string())))
348}
349
350fn meta_path(spill: &Path) -> PathBuf {
351 spill.with_extension("meta")
352}
353
354fn write_meta(spill: &Path, text: &str) {
355 let _ = std::fs::write(meta_path(spill), format!("v1 {:016x}\n", fnv64(text)));
356}
357
358fn meta_matches(spill: &Path, text: &str) -> bool {
360 let Ok(meta) = std::fs::read_to_string(meta_path(spill)) else {
361 return false;
362 };
363 meta.trim() == format!("v1 {:016x}", fnv64(text))
364}
365
366#[cfg(test)]
369mod tests {
370 use super::*;
371 use crate::buffer::Buffer;
372
373 fn snap(text: &str, row: usize, col: usize) -> BufferSnapshot {
374 let mut b = Buffer::from_string(text);
375 b.cursor = Position::new(row, col);
376 b.snapshot()
377 }
378
379 #[test]
380 fn delta_roundtrip_single_line() {
381 let mut u = UndoStack::new();
382 u.push(snap("alpha\nbeta\ngamma", 1, 0)); u.push(snap("alpha\nbeta\ngamma", 1, 0)); let cur = snap("alpha\nBETA\ngamma", 1, 4);
386 let back = u.undo(cur.clone()).expect("undo");
387 assert_eq!(back.lines()[1], "beta");
388 let fwd = u.redo(back).expect("redo");
389 assert_eq!(fwd.lines()[1], "BETA");
390 }
391
392 #[test]
393 fn noop_push_consumes_nothing() {
394 let mut u = UndoStack::new();
395 u.push(snap("x", 0, 0));
396 u.push(snap("x", 0, 0)); u.push(snap("x", 0, 0));
398 assert!(!u.can_undo());
399 }
400
401 #[test]
402 fn insert_and_delete_lines() {
403 let mut u = UndoStack::new();
404 u.push(snap("a\nb", 0, 0));
405 let grown = snap("a\nnew1\nnew2\nb", 2, 0);
406 u.push(grown.clone()); let shrunk = snap("a", 0, 0);
408 let mid = u.undo(shrunk).expect("undo shrink");
409 assert_eq!(mid.lines(), grown.lines());
410 let orig = u.undo(mid).expect("undo growth");
411 assert_eq!(orig.lines(), ["a", "b"]);
412 assert!(!u.can_undo());
413 }
414
415 #[test]
416 fn spill_and_deep_undo() {
417 let dir = std::env::temp_dir().join(format!("xei-undo-test-{}", std::process::id()));
418 let _ = std::fs::create_dir_all(&dir);
419 let file = dir.join("doc.txt");
420 std::fs::write(&file, "seed").unwrap();
421
422 let mut u = UndoStack::new();
423 u.attach_file(&file, false, "seed");
424 let mut text = String::from("line0");
426 u.push(snap(&text, 0, 0));
427 for i in 1..=80 {
428 let next = format!("{text}\nline{i}");
429 u.push(snap(&next, 0, 0));
430 text = next;
431 }
432 let mut cur = snap(&text, 0, 0);
434 let mut steps = 0;
435 while let Some(prev) = u.undo(cur.clone()) {
436 cur = prev;
437 steps += 1;
438 if steps > 200 {
439 panic!("undo runaway");
440 }
441 }
442 assert_eq!(cur.lines(), ["line0"]);
443 assert_eq!(steps, 80);
444 let _ = std::fs::remove_dir_all(&dir);
445 }
446
447 #[test]
448 fn persist_and_resume() {
449 let dir = std::env::temp_dir().join(format!("xei-undo-res-{}", std::process::id()));
450 let _ = std::fs::create_dir_all(&dir);
451 let file = dir.join("doc.txt");
452 std::fs::write(&file, "v2").unwrap();
453
454 let mut u = UndoStack::new();
455 u.attach_file(&file, true, "v2");
456 u.push(snap("v1", 0, 0));
457 u.push(snap("v2", 0, 0)); u.finish(true, "v2");
459
460 let mut u2 = UndoStack::new();
462 u2.attach_file(&file, true, "v2");
463 assert!(u2.can_undo(), "cached history should resume");
464 let back = u2.undo(snap("v2", 0, 0)).expect("undo from cache");
465 assert_eq!(back.lines(), ["v1"]);
466
467 let mut u3 = UndoStack::new();
469 u3.attach_file(&file, true, "v2-changed-outside");
470 assert!(!u3.can_undo(), "stale cache must be dropped");
471 let _ = std::fs::remove_dir_all(&dir);
472 }
473
474 #[test]
475 fn finish_without_caching_removes_spill() {
476 let dir = std::env::temp_dir().join(format!("xei-undo-rm-{}", std::process::id()));
477 let _ = std::fs::create_dir_all(&dir);
478 let file = dir.join("doc.txt");
479 std::fs::write(&file, "x").unwrap();
480 let mut u = UndoStack::new();
481 u.attach_file(&file, false, "x");
482 let mut text = String::from("l0");
483 u.push(snap(&text, 0, 0));
484 for i in 1..=60 {
485 let next = format!("{text}\nl{i}");
486 u.push(snap(&next, 0, 0));
487 text = next;
488 }
489 let spill = spill_file_for(&file);
490 assert!(spill.exists(), "overflow should have spilled");
491 u.finish(false, &text);
492 assert!(!spill.exists(), "no-caching close must clean up");
493 let _ = std::fs::remove_dir_all(&dir);
494 }
495}