Merge pull request #4243 from dotCooCoo/fix/undo-scope-cancel-forgets-cut-journal-entries

Undo: a canceled undo scope drops the journal entries it cut from open undo groups
This commit is contained in:
BFlat
2026-10-26 17:02:11 +07:00
committed by GitHub
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]