From 389dce727d3704b7d040a097035ac56fca79cf96 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 01:23:54 -0700 Subject: [PATCH] Undo: a canceled undo scope drops the journal entries it cut from open undo groups When an undo scope that joined an open undo group was canceled, the journal was cut back to where the scope began, but the group still listed the cut entries' numbers. The next command took one of those numbers, so a later cancel of the group also removed that command's entry, even when it ran in another document, and a replay of the journal no longer matched the documents. end_undo_scope now removes the cut numbers from every open undo group's journal entries and batch steps. --- crates/engine/src/lib.rs | 7 ++++++- crates/engine/src/tests_journal.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a3d10b0a7..ef447b01e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2520,7 +2520,7 @@ impl Session { /// closes it. A scope that joined an open group leaves the group open; with `cancel`, it puts the /// document and its selection back as they were when the scope began and cuts the journal back to /// where the scope began (only while the group's step is still the newest one), and an undo step - /// that the scope's first edit recorded goes too. + /// that the scope's first edit recorded goes too. The open undo groups drop the cut entries. pub fn end_undo_scope(&mut self, scope: UndoScope, cancel: bool) { let Some((doc, selection, first)) = scope.joined else { self.end_document_undo_group(scope.uid, cancel); @@ -2549,6 +2549,11 @@ impl Session { st.history.recost_last(&st.doc); st.revision += 1; self.journal.truncate(scope.journal); + // The next commands take the cut entries' numbers, so no open undo group keeps them. + for g in self.docs.iter_mut().filter_map(|st| st.undo_group.as_mut()) { + g.journal.retain(|&(n, _)| n < scope.journal); + g.steps.retain(|&(n, _)| n < scope.journal); + } } /// Commands with enablement (for menus, palette, MCP `list_commands`). diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs index 8b4d38d67..311700598 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests_journal.rs @@ -780,6 +780,34 @@ fn an_undo_scope_in_an_open_group_takes_back_only_its_own_edits() { assert_eq!(objects(&s), 0, "one undo takes back the group's edits"); } +/// A canceled undo scope that joined an open group forgets the journal entries it cut: when a command +/// in another document takes the same entry number, a later cancel of the group keeps that entry, and +/// the journal replays to the same documents. +#[test] +fn a_canceled_undo_scope_leaves_its_cut_entries_out_of_the_group() { + let mut s = Session::new(); + let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50}); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.begin_undo_group(); + s.execute("shape.rectangle", &rect).unwrap(); + let scope = s.begin_undo_scope().unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.end_undo_scope(scope, true); + let journal = s.journal.len(); + // The switch takes the entry number the scope's edit had, and the rectangle in the second document follows it. + s.execute("document.activate", &json!({"index": 1})).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.end_undo_group(true); + let objects = s.documents().iter().map(|d| d.doc.layers[0].children().map_or(0, |c| c.len())).collect::>(); + assert_eq!(objects, [0, 1]); + assert_eq!(s.journal.len(), journal + 1, "only the group's own edit leaves the journal"); + let r = replayed(&s); + assert_eq!(documents(&r), documents(&s)); + assert_eq!(r.active_index(), s.active_index()); +} + /// A move drag is named for what its last preview did: Alt pressed or released during the drag /// turns it into a copy or back (the Selection and Direct Selection tools). #[test]