Skip to main content

xei_core/
undo.rs

1//! Delta-based undo with SSD spill + optional on-close persistence.
2//!
3//! Memory model (replaces full-buffer snapshots that grew O(edits × file)):
4//! - each history entry is a **line-range delta** (changed lines only)
5//! - one full snapshot (`last`, Arc-shared) anchors the live end of the chain
6//! - only the newest [`IN_RAM_MAX`] deltas stay in RAM; older ones spill to
7//!   `~/.xei/undo/<fnv(path)>.undo` and stream back in on deep undo
8//! - `undo_caching = true` keeps the spill file on close (plus a `.meta`
9//!   content hash) so reopening the same, unchanged file resumes its history;
10//!   `false` (default) deletes it
11//!
12//! The public API mirrors the old snapshot stack (`push` the pre-edit state,
13//! `undo/redo` exchange full snapshots) so call sites stay untouched.
14
15use std::io::Write;
16use std::path::{Path, PathBuf};
17
18use crate::buffer::{BufferSnapshot, Position};
19
20/// Newest deltas kept in RAM; older ones go to the spill file.
21pub const IN_RAM_MAX: usize = 50;
22/// Safety cap for unnamed buffers (no spill target): drop oldest beyond this.
23const NO_SPILL_MAX: usize = 500;
24
25/// One edit as a reversible line-range patch.
26#[derive(Clone, Debug)]
27struct EditDelta {
28    /// First differing line index.
29    start: usize,
30    /// Lines this range held *before* the edit (apply to undo).
31    old: Vec<String>,
32    /// Lines this range holds *after* the edit (apply to redo).
33    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    /// Anchor: the most recent state the stack has seen (Arc → cheap tab clones).
43    last: Option<std::sync::Arc<BufferSnapshot>>,
44    /// Spill file for entries beyond IN_RAM_MAX (None for unnamed buffers).
45    spill_path: Option<PathBuf>,
46    /// Byte offset of each spilled record (oldest → newest).
47    spill_offsets: Vec<u64>,
48}
49
50impl UndoStack {
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// Record the state *before* a mutating edit. Consecutive pushes diff into
56    /// a delta; identical states are discarded (no more wasted `i`+Esc slots).
57    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    /// Undo: absorb any uncommitted edit, then walk one delta back.
69    pub fn undo(&mut self, current: BufferSnapshot) -> Option<BufferSnapshot> {
70        self.absorb_tail(&current);
71        let delta = match self.past.pop() {
72            Some(d) => d,
73            None => self.unspill_one()?,
74        };
75        let prev = apply_delta(&current, &delta, false);
76        self.future.push(delta);
77        self.last = Some(std::sync::Arc::new(prev.clone()));
78        Some(prev)
79    }
80
81    /// Redo the most recently undone delta.
82    pub fn redo(&mut self, current: BufferSnapshot) -> Option<BufferSnapshot> {
83        let delta = self.future.pop()?;
84        let next = apply_delta(&current, &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    /// The buffer changed after the last `push` without another push (edits in
100    /// flight when `u` is hit) — capture that edit so it undoes first.
101    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    // ── Spill: oldest deltas move to disk ──────────────────────────────
113
114    /// Bind this stack to a file (spill target). Optionally resume a cached
115    /// history when the on-disk content hash still matches `text`.
116    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            // Unnamed buffer — keep a hard cap instead of unbounded RAM.
134            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    /// Pull the newest spilled record back off disk.
149    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        // Truncate so the file stays a clean stack.
154        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    /// File is closing: persist the whole history (undo_caching = true) or
161    /// remove the session spill (false).
162    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
182// ── Diff / apply ───────────────────────────────────────────────────────────
183
184/// Line-range diff via common prefix/suffix trim. None when identical.
185fn 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; // identical content
194    }
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
210/// Rebuild the neighbouring state from `current` and a delta.
211fn 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
227// ── Spill file format ──────────────────────────────────────────────────────
228//
229// Buffer lines never contain `\n`, so a line-oriented record is unambiguous:
230//   @ <start> <n_old> <n_new> <cor> <coc> <cnr> <cnc>
231//   …n_old old lines…
232//   …n_new new lines…
233
234fn 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
293/// Offsets of every record in an existing spill file (resume path).
294fn 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; // corrupt tail — ignore the rest
304        };
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, // truncated body — keep what parsed
321            }
322        }
323    }
324    offsets
325}
326
327// ── Cache identity ─────────────────────────────────────────────────────────
328
329fn 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
358/// Cached history is only valid while the file content is unchanged.
359fn 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// ── Tests ──────────────────────────────────────────────────────────────────
367
368#[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)); // initial
383        u.push(snap("alpha\nbeta\ngamma", 1, 0)); // pre-edit (same → no delta)
384        // edit happened: beta → BETA
385        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)); // i + Esc, no typing
397        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()); // next pre-edit commits the growth delta
407        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        // 80 edits → 30 must spill to disk (IN_RAM_MAX = 50).
425        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        // absorb final edit then walk all 80 back.
433        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)); // delta v1→v2 committed
458        u.finish(true, "v2");
459
460        // Reopen same content → history resumes from disk.
461        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        // Changed content → cache invalidated.
468        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}