Merge PR #1263: Bound Undo history growth after bulk edits

crates/engine/src/lib.rs: kept both the journal caps (#1230) and the undo byte budget; separate constants and call sites.
crates/engine/src/cmd/edit.rs: undo pushes through push_history; the redo entry keeps the undone edit's selection (#1855 behaviour).
crates/engine/src/tests.rs: kept both tests (redo_selects_the_redone_cell and the two history-bound tests).
This commit is contained in:
aiongg
2026-10-22 02:35:17 +00:00
5 changed files with 144 additions and 9 deletions
+6 -4
View File
@@ -612,12 +612,13 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.undo.pop() else { break };
let cur_wb = d.wb.clone();
// Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: e.selection.clone() };
d.redo.push(cur);
let cur_selection = e.selection.clone();
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
@@ -632,11 +633,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.redo.pop() else { break };
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
d.undo.push(cur);
let cur_wb = d.wb.clone();
let cur_selection = d.selection.clone();
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
+40 -4
View File
@@ -61,6 +61,8 @@ pub struct HistoryEntry {
pub label: String,
pub wb: Arc<Workbook>,
pub selection: Selection,
/// Estimate of storage this snapshot retains beyond the next workbook version.
pub retained_bytes: usize,
}
/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
@@ -123,6 +125,43 @@ const JOURNAL_MAX_ENTRIES: usize = 10_000;
/// `engine.journal`), and a `file.open` holds the whole file as base64: without a size cap a
/// few opens of a large workbook keep tens of MB alive.
pub(crate) const JOURNAL_MAX_BYTES: usize = 64 * 1024 * 1024;
const UNDO_BYTES_LIMIT: usize = 128 * 1024 * 1024;
fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
let mut bytes = std::mem::size_of::<Workbook>();
for (index, sheet) in older.sheets.iter().enumerate() {
// Moving or inserting sheets must not charge unchanged sheets at a new index.
if newer.sheets.iter().any(|next| Arc::ptr_eq(sheet, next)) {
continue;
}
bytes = bytes.saturating_add(std::mem::size_of::<gridcraft_model::Sheet>());
let empty = gridcraft_model::CellStore::new();
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
}
bytes
}
fn push_history(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
push_history_with_budget(history, label, wb, next, selection, UNDO_BYTES_LIMIT);
}
fn push_history_with_budget(
history: &mut Vec<HistoryEntry>,
label: String,
wb: Arc<Workbook>,
next: &Workbook,
selection: Selection,
byte_budget: usize,
) {
let retained_bytes = history_retained_bytes(&wb, next);
history.push(HistoryEntry { label, wb, selection, retained_bytes });
let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
// Always preserve the latest action, even if that one edit exceeds the budget.
while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
total = total.saturating_sub(history.remove(0).retained_bytes);
}
}
impl DocState {
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
@@ -389,10 +428,7 @@ impl Session {
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
&& !Arc::ptr_eq(&d.wb, &wb)
{
d.undo.push(HistoryEntry { label: spec.label.trim_end_matches('…').to_string(), wb, selection: sel });
if d.undo.len() > UNDO_LIMIT {
d.undo.remove(0);
}
push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel);
d.redo.clear();
d.revision += 1;
}
+44
View File
@@ -269,6 +269,50 @@ fn redo_selects_the_redone_cell() {
assert_eq!(s.doc().unwrap().selection.a1(), "A3");
}
#[test]
fn bulk_formula_history_is_bounded_and_latest_edit_still_undoes() {
use std::sync::Arc;
use gridcraft_model::{Cell, Formula, Workbook};
let mut current = Arc::new(Workbook::new());
let mut history = Vec::new();
let budget = 64 * 1024;
for version in 0..5 {
let previous = current.clone();
let mut next = (*previous).clone();
let sheet = next.sheet_mut(0).unwrap();
for row in 0..128 {
sheet.cells.set(CellRef::new(row, 0), Cell::formula(Formula::new(&format!("=B{}+{}", row + 1, version))));
}
current = Arc::new(next);
crate::push_history_with_budget(&mut history, format!("edit {version}"), previous, &current, crate::Selection::default(), budget);
}
assert!(history.len() < 5, "bulk edits should evict old steps before reaching the count limit");
assert_eq!(history.last().unwrap().label, "edit 4");
assert!(history.len() == 1 || history.iter().map(|e| e.retained_bytes).sum::<usize>() <= budget);
let mut session = s();
let doc = session.doc_mut().unwrap();
doc.wb = current;
doc.undo = history;
session.execute("edit.undo", json!({})).unwrap();
assert_eq!(session.doc().unwrap().wb.active().unwrap().cells.get(CellRef::new(0, 0)).unwrap().formula.as_ref().unwrap().text, "B1+3");
session.execute("edit.redo", json!({})).unwrap();
assert_eq!(session.doc().unwrap().wb.active().unwrap().cells.get(CellRef::new(0, 0)).unwrap().formula.as_ref().unwrap().text, "B1+4");
}
#[test]
fn small_edits_keep_the_full_undo_depth() {
let mut session = s();
for number in 0..100 {
session.execute("cell.set", json!({"cell": "A1", "input": number.to_string()})).unwrap();
}
assert_eq!(session.doc().unwrap().undo.len(), 100);
session.execute("edit.undo", json!({"steps": 100})).unwrap();
assert_eq!(v(&session, "A1"), Value::Empty);
}
#[test]
fn shape_text_edit_undo_redo_and_noop() {
let mut s = s();