From 6cd57cfa1bed3b9fa35a8c19978c1c4b0e11e506 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 17:03:40 -0700 Subject: [PATCH] Undo groups: an open undo group also groups the edits in a document opened or created before its end A document opened or created between edit.undoGroup.begin and edit.undoGroup.end opens an undo group of its own with the begun group's name, so an atomic command_batch that starts with file.new or document.open is one undo step in the new document. The group opens after the command that opens the document: when that command also edits it (a command.batch), those edits stay the command's own undo step, and a canceled group keeps them, so the journal still replays. Both commands also run with no document open, and a command.batch that fails rolls back a begun group. Switching to a document that was already open does not open a group in it. --- crates/engine/src/cmd/edit.rs | 7 +- crates/engine/src/cmd/fileio/batch.rs | 6 +- crates/engine/src/lib.rs | 43 +++++++- crates/engine/src/tests_journal.rs | 138 ++++++++++++++++++++++++++ crates/engine/src/tests_library.rs | 5 +- crates/mcp/src/tests_conventions.rs | 49 +++++++++ crates/mcp/src/tools.rs | 4 +- crates/ui-egui/src/tests_scrub.rs | 10 +- docs/mcp.md | 2 +- 9 files changed, 249 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3ef1f0ab7..fdfe5c075 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -99,8 +99,8 @@ pub fn specs() -> Vec { "Begin Undo Group", [], None, - "{label?: string} until edit.undoGroup.end, the edits made in the active document are one undo step, named `label` (else after its first edit): for automation, such as MCP command_batch with `atomic` (#1163)", - has_doc, + "{label?: string} until edit.undoGroup.end, the edits made in the document active at the begin, and in each document opened or created before then (also when no document is open; the edits of a command that opens a document and edits it stay that command's undo step), are one undo step in that document, named `label` (else after its first edit); in a document that was open before the begin and is switched to (document.activate), each edit stays its own undo step: for automation, such as MCP command_batch with `atomic` (#1163)", + always, undo_group_begin ), cmd!( @@ -109,7 +109,7 @@ pub fn specs() -> Vec { [], None, "{cancel?: bool} close the undo group: its edits stay one undo step or, with `cancel`, are undone", - has_doc, + always, undo_group_end ), cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut), @@ -427,6 +427,7 @@ fn undo_group_begin(s: &mut Session, p: &Value) -> Result { Some(Value::String(l)) if !l.trim().is_empty() && l.chars().count() <= 200 => Some(l.trim().to_string()), Some(_) => return Err(bad(C, "`label` must be a non-empty name of at most 200 characters")), }; + s.explicit_undo_group = Some(label.clone()); s.begin_named_undo_group(label); ok() } diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index 4b017caa7..cf760ddda 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -11,8 +11,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { // The active artboard the app passes, for the steps that act on it. let artboard = p.get("artboard").and_then(Value::as_u64); // 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); + // (paint defaults, drawing mode, clipboard, the Untitled-N count, a begun undo group). + let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter, s.explicit_undo_group.clone()); // A drag or typing still in progress is a step of its own, as before Undo: the batch's steps // would otherwise run inside it and take its baseline as theirs (#1697). if s.active().is_some() { @@ -30,7 +30,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { 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.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter, s.explicit_undo_group) = saved; s.pending_paint = None; return Err(e); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e290abebc..0854dcaab 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1431,6 +1431,10 @@ pub struct Session { /// The running top-level query command changed the selection and is journaled like any other /// command ([`Session::journal_query`]). journal_query: bool, + /// From `edit.undoGroup.begin` to `edit.undoGroup.end`: the name of the undo step (`None`: after + /// its first edit). Each document opened or created meanwhile opens an undo group of its own + /// ([`Session::add_document`]). `None` otherwise. + pub(crate) explicit_undo_group: Option>, /// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an /// error brings back; `None` otherwise. pub(crate) batch_stash: Option>, @@ -1563,6 +1567,7 @@ impl Session { journal_note: Default::default(), note_depth: 1, journal_query: false, + explicit_undo_group: None, batch_stash: None, place_hand_off: None, queue_hand_off: None, @@ -1665,7 +1670,9 @@ impl Session { refresh_tree_text_bounds(tree); } } - /// Add a document and make it active. + /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, + /// an undo group with the begun group's name opens in it: here outside a command, and after the + /// command that adds it otherwise ([`Session::execute`]). pub fn add_document(&mut self, mut doc: Document, path: Option) -> usize { // Every edit is checked against the document before it, so the first one is within the cap // (importers and the native reader have seen to theirs already, with a warning). @@ -1680,6 +1687,11 @@ impl Session { self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); + if self.depth == 0 + && let Some(label) = self.explicit_undo_group.clone() + { + self.begin_named_undo_group(label); + } i } /// Replace the document in tab `index` (File → Revert): new content, cleared history and @@ -1843,11 +1855,15 @@ impl Session { // The entries the command journals belong to the undo groups of the documents it edits, and to // the group of the document it starts in when it ends there too. let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs()); + let open = (self.depth == 0).then(|| self.docs.iter().map(|d| d.uid).collect::>()); let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) }; let r = r?; if self.depth == 0 { self.inherit_new_art(); } + if let Some(open) = open { + self.open_undo_groups_in_new_documents(&open); + } let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query)); if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { let p = self.noted(params); @@ -2279,6 +2295,20 @@ impl Session { } } + /// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, open an undo group with the begun + /// group's name in each document that the top-level command just run opened or created (its + /// [`DocState::uid`] is not in `open`). The group takes the edits after that command: the + /// command's own edits there stay its undo step, and a canceled group keeps them, as the journal + /// entry that made the document stays. + fn open_undo_groups_in_new_documents(&mut self, open: &[u64]) { + let Some(label) = &self.explicit_undo_group else { return }; + for st in &mut self.docs { + if !open.contains(&st.uid) && st.undo_group.is_none() { + st.undo_group = Some(UndoGroup { first: None, journal: vec![], label: label.clone() }); + } + } + } + /// The documents with an undo group open ([`DocState::uid`]), each with the document it holds. fn undo_group_docs(&self) -> Vec<(u64, *const Document)> { self.docs.iter().filter(|st| st.undo_group.is_some()).map(|st| (st.uid, Arc::as_ptr(&st.doc))).collect() @@ -2311,8 +2341,15 @@ impl Session { } /// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are - /// undone and dropped from the journal. + /// undone and dropped from the journal. A group begun with `edit.undoGroup.begin` ends too, so + /// documents opened after this open no undo group. pub fn end_undo_group(&mut self, cancel: bool) { + self.explicit_undo_group = None; + self.close_undo_groups(cancel); + } + + /// [`Session::end_undo_group`] without ending a group begun with `edit.undoGroup.begin`. + fn close_undo_groups(&mut self, cancel: bool) { let mut journal: Vec<(usize, Option)> = vec![]; for st in &mut self.docs { let Some(g) = st.undo_group.take() else { continue }; @@ -2353,7 +2390,7 @@ impl Session { // The other documents' groups are set aside while `end_undo_group` closes this one's. let others: Vec<(u64, UndoGroup)> = self.docs.iter_mut().filter(|st| st.uid != uid).filter_map(|st| Some((st.uid, st.undo_group.take()?))).collect(); - self.end_undo_group(cancel); + self.close_undo_groups(cancel); for (other, g) in others { if let Some(st) = self.document_mut(other) { st.undo_group = Some(g); diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs index 04d2bb71d..6386944dd 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests_journal.rs @@ -520,6 +520,144 @@ fn an_undo_group_is_one_undo_step() { assert_eq!(undo_len(&s), undo + 2); } +/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, each document created meanwhile opens an +/// undo group with the begun group's name, also when no document was open at the begin: its edits +/// are one undo step there, and a cancel undoes them and leaves the document open. A group opened +/// without the command (a scrubbed field) and a begin that a failed batch rolled back open no group +/// in a new document. +#[test] +fn an_undo_group_begun_by_command_opens_in_each_document_created_before_its_end() { + let mut s = Session::new(); + let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50}); + let new = json!({"width": 400, "height": 300}); + let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::>(); + let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()); + s.execute("edit.undoGroup.begin", &json!({"label": "Job"})).unwrap(); + for _ in 0..2 { + s.execute("file.new", &new).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("shape.ellipse", &rect).unwrap(); + } + s.execute("edit.undoGroup.end", &json!({})).unwrap(); + assert_eq!(labels(&s), ["Job"]); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(labels(&s), ["Job"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s), 0, "one undo takes back both edits"); + + // Closed: the next document's edits are steps of their own. + s.execute("file.new", &new).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + assert_eq!(labels(&s).len(), 2); + + // Canceled: the new document stays open without the group's edits, and the journal replays to it. + let count = s.documents().len(); + s.execute("edit.undoGroup.begin", &json!({})).unwrap(); + s.execute("file.new", &new).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("edit.undoGroup.end", &json!({"cancel": true})).unwrap(); + assert_eq!(s.documents().len(), count + 1); + assert_eq!((objects(&s), labels(&s).len()), (0, 0)); + assert_eq!(documents(&replayed(&s)), documents(&s)); + + // A scrubbed field's group stays in its own document. + s.begin_undo_group(); + s.execute("file.new", &new).unwrap(); + assert!(s.doc().unwrap().undo_group.is_none()); + s.end_undo_group(false); + + // A group begun by command and closed with `end_undo_group` opens no group in a later document. + s.execute("edit.undoGroup.begin", &json!({})).unwrap(); + s.end_undo_group(false); + s.execute("file.new", &new).unwrap(); + assert!(s.doc().unwrap().undo_group.is_none()); + + // A batch that begins a group and then fails leaves no group begun. + let steps = json!([{"command": "edit.undoGroup.begin"}, {"command": "no.such.command"}]); + assert!(s.execute("command.batch", &json!({ "commands": steps })).is_err()); + s.execute("file.new", &new).unwrap(); + assert!(s.doc().unwrap().undo_group.is_none()); +} + +/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, a command that creates a document and +/// edits it (a `command.batch`) keeps its edits as an undo step of its own in that document, and the +/// edits after it are the group's step. A cancel undoes only those, keeps the document and the +/// command's edits, and the journal replays to the same documents. +#[test] +fn an_undo_group_leaves_out_the_edits_of_the_command_that_creates_its_document() { + let mut s = Session::new(); + let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50}); + let batch = json!({"commands": [ + {"command": "file.new", "params": {"width": 400, "height": 300}}, + {"command": "shape.rectangle", "params": rect}, + ]}); + let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::>(); + let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + for cancel in [false, true] { + s.execute("edit.undoGroup.begin", &json!({"label": "Job"})).unwrap(); + s.execute("command.batch", &batch).unwrap(); + s.execute("shape.ellipse", &rect).unwrap(); + s.execute("shape.ellipse", &rect).unwrap(); + s.execute("edit.undoGroup.end", &json!({ "cancel": cancel })).unwrap(); + let (objects, steps) = (objects(&s), labels(&s).len()); + assert_eq!((objects, steps), if cancel { (1, 1) } else { (3, 2) }, "cancel: {cancel}"); + assert_eq!(documents(&replayed(&s)), documents(&s), "cancel: {cancel}"); + } + assert_eq!(s.documents().len(), 3); +} + +/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, switching to a document that was open +/// before the begin opens no group in it: each edit made there is an undo step of its own. The edits +/// made in the document active at the begin, and in a document created meanwhile, stay one undo step +/// each after a switch away and back, and the journal replays to the same documents. +#[test] +fn an_undo_group_begun_by_command_opens_no_group_in_a_document_switched_to() { + let mut s = Session::new(); + let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50}); + let new = json!({"width": 400, "height": 300}); + let labels = |s: &Session, i: usize| s.documents()[i].history.undo.iter().map(|e| e.label.clone()).collect::>(); + let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()); + s.execute("file.new", &new).unwrap(); + s.execute("file.new", &new).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute("edit.undoGroup.begin", &json!({"label": "Job"})).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + assert!(s.doc().unwrap().undo_group.is_none()); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("shape.ellipse", &rect).unwrap(); + s.execute("file.new", &new).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("document.activate", &json!({"index": 2})).unwrap(); + s.execute("shape.ellipse", &rect).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute("shape.ellipse", &rect).unwrap(); + s.execute("edit.undoGroup.end", &json!({})).unwrap(); + assert_eq!((labels(&s, 0), labels(&s, 2)), (vec!["Job".to_string()], vec!["Job".to_string()])); + assert_eq!(labels(&s, 1).len(), 3, "{:?}", labels(&s, 1)); + assert!(s.documents().iter().all(|d| d.undo_group.is_none())); + assert_eq!(documents(&replayed(&s)), documents(&s)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s), 0, "one undo takes back both edits in the document active at the begin"); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s), 2, "one undo takes back one edit in the document switched to"); +} + +/// `edit.undoGroup.begin`'s params text gives the undo step's default name next to `label`, and the +/// clause on a document switched to says only that its edits stay steps of their own. +#[test] +fn the_undo_group_params_text_names_the_default_label_next_to_label() { + let params = cmd::find_command("edit.undoGroup.begin").unwrap().params; + assert!(params.contains("named `label` (else after its first edit); in a document"), "{params}"); + assert!(params.contains("each edit stays its own undo step: for automation"), "{params}"); +} + /// An undo scope ([`Session::begin_undo_scope`]) in a document without an open undo group opens /// one, and [`Session::end_undo_scope`] closes that group only, whichever document is active, and /// leaves another document's group open; canceled, it undoes only that group's edits and cuts the diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 31398944c..c82f471d6 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -153,9 +153,12 @@ fn group_open_in_another_document() -> (Session, String, String, String) { run(&mut s, "select.set", json!({"ids": [t]})); let char_style = run(&mut s, "library.add", json!({"library": lib, "kind": "charStyle"}))["name"].as_str().unwrap().to_string(); let para_style = run(&mut s, "library.add", json!({"library": lib, "kind": "paraStyle"}))["name"].as_str().unwrap().to_string(); + // The second document exists before the group begins, so no group opens in it. + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + run(&mut s, "document.activate", json!({"index": 0})); run(&mut s, "edit.undoGroup.begin", json!({})); run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})); - run(&mut s, "file.new", json!({"width": 200, "height": 200})); + run(&mut s, "document.activate", json!({"index": 1})); let u = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone(); run(&mut s, "select.set", json!({"ids": [u]})); (s, lib, char_style, para_style) diff --git a/crates/mcp/src/tests_conventions.rs b/crates/mcp/src/tests_conventions.rs index 709ecd089..943ec0094 100644 --- a/crates/mcp/src/tests_conventions.rs +++ b/crates/mcp/src/tests_conventions.rs @@ -149,6 +149,55 @@ fn render_preview_and_screenshot_match_a_png_export_on_white() { } } +/// An atomic `command_batch` that starts with `file.new` makes the edits after it one undo step in +/// the new document, with or without a document open before it, and also when a later step fails. +#[test] +fn an_atomic_batch_is_one_undo_step_in_a_document_it_creates() { + let mut steps = vec![ + json!({"id":"file.new","params":{"width":200,"height":100}}), + json!({"id":"shape.rectangle","params":{"x":0,"y":0,"width":5,"height":5}}), + json!({"id":"shape.ellipse","params":{"x":9,"y":0,"width":5,"height":5}}), + ]; + for fail in [false, true] { + if fail { + steps.push(json!({"id":"no.such.command"})); + } + for backend in [Headless::new(), Headless::with_document()] { + let mut s = Server::new(Box::new(backend)); + let r = call(&mut s, "command_batch", json!({"steps":steps,"atomic":true,"label":"Job"})); + assert_eq!(r["isError"], fail, "{r}"); + let p = payload(&r); + assert_eq!((p["completed"].clone(), p["failed"].clone()), (json!(3), json!(u8::from(fail))), "{r}"); + assert_eq!(payload(&call(&mut s, "doc_inspect", json!({"depth":0})))["history"], json!(["Job"])); + } + } +} + +/// In an atomic `command_batch`, the edits made in a document that was open before the batch and +/// that a step switches to are undo steps of their own, and the edits in the document active when +/// the batch began are one undo step, also when they come before and after the switch. +#[test] +fn an_atomic_batch_groups_no_edits_in_a_document_it_switches_to() { + let mut s = Server::new(Box::new(Headless::with_document())); + let r = call(&mut s, "command_run", json!({"id":"file.new","params":{"width":200,"height":100}})); + assert_eq!(r["isError"], false, "{r}"); + let rect = json!({"x":0,"y":0,"width":5,"height":5}); + let steps = json!([ + {"id":"shape.rectangle","params":rect}, + {"id":"document.activate","params":{"index":0}}, + {"id":"shape.rectangle","params":rect}, + {"id":"shape.ellipse","params":rect}, + {"id":"document.activate","params":{"index":1}}, + {"id":"shape.ellipse","params":rect}, + ]); + let r = call(&mut s, "command_batch", json!({"steps":steps,"atomic":true,"label":"Job"})); + assert_eq!((r["isError"].clone(), payload(&r)["completed"].clone()), (json!(false), json!(6)), "{r}"); + let history = |s: &mut Server| payload(&call(s, "doc_inspect", json!({"depth":0})))["history"].clone(); + assert_eq!(history(&mut s), json!(["Job"])); + call(&mut s, "command_run", json!({"id":"document.activate","params":{"index":0}})); + assert_eq!(history(&mut s).as_array().map(Vec::len), Some(2), "{}", history(&mut s)); +} + struct Panics; impl Backend for Panics { fn call(&mut self, _: &str, _: Value) -> Result { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1ad592bb8..0c8826a91 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -133,9 +133,9 @@ pub fn tool_definitions() -> Vec { tool( "command_batch", "Run several commands", - "Run steps in order; each edit is its own undo step, or with atomic:true the whole batch is one (named `label`, else after its first edit). A step's id is a command id, or draw_path / draw_shape with that tool's arguments as params (its result is the tool's: the new object's `id`). A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed, results and created (the ids draw_path / draw_shape steps made, in order). Stops on the first error by default. At most 256 steps; kept step results total at most 8 MiB, past which the batch stops with the reason.", + "Run steps in order; each edit is its own undo step, or with atomic:true the whole batch is one (named `label`, else after its first edit; `atomic` says which documents). A step's id is a command id, or draw_path / draw_shape with that tool's arguments as params (its result is the tool's: the new object's `id`). A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed, results and created (the ids draw_path / draw_shape steps made, in order). Stops on the first error by default. At most 256 steps; kept step results total at most 8 MiB, past which the batch stops with the reason.", obj( - json!({"steps": {"type":"array", "maxItems": 256, "items":obj(json!({"id":string("Command id, or draw_path / draw_shape"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}, "atomic":{"type":"boolean", "default":false, "description":"Make the batch's edits one undo step (needs an open document)"}, "label":string("The undo step's name with atomic")}), + json!({"steps": {"type":"array", "maxItems": 256, "items":obj(json!({"id":string("Command id, or draw_path / draw_shape"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}, "atomic":{"type":"boolean", "default":false, "description":"Make the batch's edits one undo step in the active document and in each document a step opens or creates; in a document open before the batch that a step switches to, each edit stays its own undo step"}, "label":string("The undo step's name with atomic")}), &["steps"], ), false, diff --git a/crates/ui-egui/src/tests_scrub.rs b/crates/ui-egui/src/tests_scrub.rs index adb6b161d..21091350f 100644 --- a/crates/ui-egui/src/tests_scrub.rs +++ b/crates/ui-egui/src/tests_scrub.rs @@ -290,10 +290,13 @@ fn scrubbing_w_in_the_app_is_one_undo_step() { #[test] fn a_scrub_leaves_another_documents_undo_group_open() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); + // The second document exists before the group begins, so no group opens in it. app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("document.activate", &json!({"index": 0})).unwrap(); app.session.execute("edit.undoGroup.begin", &json!({})).unwrap(); app.session.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 50})).unwrap(); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("document.activate", &json!({"index": 1})).unwrap(); app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); let ctx = egui::Context::default(); let other = |app: &VectorcraftApp| (app.session.documents()[0].undo_group.is_some(), objects(&app.session.documents()[0])); @@ -334,9 +337,12 @@ fn a_scrub_inside_an_open_undo_group_joins_its_step() { #[test] fn a_scrub_closes_its_group_in_its_own_document_after_a_switch() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); + // The second document exists before the group begins, so no group opens in it. app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("document.activate", &json!({"index": 0})).unwrap(); app.session.execute("edit.undoGroup.begin", &json!({})).unwrap(); - app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("document.activate", &json!({"index": 1})).unwrap(); app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); let ctx = egui::Context::default(); let undo = undo_len(&app); diff --git a/docs/mcp.md b/docs/mcp.md index 067730e46..a262ac217 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -349,7 +349,7 @@ The older names below remain listed because existing workflows use them. |---|---| | `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) | -| `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath); failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | +| `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | | `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` | | `render_preview` | Optional `max_side` (1–4096, default 1024) and `artboard` (0-based, default 0); inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). Artboards too large to render within the allocation bound return a tool error | | `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode |