mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
Merge pull request #1179 from storytold/fix/1163-atomic-batch
#1163: command_batch can be one undo step
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user