#1244: undo history bounded by bytes, without lookup tables or repeated selections in its steps

History was limited by step count only; a whole-selection move of 50k paths keeps ~40 MB per
step. Steps now carry a size estimate (only unshared subtrees are visited) and the oldest are
dropped beyond a byte budget, keeping the last 8. Documents entering history drop their id
index, selections are shared between steps, the command journal is bounded (held while an
Action records), and document resources are copy-on-write. Saved files are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
arhammusheer
2026-10-12 11:02:17 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent fe8a6983c5
commit bbbafdef6b
23 changed files with 989 additions and 76 deletions
+2 -2
View File
@@ -142,8 +142,8 @@ impl Clipboard {
d.patterns = self.patterns.clone();
d.swatches.extend(self.swatches.iter().cloned());
d.graphic_styles.extend(self.graphic_styles.iter().cloned());
d.char_styles = self.char_styles.clone();
d.para_styles = self.para_styles.clone();
d.char_styles = self.char_styles.clone().into();
d.para_styles = self.para_styles.clone().into();
if !self.brushes.is_empty() {
let mut lib = vectorcraft_brush::library(&d);
for b in &self.brushes {
+5 -1
View File
@@ -1198,6 +1198,7 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = st.history.undo.last_mut() {
e.label = "Draw in Perspective".into();
}
st.history.recost_last(&st.doc);
}
res?;
Ok(json!({ "id": id.0, "plane": plane.id() }))
@@ -1268,7 +1269,9 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
/// in every undo state (the flat view is never an undo step of its own).
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
let clear = |doc: &mut Arc<Document>| {
if doc.node(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing)
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
// builds (the document now keeps its own).
if doc.node_uncached(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing)
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut())
{
p.editing = false;
@@ -1281,6 +1284,7 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
}
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
clear(&mut e.doc);
e.doc.drop_lookup_cache();
}
if let Some(it) = &mut st.interaction {
clear(&mut it.doc);
+2 -2
View File
@@ -402,9 +402,9 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("two saved selections would be named `{}`", x.name)));
}
}
if after != before {
if *before != after {
s.edit("Edit Selection", |d, _| {
d.saved_selections = after;
d.saved_selections = after.into();
Ok(())
})?;
}
+18 -10
View File
@@ -215,11 +215,11 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
let editing = st.doc.mask_edit.map(|m| m.layer);
st.history.redo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
selection: std::mem::replace(&mut st.selection, e.selection),
});
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.redo.last());
st.history.redo.push(crate::HistoryEntry::kept(e.label, doc, selection, &st.doc));
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
super::maskedit::follow_history(st, editing);
@@ -231,17 +231,25 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let label = e.label.clone();
let editing = st.doc.mask_edit.map(|m| m.layer);
st.history.undo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
selection: std::mem::replace(&mut st.selection, e.selection),
});
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.undo.last());
st.history.undo.push(crate::HistoryEntry::kept(e.label, doc, selection, &st.doc));
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
super::maskedit::follow_history(st, editing);
Ok(json!({ "redone": label }))
}
/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same.
fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc<vectorcraft_doc::Selection> {
match like {
Some(l) if *l.selection == selection => l.selection.clone(),
_ => Arc::new(selection),
}
}
/// Selected top-level objects in paint order, dropping any whose ancestor is also selected.
pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
+1
View File
@@ -180,6 +180,7 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) {
if h.len() > from {
h.truncate(from + 1);
h[from].label = label.to_string();
st.history.recost_last(&st.doc);
}
}
}
+1 -2
View File
@@ -543,8 +543,7 @@ impl Session {
for st in &mut self.docs {
if history_changed {
st.history.limit = self.prefs.history_states as usize;
let over = st.history.undo.len().saturating_sub(st.history.limit);
st.history.undo.drain(..over);
st.history.trim();
}
// Documents in pattern editing mode redraw their tile edge.
if tile_edge_changed && st.doc.pattern_edit.is_some() {
+1 -1
View File
@@ -606,7 +606,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
let mut added = vec![];
if replace {
let before: Vec<String> = d.swatches_iter().map(|w| w.name.clone()).collect();
(d.swatches, d.swatch_groups) = (defaults, groups);
(d.swatches, d.swatch_groups) = (defaults.into(), groups.into());
added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone()));
live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone()));
d.map_solid_paints(&mut |_, l, _| unlink_dead(&live, l));
+1 -1
View File
@@ -755,7 +755,7 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
// plus the text (and its id), roll back to it without a trace.
let st = s.doc_mut()?;
if st.interaction.is_none()
&& let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node(id).is_none())
&& let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node_uncached(id).is_none())
&& let Some(created) = st.history.undo.get(k)
{
let mut now = (*st.doc).clone();