Engine: undo groups make several commands one undo step

Session::grouped(label, f) runs f as one undo step on the active
document. On success the group leaves exactly one undo entry holding the
workbook from before the group; on error it restores the workbook,
selection, undo and redo stacks as they were and returns the error.
group_begin, group_end and group_rollback do the same across separate
calls, for an action that runs one step per call.

While a group is open on a document, undoable commands on it add no
undo steps of their own, so a long group neither grows the history nor
pushes older entries past the undo limit, and Undo and Redo are
disabled there. Groups nest: the outermost one makes the step, and a
failing inner group rolls back only its own changes. Closing a document
drops its groups, and a command that panics drops the groups it opened.
history.list reports whether a group is open.

Insert Copied Cells folds its nested steps into one undo step by
counting revisions; inside a group the nested steps push no entries, so
it now leaves the history alone there instead of removing entries from
before the group.
This commit is contained in:
prxnvt
2026-10-21 14:05:41 -04:00
parent 37e41424ed
commit acfd38f309
6 changed files with 366 additions and 12 deletions
+203
View File
@@ -0,0 +1,203 @@
//! Engine building blocks for multi-command actions such as macros: undo groups, the recording
//! buffer, document trust and chunked storage of workbook data in hidden names.
use serde_json::json;
use super::{s, v};
use crate::{EngineError, Session};
fn undo_labels(s: &Session) -> Vec<String> {
s.doc().unwrap().undo.iter().map(|e| e.label.clone()).collect()
}
fn set(s: &mut Session, cell: &str, input: &str) {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
// ---------------------------------------------------------------- undo groups
#[test]
fn grouped_commands_are_one_undo_step() {
let mut s = s();
set(&mut s, "A1", "before");
let r = s.grouped("Fill Three", |s| {
set(s, "A1", "1");
set(s, "A2", "2");
s.execute("home.bold", json!({"range": "A1:A2"}))?;
Ok(7)
});
assert_eq!(r.unwrap(), 7);
assert_eq!(undo_labels(&s), ["Enter Cell", "Fill Three"]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "A1").display(), "before");
assert!(v(&s, "A2").is_empty());
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(v(&s, "A2").display(), "2");
assert!(!s.in_group(s.doc().unwrap().uid));
}
#[test]
fn a_failing_group_rolls_everything_back() {
let mut s = s();
set(&mut s, "B1", "base");
set(&mut s, "A1", "undone");
s.execute("edit.undo", json!({})).unwrap();
s.execute("selection.set", json!({"range": "C3"})).unwrap();
let redo_before = s.doc().unwrap().redo.len();
assert_eq!(redo_before, 1);
let r: crate::Result<()> = s.grouped("Doomed", |s| {
set(s, "B1", "changed");
s.execute("selection.set", json!({"range": "D4"}))?;
set(s, "B2", "new");
Err(EngineError::Other("step 3 failed".into()))
});
assert!(r.unwrap_err().to_string().contains("step 3 failed"));
assert_eq!(v(&s, "B1").display(), "base");
assert!(v(&s, "B2").is_empty());
assert_eq!(s.doc().unwrap().selection.a1(), "C3");
assert_eq!(undo_labels(&s), ["Enter Cell"]);
assert_eq!(s.doc().unwrap().redo.len(), redo_before, "the redo stack comes back too");
// The recalculation follows the restored workbook.
set(&mut s, "C1", "=B1&\"!\"");
assert_eq!(v(&s, "C1").display(), "base!");
}
#[test]
fn groups_nest_and_the_outermost_makes_the_step() {
let mut s = s();
let r = s.grouped("Outer", |s| {
set(s, "A1", "1");
s.grouped("Inner", |s| {
set(s, "A2", "2");
Ok(())
})?;
// A failing inner group rolls back only its own changes; the outer one carries on.
let inner: crate::Result<()> = s.grouped("Inner fails", |s| {
set(s, "A3", "3");
Err(EngineError::Other("no".into()))
});
assert!(inner.is_err());
assert!(v(s, "A3").is_empty());
set(s, "A4", "4");
Ok(())
});
r.unwrap();
assert_eq!(undo_labels(&s), ["Outer"]);
assert_eq!(v(&s, "A2").display(), "2");
s.execute("edit.undo", json!({})).unwrap();
for c in ["A1", "A2", "A3", "A4"] {
assert!(v(&s, c).is_empty(), "{c}");
}
}
#[test]
fn an_unchanged_group_adds_no_step() {
let mut s = s();
set(&mut s, "A1", "x");
s.grouped("Look Around", |s| {
s.execute("selection.set", json!({"range": "B2"}))?;
s.execute("cell.get", json!({"cell": "A1"}))?;
Ok(())
})
.unwrap();
assert_eq!(undo_labels(&s), ["Enter Cell"]);
}
#[test]
fn a_group_spans_calls_and_blocks_undo_until_it_ends() {
let mut s = s();
set(&mut s, "A1", "start");
let token = s.group_begin("Debug Macro").unwrap();
// Each step is its own call, as a debugger makes them.
set(&mut s, "A1", "step 1");
set(&mut s, "A2", "step 2");
let undo = s.execute("edit.undo", json!({}));
assert!(matches!(undo, Err(EngineError::Disabled(..))), "{undo:?}");
assert!(matches!(s.execute("edit.redo", json!({})), Err(EngineError::Disabled(..))));
assert_eq!(s.execute("history.list", json!({})).unwrap()["grouping"], json!(true));
assert!(s.group_end(token).unwrap());
assert_eq!(s.execute("history.list", json!({})).unwrap()["grouping"], json!(false));
assert_eq!(undo_labels(&s), ["Enter Cell", "Debug Macro"]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "A1").display(), "start");
assert!(v(&s, "A2").is_empty());
}
#[test]
fn rolling_back_a_spanning_group_restores_the_start() {
let mut s = s();
set(&mut s, "A1", "start");
let token = s.group_begin("Debug Macro").unwrap();
set(&mut s, "A1", "step 1");
s.execute("home.insertSheet", json!({})).unwrap();
s.group_rollback(token).unwrap();
assert_eq!(v(&s, "A1").display(), "start");
assert_eq!(s.doc().unwrap().wb.sheets.len(), 1);
assert_eq!(undo_labels(&s), ["Enter Cell"]);
}
#[test]
fn ending_an_outer_group_ends_the_inner_ones() {
let mut s = s();
let outer = s.group_begin("Outer").unwrap();
let inner = s.group_begin("Inner").unwrap();
set(&mut s, "A1", "1");
s.group_end(outer).unwrap();
assert_eq!(undo_labels(&s), ["Outer"]);
assert!(s.group_end(inner).is_err(), "the inner group ended with its outer group");
assert!(!s.in_group(s.doc().unwrap().uid));
}
#[test]
fn a_long_group_keeps_the_history_before_it() {
let mut s = s();
for i in 0..100 {
set(&mut s, "A1", &i.to_string());
}
let first = s.doc().unwrap().undo.first().map(|e| e.wb.clone()).unwrap();
s.grouped("Many Steps", |s| {
for i in 0..250 {
set(s, "B1", &i.to_string());
}
Ok(())
})
.unwrap();
let d = s.doc().unwrap();
assert_eq!(d.undo.len(), 100, "the undo limit still holds");
assert_eq!(d.undo.last().map(|e| e.label.as_str()), Some("Many Steps"));
// Only the oldest entry made room for the group's one step.
assert!(!d.undo.iter().any(|e| std::sync::Arc::ptr_eq(&e.wb, &first)));
assert_eq!(d.undo.iter().filter(|e| e.label == "Enter Cell").count(), 99);
}
#[test]
fn closing_a_document_drops_its_groups() {
let mut s = s();
let token = s.group_begin("Debug Macro").unwrap();
let uid = token.doc();
s.close_document(s.active_index());
assert!(!s.in_group(uid));
assert!(s.group_end(token).is_err());
assert!(matches!(s.group_begin("x"), Err(EngineError::NoDocument)));
}
#[test]
fn a_group_owns_the_steps_of_commands_that_run_commands() {
// Insert Copied Cells runs nested steps and folds them into its own undo step; inside a group
// it must leave the history from before the group alone.
let mut s = s();
set(&mut s, "A1", "kept");
set(&mut s, "A5", "copied");
s.execute("edit.copy", json!({"range": "5:5"})).unwrap();
s.grouped("Insert Inside", |s| {
s.execute("selection.set", json!({"range": "A2"}))?;
s.execute("home.insertCopiedCells", json!({}))?;
Ok(())
})
.unwrap();
assert_eq!(v(&s, "A2").display(), "copied");
assert_eq!(undo_labels(&s), ["Enter Cell", "Enter Cell", "Insert Inside"]);
s.execute("edit.undo", json!({})).unwrap();
assert!(v(&s, "A2").is_empty());
assert_eq!(v(&s, "A1").display(), "kept");
}