From 3ffc3c80e7e22bb8bfee3f111bb75f278485b332 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 18 Jul 2026 23:26:54 +0200 Subject: [PATCH] feat(studio): undo/redo with dirty indicator - History: bounded (64) undo/redo stacks of whole-file snapshots (uniform for JSON and HTML sources), captured on effective disk writes only (post-debounce; no-op writes excluded); new edits clear the redo stack - App-global slot survives watcher reloads (the model is replaced, the slot is not); lazy reset on file switch; reloads never push - Undo/redo rewrite the source file and let the watcher reload; a failed write rolls the stacks back exactly and surfaces write_error - Cmd/Ctrl+Z and Shift+Cmd/Ctrl+Z at the StudioApp root; the inspector subtree stops propagation so text fields keep their native undo; topbar gains Undo/Redo buttons and a Saving indicator --- .../src/editor/annotations.rs | 31 +- .../rustmotion-studio/src/editor/inspector.rs | 44 ++- crates/rustmotion-studio/src/editor/topbar.rs | 82 ++++- crates/rustmotion-studio/src/editor/view.rs | 46 ++- .../rustmotion-studio/src/scenario/history.rs | 328 ++++++++++++++++++ crates/rustmotion-studio/src/scenario/mod.rs | 2 + 6 files changed, 516 insertions(+), 17 deletions(-) create mode 100644 crates/rustmotion-studio/src/scenario/history.rs diff --git a/crates/rustmotion-studio/src/editor/annotations.rs b/crates/rustmotion-studio/src/editor/annotations.rs index dca6124..129f7f8 100644 --- a/crates/rustmotion-studio/src/editor/annotations.rs +++ b/crates/rustmotion-studio/src/editor/annotations.rs @@ -119,7 +119,20 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal) -> Ele let updated = append_annotation(raw, ann); match serde_json::to_string_pretty(&updated) { Ok(t) => { + // Pre-write state for the undo history (JSON annotation + // writes rewrite the scenario file, so they must be in + // the same linear history as inspector edits). + let snapshot = std::fs::read_to_string(&path).ok(); let write_result = write_file(&path, &t); + if write_result.is_ok() { + if let Some(s) = snapshot { + crate::scenario::record_edit( + &crate::scenario::history_slot(), + &path, + s, + ); + } + } let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); match write_result { Ok(()) => { @@ -190,13 +203,23 @@ fn delete_annotation(shared: &Shared, id: &str) { } } else { let updated = remove_annotation(raw, id); + // Pre-write state for the undo history (same linear history as + // inspector edits — this rewrites the scenario file). + let snapshot = std::fs::read_to_string(&path).ok(); let write_result = serde_json::to_string_pretty(&updated) .map_err(|e| format!("json: {e}")) .and_then(|t| write_file(&path, &t)); - if let Err(e) = write_result { - let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner()); - m.write_error = Some(e); - m.generation = m.generation.wrapping_add(1); + match write_result { + Ok(()) => { + if let Some(s) = snapshot { + crate::scenario::record_edit(&crate::scenario::history_slot(), &path, s); + } + } + Err(e) => { + let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner()); + m.write_error = Some(e); + m.generation = m.generation.wrapping_add(1); + } } } } diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index bde9e65..0f3cfc3 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -981,12 +981,22 @@ enum WritePayload { }, } +impl WritePayload { + fn path(&self) -> &std::path::Path { + match self { + WritePayload::Prop { path, .. } | WritePayload::Content { path, .. } => path, + } + } +} + /// Schedule a debounced disk write (~250 ms). Any previously scheduled write is /// cancelled first so only the last value in a burst reaches the disk. /// -/// On success the model's `write_error` is cleared; on failure it is set to the -/// OS error message and `generation` is bumped so the hot-reload loop picks it -/// up and shows the topbar indicator. +/// On success the model's `write_error` is cleared and the pre-write file state +/// is pushed onto the undo history; on failure `write_error` is set to the OS +/// error message and `generation` is bumped so the hot-reload loop picks it up +/// and shows the topbar indicator. The pending window is surfaced as the +/// "Saving…" indicator via the history slot. fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayload) { // Cancel the previous pending write (if any). `Task::cancel` is safe to // call on an already-completed task (it's a no-op). @@ -996,6 +1006,7 @@ fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayloa prev.cancel(); } } + crate::scenario::set_saving(&crate::scenario::history_slot(), true); let debounce_slot = debounce.0.clone(); let task = spawn(async move { @@ -1004,10 +1015,21 @@ fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayloa // Clear the slot so the next write doesn't try to cancel this one. *debounce_slot.borrow_mut() = None; + // Capture the file state BEFORE the write: one history entry per + // effective disk write. + let snapshot = std::fs::read_to_string(payload.path()).ok(); let result = perform_write(&payload); + crate::scenario::set_saving(&crate::scenario::history_slot(), false); + if let (Ok(true), Some(snapshot)) = (&result, snapshot) { + crate::scenario::record_edit( + &crate::scenario::history_slot(), + payload.path(), + snapshot, + ); + } let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); match result { - Ok(()) => { + Ok(_) => { // Clear any previous write error on success. m.write_error = None; } @@ -1022,8 +1044,10 @@ fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayloa *debounce.0.borrow_mut() = Some(task); } -/// Execute the actual file write and return `Ok(())` or an error message. -fn perform_write(payload: &WritePayload) -> Result<(), String> { +/// Execute the actual file write. Returns `Ok(true)` when the file was +/// written, `Ok(false)` when the edit was a no-op (nothing to record in the +/// undo history), or an error message. +fn perform_write(payload: &WritePayload) -> Result { match payload { WritePayload::Prop { path, @@ -1038,13 +1062,15 @@ fn perform_write(payload: &WritePayload) -> Result<(), String> { rustmotion::loader::set_html_inline_style(&html, pointer, prop, value) { std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?; + return Ok(true); } } else if let Some(updated) = set_style(raw.clone(), pointer, prop, value) { let text = serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?; std::fs::write(path, text).map_err(|e| format!("write: {e}"))?; + return Ok(true); } - Ok(()) + Ok(false) } WritePayload::Content { path, @@ -1058,12 +1084,14 @@ fn perform_write(payload: &WritePayload) -> Result<(), String> { rustmotion::loader::set_html_text_content(&html, pointer, text) { std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?; + return Ok(true); } } else if let Some(updated) = set_field(raw.clone(), pointer, "content", text) { let s = serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?; std::fs::write(path, s).map_err(|e| format!("write: {e}"))?; + return Ok(true); } - Ok(()) + Ok(false) } } } diff --git a/crates/rustmotion-studio/src/editor/topbar.rs b/crates/rustmotion-studio/src/editor/topbar.rs index 17a829c..c9b95b7 100644 --- a/crates/rustmotion-studio/src/editor/topbar.rs +++ b/crates/rustmotion-studio/src/editor/topbar.rs @@ -1,11 +1,54 @@ +use std::time::Duration; + use dioxus::prelude::*; -use dioxus_icons::lucide::{ChevronLeft, Download, Eye, MessageSquare, Monitor, Moon, Play, Sun}; +use dioxus_icons::lucide::{ + ChevronLeft, Download, Eye, MessageSquare, Monitor, Moon, Play, Redo2, Sun, Undo2, +}; use crate::components::button::{Button, ButtonSize, ButtonVariant}; -use crate::scenario::{Shared, Theme, View}; +use crate::scenario::{history_slot, redo, undo, Shared, SharedHistory, Theme, View}; use super::export::{export_label, export_slot, start_export, use_export_poll, ExportStatus}; +/// Snapshot of the history slot for the topbar UI (polled, set-on-change). +#[derive(Clone, PartialEq, Default)] +struct HistoryUi { + can_undo: bool, + can_redo: bool, + saving: bool, +} + +/// Poll the history slot into a signal (~150 ms), same pattern as the export +/// poll. Undo/redo availability only counts when the slot belongs to the +/// currently open file. +fn use_history_poll(shared: Shared, slot: SharedHistory, mut sig: Signal) { + use_future(move || { + let shared = shared.clone(); + let slot = slot.clone(); + async move { + loop { + tokio::time::sleep(Duration::from_millis(150)).await; + let path = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + m.path.clone() + }; + let ui = { + let st = slot.lock().unwrap_or_else(|e| e.into_inner()); + let matches = path.is_some() && st.path == path; + HistoryUi { + can_undo: matches && st.history.can_undo(), + can_redo: matches && st.history.can_redo(), + saving: st.saving, + } + }; + if ui != sig() { + sig.set(ui); + } + } + } + }); +} + /// The editor's top bar (open-slide style): a back-to-library control on the /// left, the centered document title, and the action cluster on the right /// (theme swap, Inspect overlay toggle, Comments panel toggle, Export, and @@ -38,6 +81,12 @@ pub fn TopBar( let status = export_status(); let exporting = status.is_running(); + // Undo/redo + pending-write ("Saving…") state, polled from the history slot. + let history = use_hook(history_slot); + let history_ui = use_signal(HistoryUi::default); + use_history_poll(shared.clone(), history.clone(), history_ui); + let hist = history_ui(); + rsx! { div { style: "position:relative; display:flex; align-items:center; justify-content:space-between; height:40px; padding:0 12px; border-bottom:1px solid var(--rm-border); background:var(--rm-surface-2); flex:none;", div { style: "display:flex; align-items:center; gap:8px; z-index:1;", @@ -57,6 +106,11 @@ pub fn TopBar( // ── Right: indicators + actions ────────────────────────── div { style: "display:flex; align-items:center; gap:6px; z-index:1;", + if hist.saving { + span { style: "color:var(--rm-text-muted); font-size:11px; white-space:nowrap;", + "Saving…" + } + } if let Some(ref msg) = write_error { span { title: "{msg}", @@ -81,6 +135,30 @@ pub fn TopBar( }, _ => rsx! {}, } + Button { + variant: ButtonVariant::Ghost, + size: ButtonSize::IconSm, + title: "Undo (Cmd+Z)", + disabled: !hist.can_undo, + onclick: { + let shared = shared.clone(); + let history = history.clone(); + move |_| undo(&shared, &history) + }, + Undo2 { size: 15, stroke: if hist.can_undo { "var(--rm-text)" } else { "var(--rm-text-muted)" } } + } + Button { + variant: ButtonVariant::Ghost, + size: ButtonSize::IconSm, + title: "Redo (Shift+Cmd+Z)", + disabled: !hist.can_redo, + onclick: { + let shared = shared.clone(); + let history = history.clone(); + move |_| redo(&shared, &history) + }, + Redo2 { size: 15, stroke: if hist.can_redo { "var(--rm-text)" } else { "var(--rm-text-muted)" } } + } Button { variant: ButtonVariant::Outline, size: ButtonSize::IconSm, diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index 92b7470..7143fc6 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -3,7 +3,9 @@ use dioxus::desktop::{ }; use dioxus::prelude::*; -use crate::scenario::{list_annotations, read_field, read_style_object, Shared, View}; +use crate::scenario::{ + history_slot, list_annotations, read_field, read_style_object, redo, undo, Shared, View, +}; use super::annotations::AnnotationsPanel; use super::frames::{frame_hits, render_frame, scene_prefix, HitPct}; @@ -142,8 +144,42 @@ pub fn StudioApp(view: Signal) -> Element { let panel = inspector(); let panel_w = if panel.is_some() { "300px" } else { "0px" }; + // Undo/redo keyboard shortcuts: the root div is focusable (and focused on + // mount) so Cmd+Z / Shift+Cmd+Z (Ctrl on non-mac) reach it — key events in + // the subtree bubble up to it. The inspector slot stops keydown propagation + // so text fields keep their native editing undo. + let history = use_hook(history_slot); + let on_shortcut = { + let shared = shared.clone(); + let history = history.clone(); + move |evt: KeyboardEvent| { + let mods = evt.modifiers(); + if !(mods.meta() || mods.ctrl()) { + return; + } + let is_z = matches!(evt.key(), Key::Character(ref c) if c.eq_ignore_ascii_case("z")); + if !is_z { + return; + } + evt.prevent_default(); + if mods.shift() { + redo(&shared, &history); + } else { + undo(&shared, &history); + } + } + }; + rsx! { - div { style: "margin:0; background:var(--rm-bg); height:100vh; overflow:hidden; color:var(--rm-text); font:13px -apple-system,sans-serif; display:flex; flex-direction:column;", + div { + style: "margin:0; background:var(--rm-bg); height:100vh; overflow:hidden; color:var(--rm-text); font:13px -apple-system,sans-serif; display:flex; flex-direction:column; outline:none;", + tabindex: "0", + onmounted: move |evt| { + spawn(async move { + let _ = evt.set_focus(true).await; + }); + }, + onkeydown: on_shortcut, TopBar { view, title, @@ -159,7 +195,11 @@ pub fn StudioApp(view: Signal) -> Element { Canvas { current, rev, show_hits, selected } PlaybackBar { current, playing, total } } - div { style: "flex:none; display:flex; overflow:hidden; transition:width 220ms ease; width:{panel_w};", + div { + style: "flex:none; display:flex; overflow:hidden; transition:width 220ms ease; width:{panel_w};", + // Keep Cmd+Z inside inspector inputs/textareas native + // (text-field undo), not intercepted by the editor. + onkeydown: move |evt: KeyboardEvent| evt.stop_propagation(), if let Some((pointer, style, kind, content)) = panel { InspectorPanel { selected, pointer, kind, current, content, style } } diff --git a/crates/rustmotion-studio/src/scenario/history.rs b/crates/rustmotion-studio/src/scenario/history.rs new file mode 100644 index 0000000..73c5af4 --- /dev/null +++ b/crates/rustmotion-studio/src/scenario/history.rs @@ -0,0 +1,328 @@ +//! Undo/redo history for studio edits. +//! +//! One history entry = one effective disk write (post-debounce), capturing the +//! full file text as it was BEFORE the write — uniform for JSON scenarios and +//! HTML sources. Undo/redo rewrite the source file; the watcher reload then +//! refreshes the model (reloads never push history, so an undo-induced reload +//! is not captured as a new edit). +//! +//! The stacks live in an app-global slot ([`history_slot`]) separate from +//! `StudioModel`, so they survive the model replacement a watcher reload +//! performs. Switching to a different file resets them lazily (the slot tracks +//! which path it belongs to). + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use super::Shared; + +/// Maximum retained undo entries; the oldest is evicted beyond this. +pub const HISTORY_CAP: usize = 64; + +/// Pure bounded undo/redo stacks over full-file snapshots. +#[derive(Default)] +pub struct History { + undo: Vec, + redo: Vec, +} + +impl History { + /// Record the pre-write state of the file as a new undo entry. Clears the + /// redo stack (a new edit after an undo invalidates the redone future) and + /// evicts the oldest entry beyond [`HISTORY_CAP`]. + pub fn record(&mut self, snapshot: String) { + self.undo.push(snapshot); + if self.undo.len() > HISTORY_CAP { + self.undo.remove(0); + } + self.redo.clear(); + } + + /// Pop the previous state, pushing `current` onto the redo stack. Returns + /// `None` (and leaves both stacks untouched) when there is nothing to undo. + pub fn undo(&mut self, current: String) -> Option { + let prev = self.undo.pop()?; + self.redo.push(current); + Some(prev) + } + + /// Pop the next state, pushing `current` onto the undo stack. Returns + /// `None` (and leaves both stacks untouched) when there is nothing to redo. + pub fn redo(&mut self, current: String) -> Option { + let next = self.redo.pop()?; + self.undo.push(current); + Some(next) + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } +} + +/// The slot state: which file the stacks belong to, the stacks, and whether a +/// debounced write is pending ("Saving…" indicator). +#[derive(Default)] +pub struct HistoryState { + pub path: Option, + pub history: History, + pub saving: bool, +} + +impl HistoryState { + /// Reset the stacks when the edited file changes (lazy file-switch reset). + pub fn ensure_path(&mut self, path: &Path) { + if self.path.as_deref() != Some(path) { + self.path = Some(path.to_path_buf()); + self.history = History::default(); + } + } +} + +pub type SharedHistory = Arc>; + +/// The app-global history slot. Global (not in `StudioModel`) so the stacks +/// survive watcher reloads, which replace the model wholesale. +pub fn history_slot() -> SharedHistory { + static SLOT: OnceLock = OnceLock::new(); + SLOT.get_or_init(|| Arc::new(Mutex::new(HistoryState::default()))) + .clone() +} + +/// Record a pre-write snapshot for `path` (called by the write paths after a +/// successful, effective disk write). +pub fn record_edit(slot: &SharedHistory, path: &Path, snapshot: String) { + let mut st = slot.lock().unwrap_or_else(|e| e.into_inner()); + st.ensure_path(path); + st.history.record(snapshot); +} + +/// Set the pending-write ("Saving…") indicator. +pub fn set_saving(slot: &SharedHistory, saving: bool) { + let mut st = slot.lock().unwrap_or_else(|e| e.into_inner()); + st.saving = saving; +} + +/// Undo the last edit: write the previous file state back to disk. The watcher +/// reload does the rest. Write failures are surfaced via the model's +/// `write_error` (same guarantees as every studio write) and roll the stacks +/// back so the step isn't lost. +pub fn undo(shared: &Shared, slot: &SharedHistory) { + step(shared, slot, true) +} + +/// Redo the last undone edit (see [`undo`]). +pub fn redo(shared: &Shared, slot: &SharedHistory) { + step(shared, slot, false) +} + +fn step(shared: &Shared, slot: &SharedHistory, is_undo: bool) { + // Lock discipline: the model lock and the slot lock are never held + // together (model → path, then slot → stacks + disk, then model → report). + let path = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + m.path.clone() + }; + let Some(path) = path else { + return; + }; + + // Ok(true) = a state was written; Ok(false) = nothing to undo/redo. + let outcome: Result = { + let mut st = slot.lock().unwrap_or_else(|e| e.into_inner()); + st.ensure_path(&path); + (|| { + let current = std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?; + let target = if is_undo { + st.history.undo(current) + } else { + st.history.redo(current) + }; + let Some(target) = target else { + return Ok(false); + }; + match std::fs::write(&path, &target) { + Ok(()) => Ok(true), + Err(e) => { + // Roll the stacks back so the failed step isn't lost: the + // inverse operation with `target` restores both stacks + // exactly (the popped entry returns, `current` comes back + // off the other stack and is discarded). + if is_undo { + let _ = st.history.redo(target); + } else { + let _ = st.history.undo(target); + } + Err(format!("write: {e}")) + } + } + })() + }; + + match outcome { + Ok(true) => { + // The watcher picks up the write and reloads the model. + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + m.write_error = None; + } + Ok(false) => {} + Err(e) => { + let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner()); + m.write_error = Some(e); + m.generation = m.generation.wrapping_add(1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenario::{empty_scenario, StudioModel}; + + // ── Pure stack behavior ───────────────────────────────────────────── + + #[test] + fn record_caps_at_64_evicting_oldest() { + let mut h = History::default(); + for i in 0..(HISTORY_CAP + 5) { + h.record(format!("s{i}")); + } + // Cap holds; undoing all the way lands on the oldest KEPT entry (s5). + let mut last = None; + let mut current = "top".to_string(); + while let Some(s) = h.undo(current.clone()) { + current = s.clone(); + last = Some(s); + } + assert_eq!(last.as_deref(), Some("s5")); + } + + #[test] + fn record_clears_redo() { + let mut h = History::default(); + h.record("s0".into()); + assert!(h.undo("s1".into()).is_some()); + assert!(h.can_redo()); + h.record("s2".into()); + assert!(!h.can_redo(), "a new edit after undo clears redo"); + } + + #[test] + fn undo_empty_is_noop_and_does_not_touch_redo() { + let mut h = History::default(); + assert_eq!(h.undo("current".into()), None); + assert!(!h.can_redo(), "failed undo must not push to redo"); + assert_eq!(h.redo("current".into()), None); + assert!(!h.can_undo(), "failed redo must not push to undo"); + } + + #[test] + fn undo_redo_roundtrip_restores() { + let mut h = History::default(); + h.record("v1".into()); + let prev = h.undo("v2".into()).unwrap(); + assert_eq!(prev, "v1"); + assert!(h.can_redo()); + let next = h.redo(prev).unwrap(); + assert_eq!(next, "v2"); + assert!(h.can_undo()); + assert!(!h.can_redo()); + } + + #[test] + fn ensure_path_resets_on_file_switch() { + let mut st = HistoryState::default(); + st.ensure_path(Path::new("/a.json")); + st.history.record("s".into()); + assert!(st.history.can_undo()); + // Same path: stacks kept. + st.ensure_path(Path::new("/a.json")); + assert!(st.history.can_undo()); + // Different path: stacks reset. + st.ensure_path(Path::new("/b.json")); + assert!(!st.history.can_undo()); + assert!(!st.history.can_redo()); + } + + // ── Model-level integration (no UI) ───────────────────────────────── + + fn temp_file(tag: &str, content: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("rm_history_{tag}_{}.json", std::process::id())); + std::fs::write(&p, content).unwrap(); + p + } + + fn model_for(path: &Path) -> Shared { + Arc::new(Mutex::new(StudioModel::new( + empty_scenario(), + None, + Some(path.to_path_buf()), + ))) + } + + fn local_slot() -> SharedHistory { + Arc::new(Mutex::new(HistoryState::default())) + } + + #[test] + fn undo_rewrites_file_and_redo_restores() { + let path = temp_file("roundtrip", "STATE_B"); + let shared = model_for(&path); + let slot = local_slot(); + + // The edit that produced STATE_B recorded the pre-write state. + record_edit(&slot, &path, "STATE_A".into()); + + undo(&shared, &slot); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "STATE_A"); + assert!(slot.lock().unwrap().history.can_redo()); + + redo(&shared, &slot); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "STATE_B"); + assert!(slot.lock().unwrap().history.can_undo()); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn undo_with_empty_history_is_noop() { + let path = temp_file("noop", "UNTOUCHED"); + let shared = model_for(&path); + let slot = local_slot(); + + undo(&shared, &slot); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "UNTOUCHED"); + let m = shared.lock().unwrap(); + assert!(m.write_error.is_none()); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn record_edit_resets_on_path_change() { + let a = temp_file("switch_a", "A"); + let b = temp_file("switch_b", "B"); + let slot = local_slot(); + + record_edit(&slot, &a, "old_a".into()); + assert!(slot.lock().unwrap().history.can_undo()); + + // Recording for another file drops the previous file's stacks. + record_edit(&slot, &b, "old_b".into()); + let st = slot.lock().unwrap(); + assert_eq!(st.path.as_deref(), Some(b.as_path())); + // Only b's single entry remains: one undo possible, then empty. + drop(st); + let shared = model_for(&b); + undo(&shared, &slot); + assert_eq!(std::fs::read_to_string(&b).unwrap(), "old_b"); + undo(&shared, &slot); // empty now → no-op + assert_eq!(std::fs::read_to_string(&b).unwrap(), "old_b"); + + let _ = std::fs::remove_file(&a); + let _ = std::fs::remove_file(&b); + } +} diff --git a/crates/rustmotion-studio/src/scenario/mod.rs b/crates/rustmotion-studio/src/scenario/mod.rs index 6c543f1..0fd5c25 100644 --- a/crates/rustmotion-studio/src/scenario/mod.rs +++ b/crates/rustmotion-studio/src/scenario/mod.rs @@ -2,6 +2,7 @@ //! the top-level view enum shared across features. mod edit; +mod history; mod model; mod sidecar; @@ -9,6 +10,7 @@ pub use edit::{ append_annotation, list_annotations, read_field, read_style_object, remove_annotation, set_field, set_style, }; +pub use history::{history_slot, record_edit, redo, set_saving, undo, SharedHistory}; pub use model::{empty_scenario, Shared, StudioModel}; pub use sidecar::{append_sidecar_annotation, remove_sidecar_annotation};