Merge pull request #1179 from storytold/fix/1163-atomic-batch

#1163: command_batch can be one undo step
This commit is contained in:
BFlat
2026-10-11 18:22:58 +07:00
committed by GitHub
15 changed files with 94 additions and 6 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+34
View File
@@ -39,6 +39,24 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
"edit.undoGroup.begin",
"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,
undo_group_begin
),
cmd!(
"edit.undoGroup.end",
"End Undo Group",
[],
None,
"{cancel?: bool} close the undo group: its edits stay one undo step or, with `cancel`, are undone",
has_doc,
undo_group_end
),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} (the Artboard tool chosen: artboard.copy)", has_selection_or_artboard_tool, copy),
cmd!(
@@ -167,6 +185,22 @@ pub(crate) fn typing_in_progress(s: &Session) -> bool {
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
}
fn undo_group_begin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.undoGroup.begin";
let label = match p.get("label") {
None | Some(Value::Null) => None,
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.begin_named_undo_group(label);
ok()
}
fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
s.end_undo_group(bool_or(p, "cancel", false));
ok()
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
+12 -2
View File
@@ -100,6 +100,8 @@ pub struct UndoGroup {
first: Option<Arc<Document>>,
/// The journal's length when the group began: a cancelled group drops the entries after it.
journal: usize,
/// The undo step's name; else the first edit's.
label: Option<String>,
}
/// Per-document editing state.
@@ -285,13 +287,16 @@ impl DocState {
}
/// Record undo step `e` (the document before an edit). In an undo group only the group's first
/// edit records one: the edits after it extend that step.
fn push_undo(&mut self, e: HistoryEntry) {
fn push_undo(&mut self, mut e: HistoryEntry) {
if let Some(g) = &mut self.undo_group {
let recorded = |first: &Arc<Document>| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first));
if g.first.as_ref().is_some_and(recorded) {
return;
}
g.first = Some(e.doc.clone());
if let Some(l) = &g.label {
e.label = l.clone();
}
}
self.history.undo.push(e);
if self.history.undo.len() > self.history.limit {
@@ -1417,11 +1422,16 @@ impl Session {
/// applies each value it passes as its own command, as a typed value is applied; the drag is
/// one step.
pub fn begin_undo_group(&mut self) {
self.begin_named_undo_group(None);
}
/// [`Session::begin_undo_group`] with the undo step named `label` (else after its first edit).
pub fn begin_named_undo_group(&mut self, label: Option<String>) {
let journal = self.journal.len();
if let Some(st) = self.active_mut()
&& st.undo_group.is_none()
{
st.undo_group = Some(UndoGroup { first: None, journal });
st.undo_group = Some(UndoGroup { first: None, journal, label });
}
}
+12
View File
@@ -55,6 +55,18 @@ fn conventions_commands_batch_and_bounded_preview() {
assert_eq!(p["failed"], 1);
assert_eq!(p["completed"], total - 1);
}
// atomic: the whole batch is one undo step, named by `label` (#1163).
let undo_steps = |s: &mut Server| payload(&call(s, "doc_inspect", json!({"depth":0})))["history"].as_array().map_or(0, Vec::len);
let before = undo_steps(&mut s);
let two = json!([{"id":"shape.rectangle","params":{"x":0,"y":0,"width":5,"height":5}}, {"id":"shape.ellipse","params":{"x":9,"y":0,"width":5,"height":5}}]);
let r = call(&mut s, "command_batch", json!({"steps":two,"atomic":true,"label":"Agent build"}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(undo_steps(&mut s), before + 1);
assert_eq!(payload(&call(&mut s, "run_command", json!({"command":"edit.undo"})))["undone"], "Agent build");
assert_eq!(undo_steps(&mut s), before);
for bad in [json!({"steps":two,"label":"x"}), json!({"steps":two,"atomic":"yes"}), json!({"steps":two,"atomic":true,"label":""})] {
assert_eq!(call(&mut s, "command_batch", bad.clone())["isError"], true, "{bad}");
}
let before = call(&mut s, "doc_inspect", json!({}));
let r = call(&mut s, "render_preview", json!({"max_side":64}));
assert_eq!(r["isError"], false, "{r}");
+16 -2
View File
@@ -96,9 +96,9 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"command_batch",
"Run several commands",
"Run steps in order; each edit is its own undo step. 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 and results. Stops on the first error by default.",
"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 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 and results. Stops on the first error by default.",
obj(
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}}),
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"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")}),
&["steps"],
),
false,
@@ -905,6 +905,16 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
None => true,
Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?,
};
let atomic = match a.get("atomic") {
None => false,
Some(v) => v.as_bool().ok_or("`atomic` must be a boolean")?,
};
if atomic {
let label = a.get("label").cloned().unwrap_or(Value::Null);
b.call("engine.execute", json!({"command":"edit.undoGroup.begin","params":{"label":label}}))?;
} else if a.get("label").is_some() {
return Err("`label` names the undo step of an atomic batch: give atomic:true too".into());
}
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
// Each step's result (null for a failed one), for later steps' `"$N.path"` references.
let mut values: Vec<Value> = vec![];
@@ -939,6 +949,10 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
}
}
}
// The steps that ran stay, as one undo step.
if atomic {
b.call("engine.execute", json!({"command":"edit.undoGroup.end","params":{}}))?;
}
let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results}));
r.is_error = failed > 0;
Ok(r)
+2
View File
@@ -2101,6 +2101,8 @@
Redefine Symbol Symbol neu definieren
Redefine {kind} Style {kind}format neu definieren
Redo Wiederholen
Begin Undo Group Rückgängig-Gruppe beginnen
End Undo Group Rückgängig-Gruppe beenden
Redo {name} Wiederholen: {name}
Redo {} Wiederholen: {}
Reduce Colors Farben reduzieren
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2152,6 +2152,8 @@
Redefine Symbol Redefinir símbolo
Redefine {kind} Style Redefinir estilo de {kind}
Redo Rehacer
Begin Undo Group Iniciar grupo de deshacer
End Undo Group Finalizar grupo de deshacer
Redo {name} Rehacer {name}
Redo {} Rehacer {}
Reduce Colors Reducir colores
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2149,6 +2149,8 @@
Redefine Symbol Redéfinir le symbole
Redefine {kind} Style Redéfinir le style de {kind}
Redo Rétablir
Begin Undo Group Commencer un groupe d’annulation
End Undo Group Terminer le groupe d’annulation
Redo {name} Rétablir {name}
Redo {} Rétablir {}
Reduce Colors Réduire les couleurs
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2152,6 +2152,8 @@
Redefine Symbol Ridefinisci simbolo
Redefine {kind} Style Ridefinisci stile {kind}
Redo Ripeti
Begin Undo Group Inizia gruppo di annullamento
End Undo Group Termina gruppo di annullamento
Redo {name} Ripeti {name}
Redo {} Ripeti {}
Reduce Colors Riduci colori
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -101,6 +101,8 @@
Edit 編集
Undo 取り消し
Redo やり直し
Begin Undo Group 取り消しグループを開始
End Undo Group 取り消しグループを終了
Cut 切り取り
Copy コピー
Paste 貼り付け
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2148,6 +2148,8 @@
Redefine Symbol Переопределить символ
Redefine {kind} Style Переопределить стиль {kind}
Redo Повторить
Begin Undo Group Начать группу отмены
End Undo Group Завершить группу отмены
Redo {name} Повторить {name}
Redo {} Повторить {}
Reduce Colors Сократить число цветов
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2144,6 +2144,8 @@
Redefine Symbol Перевизначити символ
Redefine {kind} Style Перевизначити стиль {kind}
Redo Повторити
Begin Undo Group Почати групу скасування
End Undo Group Завершити групу скасування
Redo {name} Повторити {name}
Redo {} Повторити {}
Reduce Colors Скоротити кількість кольорів
Can't render this file because it contains an unexpected character in line 659 and column 40.
+2
View File
@@ -2077,6 +2077,8 @@
Redefine Symbol 重新定义符号
Redefine {kind} Style 重新定义{kind}样式
Redo 重做
Begin Undo Group 开始还原组
End Undo Group 结束还原组
Redo {name} 重做 {name}
Redo {} 重做 {}
Reduce Colors 减少颜色
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2130,6 +2130,8 @@
Redefine Symbol 重新定義符號
Redefine {kind} Style 重新定義{kind}樣式
Redo 重做
Begin Undo Group 開始還原群組
End Undo Group 結束還原群組
Redo {name} 重做 {name}
Redo {} 重做 {}
Reduce Colors 縮減顏色
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -235,7 +235,7 @@ The older names below remain listed because existing workflows use them.
|---|---|
| `command_list` | Optional `filter`, `enabled_only`; returns the command array |
| `command_run` | `id`, optional `params`; runs the command through the existing backend |
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true); returns `completed`, `failed`, `results: [{ok, result\|error}]`. Each edit has 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) |
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]`. 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) |
| `doc_inspect` | Optional `depth`, `childLimit`; 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. 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 |