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.
This commit is contained in:
dotCooCoo
2026-10-26 16:58:49 +07:00
committed by bflatastic
parent b44d2500e3
commit 389dce727d
2 changed files with 34 additions and 1 deletions
+6 -1
View File
@@ -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`).
+28
View File
@@ -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::<Vec<_>>();
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]