diff --git a/crates/engine/src/batch_rollback_tests.rs b/crates/engine/src/batch_rollback_tests.rs index 298ca097..9d38ce11 100644 --- a/crates/engine/src/batch_rollback_tests.rs +++ b/crates/engine/src/batch_rollback_tests.rs @@ -59,6 +59,90 @@ fn failed_group_reopens_documents_closed_inside_it() { assert!(s.closed_in_group.is_empty()); } +#[test] +fn failed_group_restores_each_tabs_history_after_undo_close_and_open() { + let mut s = Session::new(); + let mut originals = Vec::new(); + for (index, name) in ["First", "Second"].into_iter().enumerate() { + if index > 0 { + s.execute("file.new", &json!({"name": name})).unwrap(); + } + let created = s + .execute( + "command.batch", + &json!({ + "label": format!("{name} shape"), + "commands": [{"command": "shape.rectangle", "params": { + "x": index * 20, "y": 0, "width": 10, "height": 10, "props": {"name": name} + }}] + }), + ) + .unwrap(); + let original = id(&created[0]); + s.execute( + "command.batch", + &json!({ + "label": format!("{name} name"), + "commands": [{"command": "node.set", "params": {"id": original, "props": {"name": format!("{name} renamed")}}}] + }), + ) + .unwrap(); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true})); + assert_eq!(s.undo_depth(), (1, 1)); + originals.push(original); + } + let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect(); + let before_docs: Vec<_> = s.docs.iter().map(|ds| ds.doc.clone()).collect(); + let before_labels: Vec<_> = s + .docs + .iter() + .map(|ds| { + (ds.history.undo.iter().map(|h| h.label.clone()).collect::>(), ds.history.redo.iter().map(|h| h.label.clone()).collect::>()) + }) + .collect(); + assert_ne!(before_labels[0], before_labels[1]); + let active = s.active; + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "edit.undo"}, + {"command": "doc.nextTab"}, + {"command": "edit.undo"}, + {"command": "file.close", "params": {"index": 0, "force": true}}, + {"command": "file.new", "params": {"name": "Temporary"}}, + {"command": "no.such.command"} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::>(), uids); + assert_eq!(s.active, active); + assert!(s.closed_in_group.is_empty()); + for (index, ds) in s.docs.iter().enumerate() { + assert_eq!(ds.doc, before_docs[index]); + assert_eq!(ds.history.undo.iter().map(|h| h.label.clone()).collect::>(), before_labels[index].0); + assert_eq!(ds.history.redo.iter().map(|h| h.label.clone()).collect::>(), before_labels[index].1); + } + // Travel through each restored tab's real history, including the pre-existing redo step. + for _ in 0..2 { + let index = s.active; + let original = originals[index]; + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true})); + assert!(!s.doc().unwrap().contains(original)); + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true})); + assert_eq!(s.doc().unwrap(), before_docs[index].as_ref()); + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true})); + let name = &before_docs[index].node(original).unwrap().name; + assert_eq!(s.doc().unwrap().node(original).unwrap().name, format!("{name} renamed")); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true})); + assert_eq!(s.doc().unwrap(), before_docs[index].as_ref()); + s.execute("doc.nextTab", &json!({})).unwrap(); + } +} + #[test] fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() { let mut s = Session::new(); @@ -87,6 +171,121 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() assert_eq!(s.message, before_message); } +#[test] +fn failed_group_keeps_the_undo_steps_it_undid() { + let mut s = Session::new(); + let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let before_doc = s.doc().unwrap().clone(); + assert_eq!(s.undo_depth(), (1, 0)); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "edit.undo"}, + {"command": "no.such.command"} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.doc().unwrap(), &before_doc); + assert_eq!(s.undo_depth(), (1, 0), "the undo step survives the failed batch"); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true})); + assert!(!s.doc().unwrap().contains(original)); + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true})); + assert!(s.doc().unwrap().contains(original)); +} + +#[test] +fn failed_group_keeps_history_after_undo_and_redo() { + let mut s = Session::new(); + let first = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let second = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()); + assert!(s.undo().unwrap()); + let before_doc = s.doc().unwrap().clone(); + let before_labels = s.undo_labels(10); + assert_eq!(s.undo_depth(), (1, 1)); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "edit.undo"}, + {"command": "edit.redo"}, + {"command": "no.such.command"} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.doc().unwrap(), &before_doc); + assert_eq!(s.undo_depth(), (1, 1)); + assert_eq!(s.undo_labels(10), before_labels); + assert!(s.undo().unwrap()); + assert!(!s.doc().unwrap().contains(first)); + assert!(s.redo().unwrap()); + assert!(s.doc().unwrap().contains(first)); + assert!(!s.doc().unwrap().contains(second)); + assert!(s.redo().unwrap()); + assert!(s.doc().unwrap().contains(second)); +} + +#[test] +fn failed_outer_group_keeps_the_undo_steps_a_nested_batch_undid() { + let mut s = Session::new(); + let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let before_doc = s.doc().unwrap().clone(); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "command.batch", "params": {"commands": [{"command": "edit.undo"}]}}, + {"command": "no.such.command"} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.doc().unwrap(), &before_doc); + assert_eq!(s.undo_depth(), (1, 0)); + assert!(s.undo().unwrap()); + assert!(!s.doc().unwrap().contains(original)); + assert!(s.redo().unwrap()); + assert!(s.doc().unwrap().contains(original)); +} + +#[test] +fn failed_group_restores_the_small_history_limit() { + let mut s = Session::new(); + s.ds_mut().unwrap().history.limit = 1; + let first = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let before_doc = s.doc().unwrap().clone(); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "edit.undo"}, + {"command": "shape.rectangle", "params": {"x": 20, "y": 0, "width": 10, "height": 10}}, + {"command": "no.such.command"} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.doc().unwrap(), &before_doc); + assert_eq!(s.undo_depth(), (1, 0)); + assert_eq!(s.ds().unwrap().history.limit, 1); + let second = id(&s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap()); + assert_eq!(s.undo_depth(), (1, 0), "new edits still respect the limit"); + assert!(s.undo().unwrap()); + assert!(s.doc().unwrap().contains(first)); + assert!(!s.doc().unwrap().contains(second)); + assert!(!s.undo().unwrap(), "the oldest step was dropped"); +} + #[test] fn failed_group_keeps_the_redo_steps() { let mut s = Session::new(); diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index a354ea27..abbce5bb 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -204,6 +204,8 @@ impl Session { self.ds()?; let before: Vec<_> = self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect(); + // An undo inside pops the undo steps; a failed group has to bring them back. + let undo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.undo.clone()).collect(); // An edit inside clears the redo steps; a failed group has to bring them back. let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect(); let active = self.active; @@ -233,13 +235,13 @@ impl Session { }; self.docs.extend(tab); } - for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) { + for (((uid, doc, selection, page, _, limit), undo), redo) in before.iter().zip(undo_before).zip(redo_before) { if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) { ds.history.redo = redo; ds.doc = doc.clone(); ds.selection = selection.clone(); ds.page = *page; - ds.history.undo.truncate(*undo_len); + ds.history.undo = undo; ds.history.limit = *limit; } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 14d734ba..ed2ad933 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (a failed batch keeps the undo steps it undid (#1410); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Undo kept after a failed batch (M8, #1410): a batch that undid an earlier edit and then failed restored the document but lost that edit's undo step; a failed group now restores the undo entries as well as the redo entries, so the earlier edit can still be undone and redone. Regression tests in the batch rollback suite. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |