From 0312f1ea55ad9ab7add256ed4fd4f743193d477b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 13:41:38 +0700 Subject: [PATCH 1/3] M14.4: the pasted-PDF fuzz test accepts a paste that refuses out-of-range art pasted_pdf_never_panics failed at random (about one run in four at 256 cases): a mutated digit turns a curve coordinate like 245.18535 into 245018535, so the pasted art spans ~1e8 pt, beyond MAX_COORD, and edit.paste rightly refuses it with "result would exceed the canvas", leaving the document unchanged. The helper unwrapped that result. The test is about never panicking, so paste_from_elsewhere now ignores an Err from edit.paste, with a comment saying why. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/import_fuzz.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 446590c2f..b329180ba 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -727,7 +727,9 @@ fn paste_from_elsewhere(cmd: &str, p: Value) -> Result<(), TestCaseError> { let mut s = vectorcraft_engine::Session::new(); s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); if s.execute(cmd, &p).is_ok() { - s.execute("edit.paste", &json!({"center": [50, 50]})).unwrap(); + // Paste may refuse what it was given, e.g. a damaged PDF whose art spans more than the + // canvas ("result would exceed the canvas"): an error is a fine outcome, a panic isn't. + let _ = s.execute("edit.paste", &json!({"center": [50, 50]})); } }); prop_assert!(r.is_ok(), "{cmd}: panicked: {:?}", r.err()); From 766b612070f5234177ccc3b697478de754b21add Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 13:41:41 +0700 Subject: [PATCH 2/3] M14.4: a batch journals the dates and preferences its steps used Session::note_journal only kept values noted by the top-level command, so a command run as a step of command.batch lost them: a save in a batch stamped File Info's modified date from the clock without recording it, and object.scale's strokes/corners came from the preferences again on replay. Replaying such a journal a second later (or with other preferences) gave a different document. - Session::execute_step runs a batch step with its own journal notes (note_depth) and returns its params with what it noted; command.batch writes those params into its step of the batch's journal entry (steps that note nothing stay as given). - journal_for_action strips the replay-only dates from a batch's steps too, so an action recorded with one still dates the document when it plays. - Tests: a save in a batch records its date and a replay with the recorded date moved lands on it, while an action leaves it out; a scale in a batch records Scale Strokes & Effects and replays alike with the preference off. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/batch.rs | 25 +++++++++--- crates/engine/src/lib.rs | 49 +++++++++++++++++++---- crates/engine/src/tests_journal.rs | 56 +++++++++++++++++++++++++++ docs/mcp.md | 3 +- 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index 108e0ffd7..a975d6c28 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -6,21 +6,30 @@ use super::super::*; use crate::EngineError; pub(super) fn batch(s: &mut Session, p: &Value) -> Result { - let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone(); + let mut cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone(); let label = str_param(p, "label").unwrap_or("Batch").to_string(); s.begin_interaction(&label)?; // Session-level state a step may change (paint defaults, drawing mode) rolls back too. let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone()); let mut results = vec![]; - for c in &cmds { - let id = c.get("command").and_then(Value::as_str).unwrap_or(""); + for c in &mut cmds { + let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string(); if id == "command.batch" { s.cancel_interaction()?; return Err(bad("command.batch", "batches cannot nest")); } let params = c.get("params").cloned().unwrap_or(json!({})); - match s.execute(id, ¶ms) { - Ok(v) => results.push(v), + match s.execute_step(&id, ¶ms) { + Ok((v, noted)) => { + // The step's journal entry records what it took from the preferences or the + // clock (a save's date), as a command of its own would. + if noted != params + && let Some(c) = c.as_object_mut() + { + c.insert("params".into(), noted); + } + results.push(v); + } Err(e) => { s.cancel_interaction()?; (s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved; @@ -29,8 +38,12 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { } } // Commit as one undo step (the interaction's "preview" is the batch itself). + let mut entry = p.clone(); + if let Some(o) = entry.as_object_mut() { + o.insert("commands".into(), Value::Array(cmds)); + } if let Some(it) = s.doc_mut()?.interaction.as_mut() { - it.preview = Some(("command.batch".into(), p.clone())); + it.preview = Some(("command.batch".into(), entry)); } s.commit_interaction()?; Ok(json!({ "results": results })) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 12247d64b..0b4c64e70 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -207,6 +207,27 @@ pub const MAX_COORD: f64 = 4.0e6; /// ([`Session::journal_for_action`]). const REPLAY_ONLY: [(&str, &str); 3] = [("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified")]; +/// `p`, the params of command `id`, without its [`REPLAY_ONLY`] values (a batch: its steps'). +fn strip_replay_only(id: &str, p: &mut Value) { + let strip = |id: &str, p: &mut Value| { + if let Value::Object(m) = p { + m.retain(|k, _| !REPLAY_ONLY.iter().any(|&(c, key)| c == id && key == k)); + } + }; + strip(id, p); + // Batches don't nest: one level of steps. + if id == "command.batch" + && let Some(Value::Array(steps)) = p.get_mut("commands") + { + for step in steps { + let id = step.get("command").and_then(Value::as_str).unwrap_or_default().to_string(); + if let Some(p) = step.get_mut("params") { + strip(&id, p); + } + } + } +} + /// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must /// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞. fn doc_sane(d: &Document, sel: &Selection) -> bool { @@ -587,6 +608,9 @@ pub struct Session { /// Parameters the running top-level command resolved from the preferences or the clock, added /// to its journal entry so a replay does the same ([`Session::note_journal`]). journal_note: serde_json::Map, + /// The depth of the command whose values [`Session::note_journal`] keeps: 1 (the top-level + /// command), or a step's while a batch runs it ([`Session::execute_step`]). + note_depth: u32, } impl Default for Session { @@ -624,6 +648,7 @@ impl Session { style_libraries: Default::default(), recent_urls: vec![], journal_note: Default::default(), + note_depth: 1, } } @@ -752,6 +777,7 @@ impl Session { // be recorded twice and replay differently). if self.depth == 0 { self.journal_note.clear(); + self.note_depth = 1; } let r = if self.depth == 0 { self.run_guarded(id, |s| (spec.run)(s, params)) } else { self.run_nested(|s| (spec.run)(s, params)) }; let r = r?; @@ -766,13 +792,26 @@ impl Session { } /// Record `key: value` in the running top-level command's journal entry (or its interaction's - /// preview) unless its params give `key`: a value it resolved from the preferences or the clock. + /// preview, or its step in a batch) unless its params give `key`: a value it resolved from the + /// preferences or the clock. pub fn note_journal(&mut self, key: &str, value: Value) { - if self.depth == 1 { + if self.depth == self.note_depth { self.journal_note.insert(key.to_string(), value); } } + /// Run `id` as a step of the running command, which journals its steps (`command.batch`): + /// → the step's result and its params with what it noted ([`Session::note_journal`]), for the + /// step in the running command's journal entry. + pub(crate) fn execute_step(&mut self, id: &str, params: &Value) -> Result<(Value, Value)> { + let outer = (std::mem::take(&mut self.journal_note), self.note_depth); + self.note_depth = self.depth + 1; + let r = self.execute(id, params); + let noted = self.noted(params); + (self.journal_note, self.note_depth) = outer; + Ok((r?, noted)) + } + /// The journal from entry `start` on, as an action records it: without the params that only /// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now. pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> { @@ -781,11 +820,7 @@ impl Session { .skip(start) .cloned() .map(|(id, mut p)| { - if let Value::Object(m) = &mut p { - for (_, key) in REPLAY_ONLY.iter().filter(|(c, _)| *c == id) { - m.remove(*key); - } - } + strip_replay_only(&id, &mut p); (id, p) }) .collect() diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs index 5b021f8cd..a9e5d7d41 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests_journal.rs @@ -97,3 +97,59 @@ fn a_save_records_its_date_and_replays_it() { assert_eq!(r.doc().unwrap().doc.metadata, s.doc().unwrap().doc.metadata); assert!(s.journal_for_action(0).iter().all(|(_, p)| p.get("modified").is_none() && p.get("created").is_none())); } + +/// The document `s` ends on after replaying the journal of `s` in a new session. +fn replayed(s: &Session) -> Session { + let mut r = Session::new(); + for (id, p) in &s.journal { + r.execute(id, p).unwrap(); + } + r +} + +#[test] +fn a_save_in_a_batch_records_its_date_and_replays_it() { + let path = vectorcraft_testkit::temp_dir("journal-batch").join("a.vectorcraft").to_string_lossy().into_owned(); + let mut s = Session::new(); + s.execute("file.new", &json!({"created": 1_000_000_000})).unwrap(); + let rect = json!({"x": 10, "y": 10, "width": 50, "height": 40}); + let steps = json!([{"command": "shape.rectangle", "params": rect}, {"command": "document.save", "params": {"path": path}}]); + s.execute("command.batch", &json!({ "commands": steps })).unwrap(); + let modified = s.doc().unwrap().doc.metadata.modified; + assert!(modified.is_some(), "the save in the batch dates the document"); + // The step's entry records the date it used; steps that take nothing from elsewhere stay as given. + let (id, p) = s.journal.last().unwrap().clone(); + assert_eq!(id, "command.batch"); + assert_eq!(p["commands"][0], steps[0]); + assert_eq!(p["commands"][1]["params"], json!({"path": path, "modified": modified})); + // As if the original run happened at another time: the replay lands on the recorded date. + s.journal.last_mut().unwrap().1["commands"][1]["params"]["modified"] = json!(1_234_567_890); + let r = replayed(&s); + assert_eq!(r.doc().unwrap().doc.metadata.modified, Some(1_234_567_890)); + assert_eq!(r.doc().unwrap().doc.metadata.created, Some(1_000_000_000)); + assert_eq!(r.doc().unwrap().doc.node_count(), s.doc().unwrap().doc.node_count()); + // An action leaves the step's date out, so playing it later dates the document then. + let action = s.journal_for_action(0); + assert_eq!(action[1].1["commands"][1]["params"], json!({ "path": path })); + assert_eq!(action[1].1["commands"][0], steps[0]); +} + +#[test] +fn a_batch_step_records_what_it_took_from_the_preferences() { + let mut s = Session::new(); + s.execute("file.new", &json!({"created": null})).unwrap(); + s.prefs.scale_strokes = true; + let steps = json!([ + {"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 40}}, + {"command": "stroke.set", "params": {"weight": 2}}, + {"command": "object.scale", "params": {"sx": 300}}, + ]); + s.execute("command.batch", &json!({ "commands": steps })).unwrap(); + let p = &s.journal.last().unwrap().1; + assert_eq!(p["commands"][2]["params"], json!({"sx": 300, "strokes": true, "corners": false})); + // Replayed with other preferences, the scale still scales the stroke. + let r = replayed(&s); + assert!(!r.prefs.scale_strokes); + let doc = |s: &Session| serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + assert_eq!(doc(&r), doc(&s)); +} diff --git a/docs/mcp.md b/docs/mcp.md index c1cc79e5c..5cb808fa3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -979,7 +979,8 @@ creation date) as text chunks, and SVG export writes the description as `` all of it as Dublin Core. `file.new {created}` and a save to a file (`document.save` or `file.saveAs` with `{path, modified}`) use the given date (Unix seconds, or `null` for none) instead of now. Their journal entries always record the date they used, so replaying the journal makes the same document whenever it runs (an action recorded in -the Actions panel leaves the date out: playing it later dates the document then). +the Actions panel leaves the date out: playing it later dates the document then). As a step of `command.batch`, a +command records the date it used (and any value it takes from the preferences) in its step of the batch's journal entry. ```json {"name":"run_command","arguments":{"command":"file.info","params":{"author":"Ada","keywords":"poster, fair","rating":4,"copyrightStatus":"copyrighted","copyrightNotice":"© 2026 Ada"}}} From 3d87b71ed43eba28f23068d3d03d0321c21352d1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:22:36 +0700 Subject: [PATCH 3/3] M14.4: a batch that opens a new document is journaled command.batch kept its steps in one interaction of the document active when it began and journaled itself by committing that interaction. A step that opened, closed or reverted a document broke both: the switch cancelled the interaction (throwing away the steps so far), and the commit then ran in the new document, which had none, so the batch was never journaled and a replay was missing the new document. An error after the switch rolled back only the active document. - Each step runs inside an interaction of the document active when it starts; a switch leaves it open while a batch runs, so every document the batch edits gets one undo step when it succeeds. - The batch journals itself, whichever document it ends in. - On error everything rolls back: documents the batch opened close, the ones it closed or reverted come back (Session::batch_stash), each one's interaction is undone, and the active document and the Untitled-N count go back too. - DocState::keep_interaction / undo_interaction share the undo-step and rollback code with commit_interaction / cancel_interaction. - Tests: a batch that opens a new document is journaled, replays to the same documents and keeps one undo step in each; a failed batch closes the document it opened and restores the one it closed; a failed batch brings back a document it reverted. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/batch.rs | 105 ++++++++++++++++++-------- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/engine/src/lib.rs | 69 +++++++++++------ crates/engine/src/tests_journal.rs | 78 +++++++++++++++++++ docs/mcp.md | 3 + 5 files changed, 202 insertions(+), 55 deletions(-) diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index a975d6c28..39a5026f2 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -1,50 +1,89 @@ -//! `command.batch`: several commands as one undo step. +//! `command.batch`: several commands as one undo step (in each document they edit). use serde_json::{Value, json}; use super::super::*; -use crate::EngineError; +use crate::{DocState, EngineError}; pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let mut cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone(); let label = str_param(p, "label").unwrap_or("Batch").to_string(); - s.begin_interaction(&label)?; - // Session-level state a step may change (paint defaults, drawing mode) rolls back too. - let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone()); - let mut results = vec![]; - for c in &mut cmds { - let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string(); - if id == "command.batch" { - s.cancel_interaction()?; - return Err(bad("command.batch", "batches cannot nest")); + // What an error rolls back besides the documents: session-level state a step may change + // (paint defaults, drawing mode, clipboard, the Untitled-N count). + let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter); + let (start, active): (Vec, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active); + s.batch_stash = Some(vec![]); + let r = run_steps(s, &label, &mut cmds); + let stash = s.batch_stash.take().unwrap_or_default(); + let results = match r { + Ok(results) => results, + Err(e) => { + roll_back(s, &start, stash); + s.active = active; + (s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved; + s.pending_paint = None; + return Err(e); } - let params = c.get("params").cloned().unwrap_or(json!({})); - match s.execute_step(&id, ¶ms) { - Ok((v, noted)) => { - // The step's journal entry records what it took from the preferences or the - // clock (a save's date), as a command of its own would. - if noted != params - && let Some(c) = c.as_object_mut() - { - c.insert("params".into(), noted); - } - results.push(v); - } - Err(e) => { - s.cancel_interaction()?; - (s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved; - return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len()))); - } + }; + // Each document the steps edited keeps them as one undo step. + s.remember_pending_paint(); + for st in &mut s.docs { + if let Some(it) = st.interaction.take() { + st.keep_interaction(it); } } - // Commit as one undo step (the interaction's "preview" is the batch itself). + // The batch journals itself, whichever document it ends in. let mut entry = p.clone(); if let Some(o) = entry.as_object_mut() { o.insert("commands".into(), Value::Array(cmds)); } - if let Some(it) = s.doc_mut()?.interaction.as_mut() { - it.preview = Some(("command.batch".into(), entry)); - } - s.commit_interaction()?; + s.journal.push(("command.batch".into(), entry)); Ok(json!({ "results": results })) } + +/// Run the steps → their results. Each runs inside an interaction of the document active when it +/// starts (one for all the steps in that document), so a step may open, switch or close documents. +fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result> { + let mut results = Vec::with_capacity(cmds.len()); + for c in cmds { + let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string(); + if id == "command.batch" { + return Err(bad("command.batch", "batches cannot nest")); + } + if s.active().is_some() { + s.begin_interaction(label)?; + } + let params = c.get("params").cloned().unwrap_or(json!({})); + let (v, noted) = + s.execute_step(&id, ¶ms).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?; + // The step's journal entry records what it took from the preferences or the clock (a + // save's date, a new document's), as a command of its own would. + if noted != params + && let Some(c) = c.as_object_mut() + { + c.insert("params".into(), noted); + } + results.push(v); + } + Ok(results) +} + +/// The documents as they were before the batch (`start`: their uids in order): the ones it opened +/// close, the ones it closed or replaced (`stash`) come back, and each one's interaction is undone. +fn roll_back(s: &mut Session, start: &[u64], mut stash: Vec) { + let mut open = std::mem::take(&mut s.docs); + for &uid in start { + // The newest revision the document reached, so the restored one redraws. + let newest = open.iter().chain(&stash).filter(|d| d.uid == uid).map(|d| d.revision).max().unwrap_or_default(); + // Its state before the batch: the first one a step closed or replaced, else the open one. + let st = match stash.iter().position(|d| d.uid == uid) { + Some(i) => Some(stash.remove(i)), + None => open.iter().position(|d| d.uid == uid).map(|i| open.remove(i)), + }; + if let Some(mut st) = st { + st.undo_interaction(); + st.revision = newest.saturating_add(1); + s.docs.push(st); + } + } +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 4a72a82a4..482730c14 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -108,7 +108,7 @@ pub fn specs() -> Vec { has_doc, screens::export_for_screens ), - cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch::batch), + cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", has_doc, batch::batch), cmd!( query "document.formats", "File Formats", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0b4c64e70..2bb2d64ac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -168,6 +168,24 @@ impl DocState { pub fn mark_saved_as(&mut self, snapshot: &Arc) { self.saved_doc = snapshot.clone(); } + /// Keep what interaction `it` (taken from this document) changed, as one undo step. + pub(crate) fn keep_interaction(&mut self, it: Interaction) { + if !Arc::ptr_eq(&it.doc, &self.doc) { + self.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection }); + self.history.redo.clear(); + self.revision += 1; + } + } + /// End the interaction in progress, undoing what it changed. + pub(crate) fn undo_interaction(&mut self) { + if let Some(it) = self.interaction.take() { + self.doc = it.doc; + self.selection = it.selection; + self.active_layer = it.active_layer; + self.isolation = it.isolation; + self.revision += 1; + } + } pub fn title(&self) -> String { let name = self .path @@ -611,6 +629,9 @@ pub struct Session { /// The depth of the command whose values [`Session::note_journal`] keeps: 1 (the top-level /// command), or a step's while a batch runs it ([`Session::execute_step`]). note_depth: u32, + /// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an + /// error brings back; `None` otherwise. + pub(crate) batch_stash: Option>, } impl Default for Session { @@ -649,6 +670,7 @@ impl Session { recent_urls: vec![], journal_note: Default::default(), note_depth: 1, + batch_stash: None, } } @@ -683,7 +705,11 @@ impl Session { let view = self.last_view; let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); let _ = self.apply_actions(acts); - let _ = self.cancel_interaction(); + // A batch leaves its interaction open in the document it leaves, to keep or roll back with + // the rest of the batch; anything else in progress (a drag) is cancelled. + if self.batch_stash.is_none() { + let _ = self.cancel_interaction(); + } self.tool = vectorcraft_tools::create(self.tool.id()); } pub fn set_active(&mut self, index: usize) -> bool { @@ -754,7 +780,10 @@ impl Session { st.save_options = old.save_options.clone(); st.converted = old.converted; st.view = old.view.clone(); - self.docs[index] = st; + let old = std::mem::replace(&mut self.docs[index], st); + if let Some(stash) = &mut self.batch_stash { + stash.push(old); + } true } pub fn close_document(&mut self, index: usize) -> bool { @@ -762,7 +791,10 @@ impl Session { return false; } self.reset_tool_for_doc_switch(); - self.docs.remove(index); + let old = self.docs.remove(index); + if let Some(stash) = &mut self.batch_stash { + stash.push(old); + } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; true } @@ -778,6 +810,7 @@ impl Session { if self.depth == 0 { self.journal_note.clear(); self.note_depth = 1; + self.batch_stash = None; } let r = if self.depth == 0 { self.run_guarded(id, |s| (spec.run)(s, params)) } else { self.run_nested(|s| (spec.run)(s, params)) }; let r = r?; @@ -969,17 +1002,11 @@ impl Session { } pub fn commit_interaction(&mut self) -> Result<()> { - if let Some(p) = self.pending_paint.take() { - self.remember_paint_now(&p); - } + self.remember_pending_paint(); let st = self.doc_mut()?; - let Some(it) = st.interaction.take() else { return Ok(()) }; - let Some(preview) = it.preview else { return Ok(()) }; - if !Arc::ptr_eq(&it.doc, &st.doc) { - st.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection }); - st.history.redo.clear(); - st.revision += 1; - } + let Some(mut it) = st.interaction.take() else { return Ok(()) }; + let Some(preview) = it.preview.take() else { return Ok(()) }; + st.keep_interaction(it); if preview.0 == "object.transform" { let m = cmd::matrix_param(&preview.1, "matrix"); let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); @@ -991,16 +1018,16 @@ impl Session { Ok(()) } + /// Remember the paint a live preview applied (as its interaction is kept). + pub(crate) fn remember_pending_paint(&mut self) { + if let Some(p) = self.pending_paint.take() { + self.remember_paint_now(&p); + } + } + pub fn cancel_interaction(&mut self) -> Result<()> { self.pending_paint = None; - let st = self.doc_mut()?; - if let Some(it) = st.interaction.take() { - st.doc = it.doc; - st.selection = it.selection; - st.active_layer = it.active_layer; - st.isolation = it.isolation; - st.revision += 1; - } + self.doc_mut()?.undo_interaction(); Ok(()) } diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs index a9e5d7d41..8bbaa6054 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests_journal.rs @@ -153,3 +153,81 @@ fn a_batch_step_records_what_it_took_from_the_preferences() { let doc = |s: &Session| serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); assert_eq!(doc(&r), doc(&s)); } + +/// Every open document of `s`, as JSON. +fn documents(s: &Session) -> Vec { + s.documents().iter().map(|d| serde_json::to_value(&*d.doc).unwrap()).collect() +} + +fn rect_step(x: i32) -> serde_json::Value { + json!({"command": "shape.rectangle", "params": {"x": x, "y": 10, "width": 20, "height": 20}}) +} + +#[test] +fn a_batch_that_opens_a_new_document_is_journaled_and_replays() { + let mut s = Session::new(); + s.execute("file.new", &json!({"created": null})).unwrap(); + let steps = json!([rect_step(10), {"command": "file.new", "params": {"width": 300, "height": 200}}, rect_step(50), rect_step(90)]); + s.execute("command.batch", &json!({"label": "Two documents", "commands": steps})).unwrap(); + // The steps before the new document stay in the first one and the rest go in the new one: one + // undo step in each, nothing left in progress. + assert_eq!(s.documents().len(), 2); + assert_eq!(s.active_index(), Some(1)); + for (st, objects) in s.documents().iter().zip([1, 2]) { + assert_eq!(st.doc.node_count(), 1 + objects); + assert_eq!(st.history.undo.iter().map(|h| h.label.as_str()).collect::>(), ["Two documents"]); + assert!(st.interaction.is_none()); + } + // The batch is journaled, with the new document's date, and replays to the same documents. + let (id, p) = s.journal.last().unwrap().clone(); + assert_eq!(id, "command.batch"); + assert!(p["commands"][1]["params"]["created"].is_i64(), "{p}"); + let r = replayed(&s); + assert_eq!(documents(&r), documents(&s)); + assert_eq!(r.active_index(), s.active_index()); + // Undo in the new document takes its part of the batch back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node_count(), 1); +} + +#[test] +fn a_failed_batch_closes_the_documents_it_opened_and_restores_the_ones_it_closed() { + let mut s = Session::new(); + s.execute("file.new", &json!({"created": null})).unwrap(); + s.execute("file.new", &json!({"created": null})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 30, "height": 30})).unwrap(); + let (before, active, journal, next) = (documents(&s), s.active_index(), s.journal.len(), s.peek_untitled()); + let steps = json!([ + rect_step(10), + {"command": "file.close", "params": {}}, + rect_step(30), + {"command": "file.new", "params": {}}, + rect_step(50), + {"command": "no.such.command", "params": {}}, + ]); + let e = s.execute("command.batch", &json!({ "commands": steps })).unwrap_err(); + assert!(e.to_string().contains("batch step 5"), "{e}"); + assert_eq!(documents(&s), before); + assert_eq!(s.active_index(), active); + assert_eq!(s.journal.len(), journal, "a failed batch isn't journaled"); + assert_eq!(s.peek_untitled(), next, "the next new document keeps its name"); + assert!(s.documents().iter().all(|d| d.interaction.is_none())); + assert_eq!(s.documents()[1].history.undo.len(), 1, "only the ellipse drawn before the batch"); +} + +#[test] +fn a_failed_batch_brings_back_a_document_it_reverted() { + let path = vectorcraft_testkit::temp_dir("journal-batch").join("revert.vectorcraft").to_string_lossy().into_owned(); + let mut s = Session::new(); + s.execute("file.new", &json!({"created": null})).unwrap(); + s.execute("document.save", &json!({"path": path, "modified": null})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 30, "height": 30})).unwrap(); + let (before, revision) = (documents(&s), s.doc().unwrap().revision); + let steps = json!([rect_step(10), {"command": "file.revert", "params": {}}, rect_step(30), {"command": "no.such.command"}]); + assert!(s.execute("command.batch", &json!({ "commands": steps })).is_err()); + assert_eq!(documents(&s), before, "the unsaved ellipse is back"); + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), 1); + assert!(st.is_dirty() && st.interaction.is_none()); + assert!(st.revision > revision, "the restored document redraws"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 5cb808fa3..d5d406af7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -981,6 +981,9 @@ all of it as Dublin Core. `file.new {created}` and a save to a file (`document.s record the date they used, so replaying the journal makes the same document whenever it runs (an action recorded in the Actions panel leaves the date out: playing it later dates the document then). As a step of `command.batch`, a command records the date it used (and any value it takes from the preferences) in its step of the batch's journal entry. +A batch may open, switch, close or revert documents: each document it edits gets one undo step, the batch is +journaled whichever document it ends in, and an error rolls every document back (the ones it opened close, the ones +it closed or reverted come back). ```json {"name":"run_command","arguments":{"command":"file.info","params":{"author":"Ada","keywords":"poster, fair","rating":4,"copyrightStatus":"copyrighted","copyrightNotice":"© 2026 Ada"}}}