Skip to main content

termesh_editor/
history.rs

1//! Undo/redo over the transaction log (ADR-0006 §6).
2//!
3//! One history for every author. A keyboard edit, a formatter run, and an accepted agent
4//! proposal all land in the same log, so undo means the same thing regardless of who made
5//! the change — which is the property that makes agent edits reviewable rather than
6//! frightening.
7//!
8//! The history holds no reference to a document. It hands back a [`ChangeSet`] and the
9//! caller applies it, which keeps undo testable with no rope and no buffer.
10
11use crate::change::ChangeSet;
12use crate::transaction::{EditSource, EditTransaction, UndoGroupId};
13
14#[derive(Debug, Clone)]
15struct Entry {
16    group: UndoGroupId,
17    forward: ChangeSet,
18    /// Computed at *apply* time, while the pre-image was still live (ADR-0006 §6).
19    inverse: ChangeSet,
20}
21
22/// The linear undo log.
23///
24/// ARCHITECTURE.md §8 wants one undo path for all sources; a linear stack delivers that.
25/// Helix's undo *tree* is strictly more powerful, and is additive on top of this log if
26/// V1 ever needs it — the log is the hard part and we are building it either way.
27#[derive(Debug, Default)]
28pub struct History {
29    applied: Vec<Entry>,
30    /// Undone entries, newest last. Cleared as soon as a fresh edit arrives, because
31    /// redoing onto a diverged document is not something we can honour.
32    undone: Vec<Entry>,
33    next_group: u64,
34    /// The source of the last recorded edit, for coalescing decisions.
35    last_source: Option<EditSource>,
36    /// Set when something (a cursor move, a save) should end the current run of typing.
37    group_broken: bool,
38}
39
40impl History {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// The undo group a new edit from `source` belongs to.
46    ///
47    /// Consecutive typing merges; anything else starts a fresh group, as does an explicit
48    /// [`break_group`](Self::break_group) from a cursor move or a save.
49    pub fn group_for(&mut self, source: &EditSource) -> UndoGroupId {
50        let continues = !self.group_broken
51            && source.coalesces()
52            && self.last_source.as_ref() == Some(source)
53            && !self.applied.is_empty();
54
55        if continues {
56            return self.applied[self.applied.len() - 1].group;
57        }
58
59        self.next_group += 1;
60        UndoGroupId(self.next_group)
61    }
62
63    /// End the current undo group, so the next edit starts a new one.
64    ///
65    /// Called on a cursor move, a save, or an idle timeout — the boundaries a user
66    /// intuitively expects undo to stop at.
67    pub fn break_group(&mut self) {
68        self.group_broken = true;
69    }
70
71    /// Record a transaction that has just been applied, with the inverse computed against
72    /// the document as it was *before* the change.
73    pub fn push(&mut self, transaction: &EditTransaction, inverse: ChangeSet) {
74        if transaction.is_empty() {
75            return;
76        }
77        // A new edit invalidates the redo stack: those changesets were authored against a
78        // document that no longer exists.
79        self.undone.clear();
80        // Cleared here rather than in `group_for`, so an edit that turns out to be empty
81        // cannot consume a pending break: ask for a group, record nothing, and the next
82        // real keystroke must still start fresh.
83        self.group_broken = false;
84        self.last_source = Some(transaction.source.clone());
85        self.applied.push(Entry {
86            group: transaction.undo_group,
87            forward: transaction.changes.clone(),
88            inverse,
89        });
90    }
91
92    pub fn can_undo(&self) -> bool {
93        !self.applied.is_empty()
94    }
95
96    pub fn can_redo(&self) -> bool {
97        !self.undone.is_empty()
98    }
99
100    /// The change that undoes the most recent group, or `None` at the start of history.
101    ///
102    /// The whole group comes back as one changeset, so a run of typing — or an accepted
103    /// multi-hunk proposal — is one keystroke to reverse.
104    pub fn undo(&mut self) -> Option<ChangeSet> {
105        let group = self.applied.last()?.group;
106
107        let mut composed: Option<ChangeSet> = None;
108        while self.applied.last().is_some_and(|e| e.group == group) {
109            let entry = self.applied.pop().expect("just checked");
110            // Newest first: the last edit applied is the first one undone.
111            composed = Some(match composed {
112                None => entry.inverse.clone(),
113                Some(acc) => acc.compose(&entry.inverse),
114            });
115            self.undone.push(entry);
116        }
117
118        // Typing after an undo must not silently rejoin the group we just reversed.
119        self.group_broken = true;
120        self.last_source = None;
121        composed
122    }
123
124    /// The change that reapplies the most recently undone group.
125    pub fn redo(&mut self) -> Option<ChangeSet> {
126        let group = self.undone.last()?.group;
127
128        let mut composed: Option<ChangeSet> = None;
129        while self.undone.last().is_some_and(|e| e.group == group) {
130            let entry = self.undone.pop().expect("just checked");
131            // `undone` was pushed newest-first, so popping replays in original order.
132            composed = Some(match composed {
133                None => entry.forward.clone(),
134                Some(acc) => acc.compose(&entry.forward),
135            });
136            self.applied.push(entry);
137        }
138
139        self.group_broken = true;
140        self.last_source = None;
141        composed
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::transaction::Version;
149    use ropey::Rope;
150    use termesh_core::{BufferId, ProposalId};
151
152    /// A tiny document that records edits into a history, so tests read like usage.
153    struct Doc {
154        text: Rope,
155        history: History,
156        version: Version,
157    }
158
159    impl Doc {
160        fn new(text: &str) -> Self {
161            Self { text: Rope::from_str(text), history: History::new(), version: Version(0) }
162        }
163
164        fn edit(&mut self, from: usize, to: usize, insert: &str, source: EditSource) {
165            let changes = ChangeSet::replace(self.text.len_chars(), from, to, insert);
166            let group = self.history.group_for(&source);
167            let tx = EditTransaction::new(BufferId::new(1), self.version, changes, source, group);
168            // Inverse computed here, against the live pre-image — the ADR-0006 §6 rule.
169            let inverse = tx.changes.invert(&self.text);
170            self.text = tx.changes.apply(&self.text);
171            self.version = self.version.next();
172            self.history.push(&tx, inverse);
173        }
174
175        fn type_char(&mut self, at: usize, ch: &str) {
176            self.edit(at, at, ch, EditSource::Keyboard);
177        }
178
179        fn undo(&mut self) -> bool {
180            match self.history.undo() {
181                Some(cs) => {
182                    self.text = cs.apply(&self.text);
183                    self.version = self.version.next();
184                    true
185                }
186                None => false,
187            }
188        }
189
190        fn redo(&mut self) -> bool {
191            match self.history.redo() {
192                Some(cs) => {
193                    self.text = cs.apply(&self.text);
194                    self.version = self.version.next();
195                    true
196                }
197                None => false,
198            }
199        }
200
201        fn text(&self) -> String {
202            self.text.to_string()
203        }
204    }
205
206    #[test]
207    fn nothing_to_undo_at_the_start_of_history() {
208        let mut doc = Doc::new("hello");
209        assert!(!doc.history.can_undo());
210        assert!(!doc.undo());
211    }
212
213    #[test]
214    fn a_single_edit_undoes_and_redoes() {
215        let mut doc = Doc::new("hello");
216        doc.edit(5, 5, " world", EditSource::Paste);
217        assert_eq!(doc.text(), "hello world");
218
219        assert!(doc.undo());
220        assert_eq!(doc.text(), "hello");
221        assert!(doc.redo());
222        assert_eq!(doc.text(), "hello world");
223    }
224
225    #[test]
226    fn a_run_of_typing_undoes_as_one_step() {
227        let mut doc = Doc::new("()");
228        for (i, ch) in "abc".chars().enumerate() {
229            doc.type_char(1 + i, &ch.to_string());
230        }
231        assert_eq!(doc.text(), "(abc)");
232
233        assert!(doc.undo());
234        assert_eq!(doc.text(), "()", "three keystrokes, one undo");
235        assert!(!doc.history.can_undo());
236    }
237
238    #[test]
239    fn a_cursor_move_ends_the_run() {
240        let mut doc = Doc::new("()");
241        doc.type_char(1, "a");
242        doc.history.break_group(); // as a cursor move would
243        doc.type_char(2, "b");
244        assert_eq!(doc.text(), "(ab)");
245
246        doc.undo();
247        assert_eq!(doc.text(), "(a)", "the break split the run in two");
248        doc.undo();
249        assert_eq!(doc.text(), "()");
250    }
251
252    #[test]
253    fn a_different_source_ends_the_run_without_being_asked() {
254        let mut doc = Doc::new("()");
255        doc.type_char(1, "a");
256        doc.edit(2, 2, "!", EditSource::Formatter);
257        doc.undo();
258        assert_eq!(doc.text(), "(a)", "the formatter edit is its own step");
259    }
260
261    /// The phase's exit criterion in miniature: an agent's change, however many edits it
262    /// took, is one thing to undo.
263    #[test]
264    fn an_accepted_proposal_undoes_in_one_step() {
265        let mut doc = Doc::new("fn main() {}");
266        let source = EditSource::Agent(ProposalId::new(1));
267
268        // Two hunks of one proposal share an undo group.
269        let group = doc.history.group_for(&source);
270        for (from, to, insert) in [(3, 7, "run"), (0, 0, "pub ")] {
271            let changes = ChangeSet::replace(doc.text.len_chars(), from, to, insert);
272            let tx =
273                EditTransaction::new(BufferId::new(1), doc.version, changes, source.clone(), group);
274            let inverse = tx.changes.invert(&doc.text);
275            doc.text = tx.changes.apply(&doc.text);
276            doc.history.push(&tx, inverse);
277        }
278        assert_eq!(doc.text(), "pub fn run() {}");
279
280        assert!(doc.undo());
281        assert_eq!(doc.text(), "fn main() {}", "one undo reverses the whole proposal");
282        assert!(!doc.history.can_undo());
283    }
284
285    #[test]
286    fn undo_then_type_discards_the_redo_stack() {
287        let mut doc = Doc::new("a");
288        doc.edit(1, 1, "b", EditSource::Paste);
289        doc.undo();
290        assert!(doc.history.can_redo());
291
292        doc.edit(1, 1, "c", EditSource::Paste);
293        assert!(!doc.history.can_redo(), "redoing onto a diverged document is not offered");
294        assert_eq!(doc.text(), "ac");
295    }
296
297    #[test]
298    fn typing_after_an_undo_starts_a_fresh_group() {
299        let mut doc = Doc::new("()");
300        doc.type_char(1, "a");
301        doc.type_char(2, "b");
302        doc.undo();
303        assert_eq!(doc.text(), "()");
304
305        doc.type_char(1, "z");
306        doc.undo();
307        assert_eq!(doc.text(), "()", "the new keystroke must not rejoin the reversed group");
308    }
309
310    #[test]
311    fn many_edits_round_trip_all_the_way_back() {
312        let mut doc = Doc::new("start");
313        let original = doc.text();
314        for (from, to, insert, source) in [
315            (5, 5, " middle", EditSource::Paste),
316            (0, 5, "BEGIN", EditSource::Replace),
317            (5, 12, "", EditSource::Formatter),
318        ] {
319            doc.edit(from, to, insert, source);
320        }
321        assert_ne!(doc.text(), original);
322
323        while doc.undo() {}
324        assert_eq!(doc.text(), original, "history unwinds completely");
325
326        while doc.redo() {}
327        assert_eq!(doc.text(), "BEGIN");
328    }
329
330    #[test]
331    fn empty_transactions_are_not_recorded() {
332        let mut doc = Doc::new("hello");
333        doc.edit(2, 2, "", EditSource::Keyboard);
334        assert!(!doc.history.can_undo(), "a no-op edit is not an undo step");
335    }
336
337    #[test]
338    fn an_empty_edit_cannot_swallow_a_pending_group_break() {
339        let mut doc = Doc::new("()");
340        doc.type_char(1, "a");
341        doc.history.break_group(); // a cursor move
342
343        doc.edit(2, 2, "", EditSource::Keyboard); // no-op: asks for a group, records nothing
344        doc.type_char(2, "b");
345
346        doc.undo();
347        assert_eq!(doc.text(), "(a)", "the break must survive an edit that did nothing");
348    }
349}