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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 14:22:36 +07:00
parent 766b612070
commit 3d87b71ed4
5 changed files with 202 additions and 55 deletions

View File

@ -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<Value> {
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<u64>, _) = (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, &params) {
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<Vec<Value>> {
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, &params).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<DocState>) {
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);
}
}
}

View File

@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
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",

View File

@ -168,6 +168,24 @@ impl DocState {
pub fn mark_saved_as(&mut self, snapshot: &Arc<Document>) {
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<Vec<DocState>>,
}
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(())
}

View File

@ -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<serde_json::Value> {
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::<Vec<_>>(), ["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");
}

View File

@ -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"}}}