mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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>
90 lines
4.0 KiB
Rust
90 lines
4.0 KiB
Rust
//! `command.batch`: several commands as one undo step (in each document they edit).
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use super::super::*;
|
|
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();
|
|
// 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);
|
|
}
|
|
};
|
|
// 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);
|
|
}
|
|
}
|
|
// 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));
|
|
}
|
|
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, ¶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<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);
|
|
}
|
|
}
|
|
}
|