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]