undo: snapshot history engine (state::undo)

- History: capped undo/redo stacks of whole Dashboard clones; a pending
  pre-burst snapshot becomes a real undo step when the tracking effect's
  settle timer fires (1 s of quiet) — one undo step per edit burst
- undo/redo consume the pending burst first, so a fresh edit is
  undoable before it settles; a new edit clears the redo branch
- suppress flag: wholesale replacements (Clear, import) flush the
  pending burst, push the current state, and mark their own write as
  non-capturable — Clear and import are undoable themselves
- HistoryHandle is Copy (RwSignal fields): context-friendly (Send+Sync)
  and safe to capture in view closures under edition-2024 rules
- no std::time::Instant: it panics on wasm32-unknown-unknown; the
  browser timer is the only clock
- 9 unit tests: burst coalescing, redo, cap, flush order, suppress
This commit is contained in:
yaroslav k 2026-08-16 12:31:17 +03:00
parent 42b106c793
commit 99ccb0cdc7
3 changed files with 504 additions and 9 deletions

View File

@ -3,7 +3,8 @@
//! The whole dashboard lives in one `reactive_stores::Store<Dashboard>` //! The whole dashboard lives in one `reactive_stores::Store<Dashboard>`
//! (alias `Context`), created in `App` and shared through Leptos context. //! (alias `Context`), created in `App` and shared through Leptos context.
//! Persistence (localStorage autosave, JSON export/import) lives in //! Persistence (localStorage autosave, JSON export/import) lives in
//! [`persist`]. //! [`persist`]; undo/redo over the same store lives in [`undo`].
pub (crate) mod persist; pub (crate) mod persist;
pub (crate) mod undo;

View File

