From 85f344bd932f8bc0c6079fa6d9521a9d7d2ac4c1 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 22 Sep 2026 10:45:38 +0200 Subject: [PATCH] fix(studio): rebase the debounced write on the current document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write_prop/write_root_field/write_content capture m.raw (the ENTIRE document) at edit time, then 250 ms later serialize that snapshot over the whole file. If the notify watcher reloads the model from an agent/editor write during that window, the payload's raw is already stale and the flush wipes the agent's change completely — not just the edited pointer. write_and_note then registers the result in the self-write ledger, so app/mod.rs:141 makes the watcher skip the resulting event: memory holds the agent's version, disk holds the studio's stale version, and nothing ever reconciles them. No error is shown (m.write_error = None on success). This is the crate's headline workflow (baseline/diff review of agent edits), so the race is routine, not exotic. Fix: Before flushing, re-read the file and verify it still matches what the payload was derived from (hash the source at snapshot time, compare at write time); on mismatch, re-apply the single pointer mutation to the CURRENT disk content instead of replaying the stale whole-document snapshot, or surface a conflict in write_error. Do not register a self-write for a flush whose base no longer matches the disk. Refs #220 --- .../rustmotion-studio/src/editor/inspector.rs | 353 ++++-------------- crates/rustmotion-studio/src/lib.rs | 2 +- .../rustmotion-studio/src/scenario/history.rs | 13 +- crates/rustmotion-studio/src/scenario/mod.rs | 5 +- .../src/scenario/optimistic.rs | 70 ++++ crates/rustmotion-studio/tests/audit_ws_g.rs | 221 +++++++++++ 6 files changed, 389 insertions(+), 275 deletions(-) create mode 100644 crates/rustmotion-studio/tests/audit_ws_g.rs diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index 50b71250..af2b8e49 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -12,10 +12,7 @@ use crate::components::color_picker::ColorPicker; use crate::components::select::{Select, SelectOption}; use crate::components::slider::Slider; use crate::components::switch::Switch; -use crate::scenario::{ - apply_optimistic, scene_duration_for_pointer, set_field, set_field_value, set_style, - set_style_value, Mutation, Shared, -}; +use crate::scenario::{apply_optimistic, scene_duration_for_pointer, Mutation, Shared}; use super::view::RevSignal; @@ -2032,72 +2029,31 @@ fn hsv_to_hex(c: Hsv) -> String { // ── Persistence ────────────────────────────────────────────────────────────── -/// The payload for a deferred disk write. Carries everything needed to perform -/// the write so it can be captured by the spawned task without borrowing. -enum WritePayload { - Prop { - path: std::path::PathBuf, - raw: serde_json::Value, - pointer: String, - prop: String, - value: String, - }, - Content { - path: std::path::PathBuf, - raw: serde_json::Value, - pointer: String, - text: String, - }, - /// Typed root-field write (`Value::Null` removes the field / attribute). - RootField { - path: std::path::PathBuf, - raw: serde_json::Value, - pointer: String, - field: String, - value: serde_json::Value, - }, - /// Remove one style property (emptied generic control). - StyleRemove { - path: std::path::PathBuf, - raw: serde_json::Value, - pointer: String, - prop: String, - }, -} - -impl WritePayload { - fn path(&self) -> &std::path::Path { - match self { - WritePayload::Prop { path, .. } - | WritePayload::Content { path, .. } - | WritePayload::RootField { path, .. } - | WritePayload::StyleRemove { path, .. } => path, - } - } -} - -/// A root-field JSON value as an HTML attribute string. `Null` → empty (which -/// [`rustmotion::loader::set_html_attribute`] treats as "remove"); complex -/// values are compact JSON (attributes are strings; the transpiler coerces). -fn root_value_to_attr(value: &serde_json::Value) -> String { - match value { - serde_json::Value::Null => String::new(), - serde_json::Value::String(s) => s.clone(), - other => other.to_string(), - } -} - -/// 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. +/// Schedule a debounced disk write (~250 ms). Queues `mutation` onto the +/// app-global pending list for `path` and (re)starts the timer, cancelling +/// only the timer — not the queue — so a burst of edits inside the window +/// accumulates every mutation instead of dropping all but the last. /// -/// 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). +/// When the timer fires it rebases the whole queue onto whatever is on disk +/// at that moment, never a copy captured back when the edit happened, so a +/// write from outside the process landing mid-window is preserved instead of +/// being overwritten by a stale in-memory snapshot. +/// +/// 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 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. [`crate::scenario::undo`] +/// and [`crate::scenario::redo`] drain the queue before touching the file, so +/// an orphaned flush that still fires after a revert has nothing to replay. +fn schedule_write( + debounce: &WriteDebounce, + shared: Shared, + path: std::path::PathBuf, + mutation: Mutation, +) { + crate::scenario::queue_mutation(&crate::scenario::pending_write_slot(), &path, mutation); + { let mut slot = debounce.0.borrow_mut(); if let Some(prev) = slot.take() { @@ -2109,28 +2065,32 @@ fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayloa let debounce_slot = debounce.0.clone(); let task = spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(250)).await; - - // 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); + let mutations = + crate::scenario::take_pending(&crate::scenario::pending_write_slot(), &path); 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, - ); + if mutations.is_empty() { + return; + } + + let is_html = rustmotion::loader::is_html_path(&path); + let read = std::fs::read_to_string(&path).map_err(|e| format!("read: {e}")); + let result: Result = match &read { + Ok(content) => match crate::scenario::resolve_flush(content, is_html, &mutations) { + Ok(Some(new_content)) => write_and_note(&path, &new_content).map(|()| true), + Ok(None) => Ok(false), + Err(e) => Err(e), + }, + Err(e) => Err(e.clone()), + }; + if let (Ok(true), Ok(snapshot)) = (&result, &read) { + crate::scenario::record_edit(&crate::scenario::history_slot(), &path, snapshot.clone()); } + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); match result { - Ok(_) => { - // Clear any previous write error on success. - m.write_error = None; - } + Ok(_) => m.write_error = None, Err(e) => { m.write_error = Some(e); m.generation = m.generation.wrapping_add(1); @@ -2138,7 +2098,6 @@ fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayloa } }); - // Store the new handle for the next cancellation. *debounce.0.borrow_mut() = Some(task); } @@ -2150,112 +2109,12 @@ fn write_and_note(path: &std::path::Path, content: &str) -> Result<(), String> { Ok(()) } -/// 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, - raw, - pointer, - prop, - value, - } => { - if rustmotion::loader::is_html_path(path) { - let html = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; - if let Some(updated) = - rustmotion::loader::set_html_inline_style(&html, pointer, prop, value) - { - write_and_note(path, &updated)?; - 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}"))?; - write_and_note(path, &text)?; - return Ok(true); - } - Ok(false) - } - WritePayload::Content { - path, - raw, - pointer, - text, - } => { - if rustmotion::loader::is_html_path(path) { - let html = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; - if let Some(updated) = - rustmotion::loader::set_html_text_content(&html, pointer, text) - { - write_and_note(path, &updated)?; - 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}"))?; - write_and_note(path, &s)?; - return Ok(true); - } - Ok(false) - } - WritePayload::RootField { - path, - raw, - pointer, - field, - value, - } => { - if rustmotion::loader::is_html_path(path) { - let html = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; - let attr = root_value_to_attr(value); - if let Some(updated) = - rustmotion::loader::set_html_attribute(&html, pointer, field, &attr) - { - write_and_note(path, &updated)?; - return Ok(true); - } - } else if let Some(updated) = - set_field_value(raw.clone(), pointer, field, value.clone()) - { - let s = serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?; - write_and_note(path, &s)?; - return Ok(true); - } - Ok(false) - } - WritePayload::StyleRemove { - path, - raw, - pointer, - prop, - } => { - if rustmotion::loader::is_html_path(path) { - let html = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; - if let Some(updated) = - rustmotion::loader::remove_html_inline_style(&html, pointer, prop) - { - write_and_note(path, &updated)?; - return Ok(true); - } - } else if let Some(updated) = - set_style_value(raw.clone(), pointer, prop, serde_json::Value::Null) - { - let s = serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?; - write_and_note(path, &s)?; - return Ok(true); - } - Ok(false) - } - } -} - /// Apply an edit to the in-memory model immediately (canvas refreshes in ~one /// render) and nudge the hot-reload signal. Rebuild failures are transient /// (mid-typing) and silently keep the previous model — the disk write path /// has its own guards. -fn optimistic(shared: &Shared, mutation: Mutation) { - if apply_optimistic(shared, &mutation).is_ok() { +fn optimistic(shared: &Shared, mutation: &Mutation) { + if apply_optimistic(shared, mutation).is_ok() { if let Some(rev) = try_consume_context::() { let mut r = rev.0; r.set(r() + 1); @@ -2272,129 +2131,83 @@ fn write_prop(shared: &Shared, pointer: &str, prop: &str, value: &str) { if value.trim().is_empty() { return; } - optimistic( - shared, - Mutation::Style { - pointer: pointer.to_string(), - prop: prop.to_string(), - value: serde_json::Value::String(value.to_string()), - }, - ); - let (path, raw) = { + let mutation = Mutation::Style { + pointer: pointer.to_string(), + prop: prop.to_string(), + value: serde_json::Value::String(value.to_string()), + }; + optimistic(shared, &mutation); + let path = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); - (m.path.clone(), m.raw.clone()) + m.path.clone() }; let Some(path) = path else { return; }; let debounce = consume_context::(); - schedule_write( - &debounce, - shared.clone(), - WritePayload::Prop { - path, - raw, - pointer: pointer.to_string(), - prop: prop.to_string(), - value: value.to_string(), - }, - ); + schedule_write(&debounce, shared.clone(), path, mutation); } /// Typed write of a component root field (schema-driven Properties section). /// `Value::Null` removes the field (JSON) / the attribute (HTML). Debounced /// with the same guarantees as [`write_prop`]. fn write_root_field(shared: &Shared, pointer: &str, field: &str, value: serde_json::Value) { - optimistic( - shared, - Mutation::Field { - pointer: pointer.to_string(), - field: field.to_string(), - value: value.clone(), - }, - ); - let (path, raw) = { + let mutation = Mutation::Field { + pointer: pointer.to_string(), + field: field.to_string(), + value, + }; + optimistic(shared, &mutation); + let path = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); - (m.path.clone(), m.raw.clone()) + m.path.clone() }; let Some(path) = path else { return; }; let debounce = consume_context::(); - schedule_write( - &debounce, - shared.clone(), - WritePayload::RootField { - path, - raw, - pointer: pointer.to_string(), - field: field.to_string(), - value, - }, - ); + schedule_write(&debounce, shared.clone(), path, mutation); } /// Remove one style property (an emptied generic control unsets the key / /// declaration rather than writing an empty string). Debounced. fn write_style_removal(shared: &Shared, pointer: &str, prop: &str) { - optimistic( - shared, - Mutation::Style { - pointer: pointer.to_string(), - prop: prop.to_string(), - value: serde_json::Value::Null, - }, - ); - let (path, raw) = { + let mutation = Mutation::Style { + pointer: pointer.to_string(), + prop: prop.to_string(), + value: serde_json::Value::Null, + }; + optimistic(shared, &mutation); + let path = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); - (m.path.clone(), m.raw.clone()) + m.path.clone() }; let Some(path) = path else { return; }; let debounce = consume_context::(); - schedule_write( - &debounce, - shared.clone(), - WritePayload::StyleRemove { - path, - raw, - pointer: pointer.to_string(), - prop: prop.to_string(), - }, - ); + schedule_write(&debounce, shared.clone(), path, mutation); } /// Write the element's text `content` back to the scenario file. Unlike /// [`write_prop`], an empty value is allowed (clearing the text is valid). /// Schedules a debounced write (~250 ms); errors are surfaced in the topbar. fn write_content(shared: &Shared, pointer: &str, text: &str) { - optimistic( - shared, - Mutation::Field { - pointer: pointer.to_string(), - field: "content".to_string(), - value: serde_json::Value::String(text.to_string()), - }, - ); - let (path, raw) = { + let mutation = Mutation::Field { + pointer: pointer.to_string(), + field: "content".to_string(), + value: serde_json::Value::String(text.to_string()), + }; + optimistic(shared, &mutation); + let path = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); - (m.path.clone(), m.raw.clone()) + m.path.clone() }; let Some(path) = path else { return; }; let debounce = consume_context::(); - schedule_write( - &debounce, - shared.clone(), - WritePayload::Content { - path, - raw, - pointer: pointer.to_string(), - text: text.to_string(), - }, - ); + schedule_write(&debounce, shared.clone(), path, mutation); } #[cfg(test)] diff --git a/crates/rustmotion-studio/src/lib.rs b/crates/rustmotion-studio/src/lib.rs index 85d56e67..9af6d687 100644 --- a/crates/rustmotion-studio/src/lib.rs +++ b/crates/rustmotion-studio/src/lib.rs @@ -2,7 +2,7 @@ mod app; mod components; mod editor; mod library; -mod scenario; +pub mod scenario; pub use app::{run_preview, run_preview_with_error}; diff --git a/crates/rustmotion-studio/src/scenario/history.rs b/crates/rustmotion-studio/src/scenario/history.rs index d827c2f7..37378cfa 100644 --- a/crates/rustmotion-studio/src/scenario/history.rs +++ b/crates/rustmotion-studio/src/scenario/history.rs @@ -119,9 +119,16 @@ pub fn redo(shared: &Shared, slot: &SharedHistory) { step(shared, slot, false) } +/// Shared body of [`undo`]/[`redo`]. Lock discipline: the model lock and the +/// slot lock are never held together (model → path, then slot → stacks + +/// disk, then model → report). +/// +/// Drains the pending-write queue for `path` before touching the file: any +/// edit still waiting out its debounce window would otherwise fire later and +/// replay over the state this step is about to write, silently reverting the +/// undo/redo and destroying the redo entry that could have recovered it +/// (`record_edit` clears redo on every write). 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() @@ -129,8 +136,8 @@ fn step(shared: &Shared, slot: &SharedHistory, is_undo: bool) { let Some(path) = path else { return; }; + let _ = super::optimistic::take_pending(&super::optimistic::pending_write_slot(), &path); - // 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); diff --git a/crates/rustmotion-studio/src/scenario/mod.rs b/crates/rustmotion-studio/src/scenario/mod.rs index 231be62e..f6e18098 100644 --- a/crates/rustmotion-studio/src/scenario/mod.rs +++ b/crates/rustmotion-studio/src/scenario/mod.rs @@ -17,7 +17,10 @@ pub use edit::{ }; pub use history::{history_slot, record_edit, redo, set_saving, undo, SharedHistory}; pub use model::{empty_scenario, Shared, StudioModel}; -pub use optimistic::{apply_optimistic, is_self_write, note_self_write, self_write_slot, Mutation}; +pub use optimistic::{ + apply_optimistic, is_self_write, note_self_write, pending_write_slot, queue_mutation, + resolve_flush, self_write_slot, take_pending, Mutation, PendingWrites, +}; pub use sidecar::{append_sidecar_annotation, remove_sidecar_annotation}; /// Which top-level view is shown (library home vs. the editor). diff --git a/crates/rustmotion-studio/src/scenario/optimistic.rs b/crates/rustmotion-studio/src/scenario/optimistic.rs index 5cca75c0..2d0a6f32 100644 --- a/crates/rustmotion-studio/src/scenario/optimistic.rs +++ b/crates/rustmotion-studio/src/scenario/optimistic.rs @@ -201,6 +201,76 @@ pub fn is_self_write(slot: &SelfWrites, path: &Path, content: &str) -> bool { map.get(path) == Some(&content_hash(content)) } +// ── Pending writes (debounce queue) ───────────────────────────────────────── + +pub type PendingWrites = Arc>>>; + +/// App-global queue of mutations accumulated since the last successful disk +/// flush, keyed by scenario path. The debounce timer in `inspector.rs` +/// appends to it on every edit and drains it when it fires; `undo`/`redo` +/// drain it too, before touching the file, so an orphaned flush that still +/// fires after a revert has nothing left to replay. +pub fn pending_write_slot() -> PendingWrites { + static SLOT: OnceLock = OnceLock::new(); + SLOT.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) + .clone() +} + +/// Queue one mutation for `path`, to be replayed onto the freshest disk +/// content the next time the debounce flushes. +pub fn queue_mutation(slot: &PendingWrites, path: &Path, mutation: Mutation) { + let mut map = slot.lock().unwrap_or_else(|e| e.into_inner()); + map.entry(path.to_path_buf()).or_default().push(mutation); +} + +/// Remove and return every mutation queued for `path`, in the order they were +/// queued (empty when there is nothing pending: a no-op flush, or a queue a +/// concurrent undo/redo already drained). +pub fn take_pending(slot: &PendingWrites, path: &Path) -> Vec { + let mut map = slot.lock().unwrap_or_else(|e| e.into_inner()); + map.remove(path).unwrap_or_default() +} + +// ── Flush decision (pure) ──────────────────────────────────────────────────── + +/// Replay `mutations`, in order, onto `disk_content` — the freshest content on +/// disk, read right before the flush, never a snapshot captured back when an +/// edit happened. `Ok(None)` means every mutation was a no-op (nothing to +/// write); `Err` means `disk_content` itself could not be parsed as JSON (the +/// HTML branch has no such failure mode: any string is a valid rebase base). +pub fn resolve_flush( + disk_content: &str, + is_html: bool, + mutations: &[Mutation], +) -> Result, String> { + if is_html { + let mut current = disk_content.to_string(); + let mut changed = false; + for mutation in mutations { + if let Some(updated) = apply_to_html(¤t, mutation) { + current = updated; + changed = true; + } + } + Ok(changed.then_some(current)) + } else { + let mut value: Value = + serde_json::from_str(disk_content).map_err(|e| format!("parse: {e}"))?; + let mut changed = false; + for mutation in mutations { + if let Some(updated) = apply_to_raw(value.clone(), mutation) { + value = updated; + changed = true; + } + } + if !changed { + return Ok(None); + } + let text = serde_json::to_string_pretty(&value).map_err(|e| format!("json: {e}"))?; + Ok(Some(text)) + } +} + // ── Rebuild ────────────────────────────────────────────────────────────────── /// Build a `ResolvedScenario` from a raw scenario JSON value — the same diff --git a/crates/rustmotion-studio/tests/audit_ws_g.rs b/crates/rustmotion-studio/tests/audit_ws_g.rs new file mode 100644 index 00000000..1f7d983d --- /dev/null +++ b/crates/rustmotion-studio/tests/audit_ws_g.rs @@ -0,0 +1,221 @@ +//! Regression tests for the studio's file-write pipeline: a debounced write +//! that must rebase onto the current disk instead of replaying a stale +//! in-memory snapshot, coalesced edits inside one debounce window that must +//! all survive (not just the last), and undo/redo cancelling a still-pending +//! write before it can clobber the just-restored state. +//! +//! `rustmotion-studio` has no `[dev-dependencies]` and cannot gain one in +//! this change, so every test below drives the crate's existing public +//! surface (`scenario::*`) against real temp files with plain synchronous +//! `#[test]`s — no Dioxus runtime, no async executor. That public surface is +//! itself the fix for the crate having no integration tests: the defects +//! lived in a debounce timer and Dioxus event handlers that cannot be driven +//! from a test, so each one was reduced to a pure decision over plain data +//! (`resolve_flush`, the pending-write queue) and the handler calls that +//! instead of deciding inline. + +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use rustmotion_studio::scenario::{ + apply_optimistic, empty_scenario, pending_write_slot, queue_mutation, record_edit, + resolve_flush, take_pending, undo, Mutation, Shared, SharedHistory, StudioModel, +}; + +fn temp_path(tag: &str, ext: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + std::env::temp_dir().join(format!( + "rustmotion_studio_test_{tag}_{}_{nanos}.{ext}", + std::process::id() + )) +} + +fn write(path: &std::path::Path, content: &str) { + fs::write(path, content).unwrap(); +} + +fn read(path: &std::path::Path) -> String { + fs::read_to_string(path).unwrap() +} + +// ── A debounced flush rebases onto the current disk, never a stale snapshot ── + +#[test] +fn debounced_flush_rebases_onto_disk_and_survives_a_concurrent_external_edit() { + let path = temp_path("stale_snapshot", "json"); + let base = + r#"{"scenes":[{"duration":1.0,"children":[{"type":"text","content":"Hi","style":{}}]}]}"#; + write(&path, base); + + let slot = pending_write_slot(); + queue_mutation( + &slot, + &path, + Mutation::Style { + pointer: "/scenes/0/children/0".into(), + prop: "color".into(), + value: serde_json::json!("#ff0000"), + }, + ); + + // An agent (or another editor) writes the file while the debounce window + // is still open. + let external_edit = r#"{"scenes":[{"duration":1.0,"children":[{"type":"text","content":"AGENT EDIT","style":{}}]}]}"#; + write(&path, external_edit); + + let mutations = take_pending(&slot, &path); + let disk = read(&path); + let flushed = resolve_flush(&disk, false, &mutations) + .expect("disk parses as JSON") + .expect("the queued mutation is not a no-op"); + write(&path, &flushed); + + let on_disk: serde_json::Value = serde_json::from_str(&read(&path)).unwrap(); + assert_eq!( + on_disk["scenes"][0]["children"][0]["content"], + serde_json::json!("AGENT EDIT"), + "the external edit must survive the flush" + ); + assert_eq!( + on_disk["scenes"][0]["children"][0]["style"]["color"], + serde_json::json!("#ff0000"), + "the queued edit must still land" + ); + let _ = fs::remove_file(&path); +} + +// ── Every coalesced HTML mutation in a burst survives, not just the last ──── + +#[test] +fn coalesced_html_edits_in_one_debounce_window_all_survive_the_flush() { + let path = temp_path("coalesced_html", "html"); + let html_v0 = r##"Hi"##; + write(&path, html_v0); + + let slot = pending_write_slot(); + queue_mutation( + &slot, + &path, + Mutation::Style { + pointer: "/scenes/0/children/0".into(), + prop: "color".into(), + value: serde_json::json!("#ff0000"), + }, + ); + queue_mutation( + &slot, + &path, + Mutation::Style { + pointer: "/scenes/0/children/0".into(), + prop: "font-size".into(), + value: serde_json::json!("48px"), + }, + ); + + let mutations = take_pending(&slot, &path); + assert_eq!(mutations.len(), 2, "both coalesced edits are queued"); + let disk = read(&path); + let flushed = resolve_flush(&disk, true, &mutations) + .expect("html rebase never fails to parse") + .expect("two real mutations are not a no-op"); + write(&path, &flushed); + + let on_disk = read(&path); + assert!(on_disk.contains("color"), "first coalesced edit survives"); + assert!( + on_disk.contains("font-size"), + "second coalesced edit survives" + ); + let _ = fs::remove_file(&path); +} + +// ── Undo/redo cancel the pending write queue before touching the file ────── + +#[test] +fn undo_cancels_the_pending_write_queue_before_touching_the_file() { + let path = temp_path("undo_cancels_pending", "json"); + let state_a = r#"{"scenes":[{"duration":1.0,"children":[{"type":"text","content":"Hi","style":{"font-size":48}}]}]}"#; + let state_b = state_a.replace("48", "72"); + write(&path, &state_b); + + let hist: SharedHistory = Arc::new(Mutex::new(Default::default())); + record_edit(&hist, &path, state_a.to_string()); + + // An edit is still waiting out its debounce window when undo fires. + let slot = pending_write_slot(); + queue_mutation( + &slot, + &path, + Mutation::Style { + pointer: "/scenes/0/children/0".into(), + prop: "font-size".into(), + value: serde_json::json!(72), + }, + ); + + let shared: Shared = Arc::new(Mutex::new(StudioModel::new( + empty_scenario(), + None, + Some(path.clone()), + ))); + undo(&shared, &hist); + + assert_eq!(read(&path), state_a, "undo applied"); + assert!( + hist.lock().unwrap().history.can_redo(), + "redo entry available right after undo" + ); + assert!( + take_pending(&slot, &path).is_empty(), + "undo must drop the pending edit so an orphaned flush later has nothing to replay" + ); + + let _ = fs::remove_file(&path); +} + +// ── The write pipeline is reachable end to end without a Dioxus runtime ──── + +#[test] +fn the_write_pipeline_round_trips_an_edit_through_a_real_file() { + let path = temp_path("full_pipeline", "json"); + let base = r#"{"video":{"width":64,"height":64},"scenes":[{"duration":1.0,"children":[{"type":"text","content":"Hi","style":{}}]}]}"#; + write(&path, base); + + let shared: Shared = Arc::new(Mutex::new(StudioModel::new( + empty_scenario(), + None, + Some(path.clone()), + ))); + let mutation = Mutation::Style { + pointer: "/scenes/0/children/0".into(), + prop: "color".into(), + value: serde_json::json!("#00ff00"), + }; + apply_optimistic(&shared, &mutation).expect("optimistic apply succeeds"); + assert_eq!( + shared.lock().unwrap().raw["scenes"][0]["children"][0]["style"]["color"], + serde_json::json!("#00ff00"), + "the canvas sees the edit immediately, before any disk write" + ); + + let slot = pending_write_slot(); + queue_mutation(&slot, &path, mutation); + let mutations = take_pending(&slot, &path); + let disk = read(&path); + let flushed = resolve_flush(&disk, false, &mutations) + .expect("disk parses") + .expect("not a no-op"); + write(&path, &flushed); + + let on_disk: serde_json::Value = serde_json::from_str(&read(&path)).unwrap(); + assert_eq!( + on_disk["scenes"][0]["children"][0]["style"]["color"], + serde_json::json!("#00ff00"), + "disk ends up with the same edit the in-memory model already has" + ); + let _ = fs::remove_file(&path); +}