fix(engine): programmatic undo/redo ends a running command first (#178)

* Programmatic undo/redo ends a running command first (#177)

`undo` or `redo` run through engine.execute, MCP, the control channel or a
script while an interactive command was still running (e.g. TEXT waiting for
another line) swapped the drawing under that command. Interactive commands
push their undo step only when they end, so `undo` popped the previous
command's step instead: it removed the earlier geometry, dropped the running
command's entities, and left the command running from a stale state.

Both commands now first end the running command the way Esc does, exactly as
Session::start already does for Cmd+Z, the menu and the command line. The
command's work so far becomes one undo step, and `undo` then removes that step.

Regression test: programmatic_undo_during_a_command_ends_it_first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move test away from other PRs' hunks to avoid merge conflicts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 18:14:53 +02:00 committed by GitHub
parent bacb951059
commit 7497618f76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 33 additions and 0 deletions

View File

@ -33,7 +33,17 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// A programmatic undo/redo (`engine.execute`, MCP, control channel, scripts) first ends a running
/// command the way Esc does, exactly as `Session::start` does for Cmd+Z, the menu or the command
/// line. Otherwise it would swap the drawing under the command and pop the previous command's step.
fn end_running_command(s: &mut Session) {
if s.running.is_some() {
s.cancel();
}
}
fn run_undo(s: &mut Session, p: &Value) -> Result<Value> {
end_running_command(s);
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000);
let mut labels = Vec::new();
for _ in 0..n {
@ -49,6 +59,7 @@ fn run_undo(s: &mut Session, p: &Value) -> Result<Value> {
}
fn run_redo(s: &mut Session, p: &Value) -> Result<Value> {
end_running_command(s);
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000);
let mut labels = Vec::new();
for _ in 0..n {

View File

@ -34,6 +34,28 @@ fn line_undo_option_and_aliases() {
assert!(s.running.is_none());
}
#[test]
fn programmatic_undo_during_a_command_ends_it_first() {
let mut s = Session::new();
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
// TEXT stays active, waiting for the next line.
s.script("TEXT 0,0 5 0 HELLO\n").unwrap();
assert!(s.running.is_some());
assert_eq!(kinds(&s), vec!["Line", "Text"]);
// Like Ctrl+Z during a command: end it (as Esc does), then undo it, not the line.
s.execute("undo", &json!({})).unwrap();
assert!(s.running.is_none());
assert_eq!(kinds(&s), vec!["Line"]);
s.execute("redo", &json!({})).unwrap();
assert_eq!(kinds(&s), vec!["Line", "Text"]);
// Redo while a command runs ends it the same way and leaves the drawing alone.
s.script("TEXT 0,-10 5 0\n").unwrap();
assert!(s.running.is_some());
s.execute("redo", &json!({})).unwrap();
assert!(s.running.is_none());
assert_eq!(kinds(&s), vec!["Line", "Text"]);
}
#[test]
fn polar_and_direct_distance() {
let mut s = Session::new();