@ -77,34 +77,40 @@ pub fn replace_state(store: &Context, dashboard: Dashboard) {
/// Parses `text` and applies it: replaces the state and saves it back. /// Parses `text` and applies it: replaces the state and saves it back.
/// ///
/// Returns the error for the UI to show; nothing is applied on error. /// The current state becomes an undo step first, so the import is
/// undoable. Returns the error for the UI to show; nothing is applied
/// on error.
pub fn apply_import(store: &Context, text: &str) -> std::result::Result<(), ImportError> { pub fn apply_import(store: &Context, text: &str) -> std::result::Result<(), ImportError> {
let dashboard = parse_dashboard(text)?; let dashboard = parse_dashboard(text)?;
crate::state::undo::before_replace();
replace_state(store, dashboard); replace_state(store, dashboard);
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
save_to_storage(store); save_to_storage(store);
Ok(()) Ok(())
} }
/// Client-only startup: load the saved state after mount, start autosave. /// Client-only startup: load the saved state after mount, start autosave
/// and undo tracking.
/// ///
/// Called from `App`. The load is deferred out of the hydration render so /// Called from `App`. The load is deferred out of the hydration render so
/// the SSR markup stays authoritative; the swap happens before the browser /// the SSR markup stays authoritative; the swap happens before the browser
/// draws the first frame. /// draws the first frame. Undo starts after the load (see the callback).
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
pub fn init_hydrated(store: &Context) { pub fn init_hydrated(store: &Context, history: &crate::state::undo::HistoryHandle) {
let store = store.clone(); let store = store.clone();
let pending: Rc<Cell<Option<TimeoutHandle>>> = Rc::new(Cell::new(None)); let pending: Rc<Cell<Option<TimeoutHandle>>> = Rc::new(Cell::new(None));
// Load the saved state (or keep mock) once hydration has settled. // Load the saved state (or keep mock) once hydration has settled,
let store_load = store.clone(); // then start undo tracking: its first captured state must be the
// saved one, not the mock the SSR pass rendered.
let history = history.clone();
let _ = set_timeout_with_handle( let _ = set_timeout_with_handle(
move || { move || {
load_from_storage(&store_load); load_from_storage(&store);
crate::state::undo::start_tracking(&history);
}, },
Duration::ZERO, Duration::ZERO,
); );
// Debounced autosave: any store change restarts the timer. // Debounced autosave: any store change restarts the timer.
let store_save = store.clone(); let store_save = store.clone();
Effect::new(move |_| { Effect::new(move |_| {
@ -166,10 +172,12 @@ pub fn load_from_storage(store: &Context) -> bool {
/// Wipes the saved state and replaces the dashboard with the blank slate. /// Wipes the saved state and replaces the dashboard with the blank slate.
/// ///
/// The current state becomes an undo step first, so Clear is undoable.
/// The blank dashboard is written back to storage so it survives a reload; /// The blank dashboard is written back to storage so it survives a reload;
/// mock data stays the fallback for the very first visit only. /// mock data stays the fallback for the very first visit only.
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
pub fn clear_all (store: &Context) { pub fn clear_all (store: &Context) {
crate::state::undo::before_replace();
replace_state(store, Dashboard::blank()); replace_state(store, Dashboard::blank());
save_to_storage(store); save_to_storage(store);
} }

486
src/state/undo.rs Normal file
View File

@ -0,0 +1,486 @@
//! Undo/redo for the whole dashboard (ROADMAP Phase 1.5).
//!
//! Snapshot-based: [`History`] keeps whole `Dashboard` snapshots — the
//! state as it was *before* each edit burst. Snapshots are plain clones
//! (the dashboard is small; the 5e.tools item data lives in `statics`,
//! not in the state), and a cap bounds memory.
//!
//! The tracking effect (started in [`start_tracking`], hydrate only)
//! subscribes to the store root trigger — the same one the autosave
//! effect in `persist` uses — and remembers the previous state on every
//! change. When the burst goes quiet for [`SETTLE_WINDOW`], the pre-burst
//! state becomes a real undo step. Undo before the window fires reverts
//! the whole burst straight away.
//!
//! Wholesale replacements (Clear, import — see `persist`) call
//! [`before_replace`] first, so the replacement itself is undoable.
//! Undo/redo restore via `persist::replace_state`, which fires the
//! autosave effect too — an undone state survives a reload.
//!
//! The `ssr` build provides the same [`HistoryHandle`] so the header
//! renders identically, but both stacks stay empty: the buttons render
//! disabled and clicks are no-ops.
use std::time::Duration;
use leptos::prelude::*;
use crate::prelude::*;
use crate::state::persist::replace_state;
/// Maximum number of undo steps kept per direction.
const HISTORY_CAP: usize = 100;
/// A burst of edits becomes one undo step this long after the last change.
///
/// (Only the hydrate tracking effect arms this timer; the `ssr` build
/// keeps the constant for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
const SETTLE_WINDOW: Duration = Duration::from_secs(1);
/// Pure undo/redo stack pair over whole dashboards.
///
/// The undo stack holds states *before* an edit burst; `pending` holds the
/// pre-burst state until the burst settles. The settle window is enforced
/// by the tracking effect's timer — the clock lives in the browser, not
/// here (the wasm target has no `Instant`).
pub struct History {
undo: Vec<Dashboard>,
redo: Vec<Dashboard>,
cap: usize,
/// The pre-burst state, promoted to a real undo step on [`Self::settle`].
pending: Option<Dashboard>,
/// Set right before a wholesale store replacement (undo/redo/Clear/
/// import); the tracking effect consumes it and starts a fresh burst
/// from the new state.
suppress: bool,
/// Reactive stack depths, for the buttons' disabled states.
undo_depth: RwSignal<usize>,
redo_depth: RwSignal<usize>,
}
impl History {
/// Creates an empty history with the given cap.
///
/// The settle window is not stored here: the tracking effect's
/// timer enforces it.
pub fn new(cap: usize) -> Self {
History {
undo: Vec::new(),
redo: Vec::new(),
cap,
pending: None,
suppress: false,
undo_depth: RwSignal::new(0),
redo_depth: RwSignal::new(0),
}
}
/// Records that the store changed from `before` to something else.
///
/// The first change of a burst sets `pending` to `before`; later
/// changes of the same burst keep it, so one undo step covers the
/// whole burst.
///
/// (Only the hydrate tracking effect calls this; the `ssr` build
/// keeps the method for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
pub fn note_change(&mut self, before: Dashboard) {
if self.pending.is_none() {
self.pending = Some(before);
// Undo works before the burst settles (it consumes the
// pending state), so the button must enable right away.
self.undo_depth.set(self.undo.len() + 1);
}
}
/// The burst has been quiet for `window`: promote `pending` to a real
/// undo step. No-op when there is no pending burst.
pub fn settle(&mut self) {
if let Some(pending) = self.pending.take() {
self.push_undo(pending);
}
}
/// Undo one step: reverts the current burst if it has not settled,
/// otherwise pops the most recent settled step. `current` moves to
/// the redo stack.
pub fn undo(&mut self, current: Dashboard) -> Option<Dashboard> {
let snapshot = self.pending.take().or_else(|| self.undo.pop())?;
push_capped(&mut self.redo, self.cap, current);
self.undo_depth.set(self.undo.len());
self.redo_depth.set(self.redo.len());
Some(snapshot)
}
/// Redo: pops the redo stack; `current` moves back to the undo stack.
pub fn redo(&mut self, current: Dashboard) -> Option<Dashboard> {
let snapshot = self.redo.pop()?;
push_capped(&mut self.undo, self.cap, current);
self.undo_depth.set(self.undo.len());
self.redo_depth.set(self.redo.len());
Some(snapshot)
}
/// Flush: promotes the pending burst, then marks `current` as an undo
/// step. Called before wholesale replacements (Clear, import) so the
/// replacement itself is undoable — first the replacement, then the
/// burst it interrupted.
pub fn flush(&mut self, current: Dashboard) {
self.settle();
self.push_undo(current);
}
/// Drops the pending burst. Called after a suppressed replacement:
/// the next edit starts a fresh burst from the new state.
///
/// (Only the hydrate tracking effect calls this; the `ssr` build
/// keeps the method for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
pub fn reset(&mut self) {
if self.pending.take().is_some() {
self.undo_depth.set(self.undo.len());
}
}
/// Flags the next change as a wholesale replacement (no snapshot).
pub fn suppress(&mut self) {
self.suppress = true;
}
/// Reads and clears the replacement flag (the tracking effect's check).
///
/// (Only the hydrate tracking effect calls this; the `ssr` build
/// keeps the method for the shared unit tests.)
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
pub fn consume_suppress(&mut self) -> bool {
std::mem::take(&mut self.suppress)
}
/// Reactive depth of the undo stack.
pub fn undo_depth(&self) -> RwSignal<usize> {
self.undo_depth
}
/// Reactive depth of the redo stack.
pub fn redo_depth(&self) -> RwSignal<usize> {
self.redo_depth
}
/// Pushes a pre-edit snapshot onto the undo stack (capped). A new
/// edit invalidates the redo branch.
fn push_undo(&mut self, snapshot: Dashboard) {
push_capped(&mut self.undo, self.cap, snapshot);
self.undo_depth.set(self.undo.len());
self.redo.clear();
self.redo_depth.set(0);
}
}
/// Pushes `value`, dropping the oldest entry when the stack is at `cap`.
fn push_capped(stack: &mut Vec<Dashboard>, cap: usize, value: Dashboard) {
if stack.len() == cap {
stack.remove(0);
}
stack.push(value);
}
/// The shared undo/redo state, plus the store it operates on.
///
/// All fields are `Copy` handles, so the handle itself is `Copy`: the
/// header buttons and the tracking effect can capture it freely. Created
/// in `App` and injected through context in both build modes (on the
/// server the stacks simply stay empty).
#[derive(Clone, Copy)]
pub struct HistoryHandle {
history: RwSignal<History>,
store: Context,
}
impl HistoryHandle {
/// Creates an empty history around `store`.
pub fn new(store: Context) -> Self {
HistoryHandle {
history: RwSignal::new(History::new(HISTORY_CAP)),
store,
}
}
/// Undoes the last edit burst (or the last settled step).
pub fn undo(&self) {
let Some(current) = current_state(&self.store) else { return };
let mut snapshot = None;
self.history.update(|history| snapshot = history.undo(current));
self.apply(snapshot);
}
/// Re-applies the most recently undone state.
pub fn redo(&self) {
let Some(current) = current_state(&self.store) else { return };
let mut snapshot = None;
self.history.update(|history| snapshot = history.redo(current));
self.apply(snapshot);
}
/// Reactive depth of the undo stack, for the button's disabled state.
pub fn undo_depth(&self) -> RwSignal<usize> {
self.history.with_untracked(|history| history.undo_depth())
}
/// Reactive depth of the redo stack, for the button's disabled state.
pub fn redo_depth(&self) -> RwSignal<usize> {
self.history.with_untracked(|history| history.redo_depth())
}
/// Applies a popped snapshot: suppresses history capture for the
/// resulting store write, then restores.
fn apply(&self, snapshot: Option<Dashboard>) {
if let Some(snapshot) = snapshot {
self.history.update(|history| history.suppress());
replace_state(&self.store, snapshot);
}
}
}
/// Marks the current state as an undo step and suppresses history capture
/// for the next store write.
///
/// Called by `persist` right before a wholesale replacement (Clear,
/// import) so the replacement is undoable. No-op without a handle in
/// context (unit tests, SSR stubs).
pub fn before_replace() {
if let Some(handle) = use_context::<HistoryHandle>() {
flush_for_replace(&handle);
}
}
/// The core of [`before_replace`]: promotes the pending burst, marks the
/// current state as an undo step, and suppresses capture for the next write.
fn flush_for_replace(handle: &HistoryHandle) {
handle.history.update(|history| {
if let Some(current) = current_state(&handle.store) {
history.flush(current);
}
history.suppress();
});
}
/// Clones the current dashboard out of the store, when readable.
fn current_state(store: &Context) -> Option<Dashboard> {
store.try_read_untracked().map(|guard| (*guard).clone())
}
/// Client-only: starts the tracking effect and installs the shortcuts.
///
/// Call after the saved state has been loaded (`persist::init_hydrated`
/// does this), so the first captured state is the saved one, not the mock
/// the SSR pass rendered.
#[cfg(feature = "hydrate")]
pub fn start_tracking(handle: &HistoryHandle) {
use std::{
cell::{ Cell, RefCell },
rc::Rc,
};
let handle = handle.clone();
// The state as of the previous store trigger; `None` until the first change.
let last: Rc<RefCell<Option<Dashboard>>> = Rc::new(RefCell::new(None));
// The settle timer for the current burst, re-armed on every change.
let settle_timer: Rc<Cell<Option<TimeoutHandle>>> = Rc::new(Cell::new(None));
Effect::new(move |_| {
handle.store.track();
let Some(current) = current_state(&handle.store) else { return };
let mut arm = false;
handle.history.update(|history| {
if history.consume_suppress() {
// The store was replaced wholesale (undo/redo/Clear/
// import): start a fresh burst from the new state.
history.reset();
last.borrow_mut().replace(current);
} else if let Some(prev) = last.borrow_mut().replace(current.clone()) {
history.note_change(prev);
arm = true;
}
});
if arm {
// (Re)arm the settle timer: one undo step per quiet burst.
if let Some(timer) = settle_timer.take() {
timer.clear();
}
let armed = handle;
let Ok(timer) = set_timeout_with_handle(
move || armed.history.update(|history| history.settle()),
SETTLE_WINDOW,
) else {
return;
};
settle_timer.set(Some(timer));
}
});
install_shortcuts(&handle);
}
/// Installs Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (and the ⌘ variants) on the
/// window. Key combos inside editable elements are left to the browser's
/// native undo, so text fields keep their own history.
#[cfg(feature = "hydrate")]
fn install_shortcuts(handle: &HistoryHandle) {
use leptos::ev::keydown;
let handle = handle.clone();
window_event_listener(keydown, move |event: leptos::ev::KeyboardEvent| {
if !(event.ctrl_key() || event.meta_key()) {
return;
}
let key = event.key();
let is_undo = key == "z" || key == "Z";
let is_redo = key == "y" || key == "Y" || (is_undo && event.shift_key());
if !(is_undo || is_redo) {
return;
}
if target_is_editable(&event) {
return;
}
event.prevent_default();
if is_undo {
handle.undo();
} else {
handle.redo();
}
});
}
/// True when the event target is a text-editing element (input, textarea,
/// select, contenteditable): the browser owns Ctrl+Z there.
#[cfg(feature = "hydrate")]
fn target_is_editable(event: &web_sys::KeyboardEvent) -> bool {
use wasm_bindgen::JsCast;
let Some(target) = event.target().and_then(|t| t.dyn_into::<web_sys::Element>().ok()) else {
return false;
};
matches!(target.tag_name().as_str(), "INPUT" | "TEXTAREA" | "SELECT")
|| target
.dyn_into::<web_sys::HtmlElement>()
.is_ok_and(|element| element.is_content_editable())
}
#[cfg(test)]
mod tests {
use super::*;
fn named(name: &str) -> Dashboard {
let mut dashboard = Dashboard::mock();
dashboard.campaign = name.to_string();
dashboard
}
fn campaign(snapshot: Option<Dashboard>) -> Option<String> {
snapshot.map(|d| d.campaign)
}
#[test]
fn one_undo_step_per_settled_burst() {
let mut history = History::new(10);
history.note_change(named("s0"));
history.note_change(named("s1")); // same burst: the first pre-state wins
history.settle();
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
}
#[test]
fn undo_before_settle_reverts_the_whole_burst() {
let mut history = History::new(10);
history.note_change(named("s0"));
// No settle: the burst is still in flight, undo consumes it.
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
// The undone state lands on the redo stack.
assert_eq!(history.redo_depth().get(), 1);
assert_eq!(history.undo_depth().get(), 0);
}
#[test]
fn redo_restores_and_can_be_undone_again() {
let mut history = History::new(10);
history.note_change(named("s0"));
history.undo(named("s2"));
assert_eq!(campaign(history.redo(named("s0"))), Some("s2".to_string()));
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
}
#[test]
fn new_edit_clears_the_redo_stack() {
let mut history = History::new(10);
history.note_change(named("s0"));
history.undo(named("s2"));
assert_eq!(history.redo_depth().get(), 1);
// A fresh burst after the undo invalidates the redo branch.
history.note_change(named("s2"));
history.settle();
assert_eq!(history.redo_depth().get(), 0);
assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string()));
}
#[test]
fn cap_drops_the_oldest_snapshot() {
let mut history = History::new(2);
for state in ["s0", "s1", "s2"] {
history.note_change(named(state));
history.settle();
}
assert_eq!(history.undo_depth().get(), 2);
assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string()));
assert_eq!(campaign(history.undo(named("s2"))), Some("s1".to_string()));
assert_eq!(campaign(history.undo(named("s1"))), None); // "s0" was dropped
}
#[test]
fn undo_on_empty_history_is_none() {
let mut history = History::new(10);
assert_eq!(campaign(history.undo(named("s0"))), None);
assert_eq!(campaign(history.redo(named("s0"))), None);
}
#[test]
fn flush_promotes_pending_then_current() {
let mut history = History::new(10);
// A burst in flight: pre-state "s0", current "s2".
history.note_change(named("s0"));
// Wholesale replacement prep: the replacement becomes the first
// undo target, the interrupted burst the second.
history.flush(named("s2"));
assert_eq!(campaign(history.undo(named("s3"))), Some("s2".to_string()));
assert_eq!(campaign(history.undo(named("s2"))), Some("s0".to_string()));
}
#[test]
fn suppress_flag_is_consumed_once() {
let mut history = History::new(10);
history.suppress();
assert!(history.consume_suppress());
assert!(!history.consume_suppress());
}
#[test]
fn handle_flush_makes_replacements_undoable() {
let store = Store::new(named("base"));
let handle = HistoryHandle::new(store.clone());
flush_for_replace(&handle);
assert_eq!(handle.undo_depth().get(), 1);
replace_state(&store, named("blank"));
handle.undo();
let current = store.try_read_untracked().map(|g| (*g).clone()).unwrap();
assert_eq!(campaign(Some(current)), Some("base".to_string()));
handle.redo();
let current = store.try_read_untracked().map(|g| (*g).clone()).unwrap();
assert_eq!(campaign(Some(current)), Some("blank".to_string()));
}
}