From f5ca169c84be41a44853c52dbc61c0363043e100 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sun, 21 Jun 2026 15:20:16 +0200 Subject: [PATCH 1/3] Integrate document-format storage into the editor --- Cargo.lock | 3 + desktop/src/app.rs | 2 +- .../wrapper/src/intercept_frontend_message.rs | 4 +- desktop/wrapper/src/lib.rs | 4 +- editor/Cargo.toml | 3 + editor/src/application.rs | 11 +- editor/src/consts.rs | 3 + editor/src/dispatcher.rs | 3 +- .../messages/future/future_message_handler.rs | 45 +- .../document/document_message_handler.rs | 654 ++++++++++++++- editor/src/messages/portfolio/document/mod.rs | 3 + .../document/storage_round_trip_tests.rs | 783 ++++++++++++++++++ .../utility_types/network_interface.rs | 35 + .../network_interface/storage_metadata.rs | 721 ++++++++++++++++ .../messages/portfolio/portfolio_message.rs | 50 +- .../portfolio/portfolio_message_handler.rs | 484 ++++++++++- .../src/messages/portfolio/utility_types.rs | 4 +- .../resource_storage_message_handler.rs | 26 + frontend/src/stores/portfolio.ts | 2 +- frontend/wrapper/src/editor_wrapper.rs | 13 +- 20 files changed, 2787 insertions(+), 66 deletions(-) create mode 100644 editor/src/messages/portfolio/document/storage_round_trip_tests.rs create mode 100644 editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs diff --git a/Cargo.lock b/Cargo.lock index 5aae8b9592..9576615cd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2282,11 +2282,14 @@ dependencies = [ "bitflags 2.11.0", "color", "derivative", + "document-container", + "document-format", "dyn-any", "env_logger", "futures", "glam", "graph-craft", + "graph-storage", "graphene-hash", "graphene-std", "graphite-proc-macros", diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 1c0841d7c7..ddd16dfa92 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -100,7 +100,7 @@ impl App { let wake = Arc::new(move || { wake_scheduler.schedule(AppEvent::DesktopWrapperMessage(DesktopWrapperMessage::Wake)); }); - let desktop_wrapper = DesktopWrapper::new(rand::rng().random(), Arc::new(resource_storage), wgpu_context.clone(), wake); + let desktop_wrapper = DesktopWrapper::new(rand::rng().random(), Arc::new(resource_storage), dirs::app_autosave_documents_dir(), wgpu_context.clone(), wake); Self { render_state: None, diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 2504746f8a..8d8dd54cf0 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -42,8 +42,8 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD default_filename: name, default_folder: folder, filters: vec![FileFilter { - name: "Graphite".to_string(), - extensions: vec!["graphite".to_string()], + name: "Graphite Document".to_string(), + extensions: vec!["gdd".to_string()], }], context: SaveFileDialogContext::Document { document_id, content }, }); diff --git a/desktop/wrapper/src/lib.rs b/desktop/wrapper/src/lib.rs index ad4c3f13c4..0c12580c06 100644 --- a/desktop/wrapper/src/lib.rs +++ b/desktop/wrapper/src/lib.rs @@ -25,7 +25,7 @@ pub struct DesktopWrapper { } impl DesktopWrapper { - pub fn new(uuid_random_seed: u64, resource_storage: Arc, wgpu_context: WgpuContext, schedule_wake: Wake) -> Self { + pub fn new(uuid_random_seed: u64, resource_storage: Arc, working_copy_root: std::path::PathBuf, wgpu_context: WgpuContext, schedule_wake: Wake) -> Self { #[cfg(target_os = "windows")] let host = Host::Windows; #[cfg(target_os = "macos")] @@ -36,7 +36,7 @@ impl DesktopWrapper { let application_io = PlatformApplicationIo::new_with_context(wgpu_context); Self { - editor: Editor::new(env, uuid_random_seed, resource_storage, application_io, schedule_wake), + editor: Editor::new(env, uuid_random_seed, resource_storage, Some(working_copy_root), application_io, schedule_wake), } } diff --git a/editor/Cargo.toml b/editor/Cargo.toml index a1d229e93c..6b663cfb26 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -19,6 +19,9 @@ gpu = ["interpreted-executor/gpu", "dep:wgpu-executor"] # Local dependencies graphite-proc-macros = { workspace = true } graph-craft = { workspace = true } +graph-storage = { workspace = true, features = ["conversion"] } +document-format = { workspace = true } +document-container = { workspace = true } graphene-hash = { workspace = true } interpreted-executor = { workspace = true } graphene-std = { workspace = true } # NOTE: `core-types` should not be added here because `graphene-std` re-exports its contents diff --git a/editor/src/application.rs b/editor/src/application.rs index 81d4fe0157..ade1346098 100644 --- a/editor/src/application.rs +++ b/editor/src/application.rs @@ -10,11 +10,18 @@ pub struct Editor { } impl Editor { - pub fn new(environment: Environment, uuid_random_seed: u64, resource_storage: Arc, mut application_io: PlatformApplicationIo, wake: Wake) -> Self { + pub fn new( + environment: Environment, + uuid_random_seed: u64, + resource_storage: Arc, + working_copy_root: Option, + mut application_io: PlatformApplicationIo, + wake: Wake, + ) -> Self { ENVIRONMENT.set(environment).expect("Editor shoud only be initialized once"); graphene_std::uuid::set_uuid_seed(uuid_random_seed); - let mut dispatcher = Dispatcher::new(resource_storage); + let mut dispatcher = Dispatcher::new(resource_storage, working_copy_root); dispatcher.message_handlers.future_message_handler.set_wake(wake); application_io.inject_resource_proxy(dispatcher.message_handlers.resource_storage_message_handler.resources()); crate::node_graph_executor::replace_application_io(application_io); diff --git a/editor/src/consts.rs b/editor/src/consts.rs index bee85d36e1..d1615e5656 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -176,6 +176,9 @@ pub const COLOR_OVERLAY_BLACK_75: &str = "#000000bf"; // DOCUMENT pub const FILE_EXTENSION: &str = "graphite"; +/// New document container format. Save now writes a `.gdd` (with the legacy `.graphite` embedded as a +/// fallback during the dual-write soak); `.graphite` stays a supported open/import input. +pub const GDD_FILE_EXTENSION: &str = "gdd"; pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document"; pub const MAX_UNDO_HISTORY_LEN: usize = 100; // TODO: Add this to user preferences pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 1; diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index b7d6e12309..649f62bd5b 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -89,9 +89,10 @@ const DEBUG_MESSAGE_BLOCK_LIST: &[MessageDiscriminant] = &[ const DEBUG_MESSAGE_ENDING_BLOCK_LIST: &[&str] = &["PointerMove", "PointerOutsideViewport", "Overlays", "Draw", "CurrentTime", "Time"]; impl Dispatcher { - pub fn new(resource_storage: Arc) -> Self { + pub fn new(resource_storage: Arc, working_copy_root: Option) -> Self { let mut s = Self::default(); s.message_handlers.resource_storage_message_handler = ResourceStorageMessageHandler::new(resource_storage); + s.message_handlers.portfolio_message_handler.set_working_copy_root(working_copy_root); s } diff --git a/editor/src/messages/future/future_message_handler.rs b/editor/src/messages/future/future_message_handler.rs index 353efda481..52772e717d 100644 --- a/editor/src/messages/future/future_message_handler.rs +++ b/editor/src/messages/future/future_message_handler.rs @@ -7,6 +7,11 @@ use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded}; use crate::messages::prelude::*; +// Native spawns onto a multi-thread tokio runtime, so the boxed future must be `Send`. Wasm uses +// `spawn_local` on the single JS thread, where `Send` is unavailable (OPFS/`JsFuture` are `!Send`) +// and unnecessary. `WasmNotSend` (`Send` on native, no-op on wasm) expresses the input bound on +// `MessageFuture::new`; the stored trait-object alias still needs a `cfg` split because `Send` is +// an auto trait usable in a `dyn` bound while `WasmNotSend` is not. #[cfg(not(target_family = "wasm"))] type InnerMessageFuture = Pin + Send + 'static>>; #[cfg(target_family = "wasm")] @@ -132,7 +137,7 @@ impl MessageHandler for FutureMessageHandle #[cfg(not(target_family = "wasm"))] fn default_spawner() -> Arc { - Arc::new(TokioSpawner::default()) + Arc::new(TokioSpawner) } #[cfg(target_family = "wasm")] @@ -140,37 +145,27 @@ fn default_spawner() -> Arc { Arc::new(WasmSpawner) } +/// Process-global runtime for editor async work. Held in a `LazyLock` so it lives for the lifetime +/// of the process and is never dropped: dropping a `tokio::runtime::Runtime` blocks to join its +/// worker threads, which panics if it happens inside an async context (e.g. a `#[tokio::test]` body +/// or the desktop event loop). A leaked-for-process runtime sidesteps that entirely. #[cfg(not(target_family = "wasm"))] -struct TokioSpawner { - /// Built lazily on first spawn. `multi_thread(1)` lets Tokio manage its own driver. - runtime: std::sync::OnceLock, -} - -#[cfg(not(target_family = "wasm"))] -impl Default for TokioSpawner { - fn default() -> Self { - Self { runtime: std::sync::OnceLock::new() } - } -} +static EDITOR_ASYNC_RUNTIME: std::sync::LazyLock = std::sync::LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .thread_name("graphite-async") + .enable_all() + .build() + .expect("failed to construct async-message tokio runtime") +}); #[cfg(not(target_family = "wasm"))] -impl TokioSpawner { - fn runtime(&self) -> &tokio::runtime::Runtime { - self.runtime.get_or_init(|| { - tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) - .thread_name("graphite-async") - .enable_all() - .build() - .expect("failed to construct async-message tokio runtime") - }) - } -} +struct TokioSpawner; #[cfg(not(target_family = "wasm"))] impl MessageSpawner for TokioSpawner { fn spawn(&self, future: InnerMessageFuture, results: UnboundedSender, wake: Wake) { - self.runtime().spawn(async move { + EDITOR_ASYNC_RUNTIME.spawn(async move { let message = future.await; let _ = results.unbounded_send(message); wake(); diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index dcffdac794..48a51b8649 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -5,7 +5,7 @@ use super::utility_types::network_interface::{self, NodeNetworkInterface, Transa use super::utility_types::nodes::{CollapsedLayers, LayerStructureEntry, SelectedNodes}; use crate::application::{GRAPHITE_GIT_COMMIT_HASH, generate_uuid}; use crate::consts::{ - ASYMPTOTIC_EFFECT, BLEND_COUNT_PER_LAYER, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING, + ASYMPTOTIC_EFFECT, BLEND_COUNT_PER_LAYER, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, GDD_FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ROTATE_SNAP_INTERVAL, }; use crate::messages::input_mapper::utility_types::macros::action_shortcut; @@ -64,7 +64,8 @@ pub struct DocumentMessageContext<'a> { pub fonts: &'a FontsMessageHandler, } -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, ExtractField)] +#[derive(derivative::Derivative, serde::Serialize, serde::Deserialize, ExtractField)] +#[derivative(Clone, Debug)] #[serde(default)] pub struct DocumentMessageHandler { // ====================== @@ -91,6 +92,13 @@ pub struct DocumentMessageHandler { /// Resources embedded in the document. #[serde(default, skip_serializing_if = "ResourceMessageHandler::is_empty")] pub resources: ResourceMessageHandler, + /// Per-document `Gdd` working copy: owns the CRDT `Session` and mirrors edits to disk. `None` + /// until the mount future built by `load_document` resolves (the working-copy container is + /// constructed asynchronously). Not serialized. A clone shares the same working-copy container + /// (`Gdd` holds an `Arc`), so a cloned document still reads the live working copy. + #[serde(skip, default)] + #[derivative(Debug = "ignore")] + pub storage: Option, /// Tracks which layer occurrences are collapsed in the Layers panel, keyed by tree path. #[serde(deserialize_with = "deserialize_collapsed_layers", default)] pub collapsed: CollapsedLayers, @@ -172,6 +180,7 @@ impl Default for DocumentMessageHandler { // ============================================ network_interface: default_document_network_interface(), resources: ResourceMessageHandler::default(), + storage: None, collapsed: CollapsedLayers::default(), commit_hash: GRAPHITE_GIT_COMMIT_HASH.to_string(), document_ptz: PTZ::default(), @@ -396,8 +405,8 @@ impl MessageHandler> for DocumentMes responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] }); self.layer_range_selection_reference = None; } - DocumentMessage::DocumentHistoryBackward => self.undo_with_history(viewport, responses), - DocumentMessage::DocumentHistoryForward => self.redo_with_history(viewport, responses), + DocumentMessage::DocumentHistoryBackward => self.undo_with_history(document_id, viewport, resource_storage, responses), + DocumentMessage::DocumentHistoryForward => self.redo_with_history(document_id, viewport, resource_storage, responses), DocumentMessage::DocumentStructureChanged => { if layers_panel_open { self.network_interface.load_structure(); @@ -784,7 +793,13 @@ impl MessageHandler> for DocumentMes let layer_node_id = NodeId::new(); let layer_id = LayerNodeIdentifier::new_unchecked(layer_node_id); - responses.add(DocumentMessage::AddTransaction); + // Emit the transaction as an explicit StartTransaction ... CommitTransaction pair bracketing the + // mutations, rather than `AddTransaction`. `AddTransaction` prepends its Start/Commit as a tight + // pair that the dispatcher drains before these sibling mutation messages, so the commit closes + // before the layer is added and the storage staging sees an empty diff. Interleaving the pair + // around the mutations makes them all siblings drained in order (Start, mutate, Commit), so the + // commit observes the paste and stages it. + responses.add(DocumentMessage::StartTransaction); let layer = graph_modification_utils::new_image_layer(image, layer_node_id, layer_parent, responses); @@ -793,7 +808,8 @@ impl MessageHandler> for DocumentMes node_id: layer.to_node(), network_path: Vec::new(), alias: name, - skip_adding_history_step: false, + // Fold the name into the paste's single transaction so the whole paste is one undo step. + skip_adding_history_step: true, }); } if let Some((parent, insert_index)) = parent_and_insert_index { @@ -814,6 +830,8 @@ impl MessageHandler> for DocumentMes skip_rerender: false, }); + responses.add(DocumentMessage::CommitTransaction); + // Force chosen tool to be Select Tool after importing image. responses.add(ToolMessage::ActivateTool { tool_type: ToolType::Select }); } @@ -851,7 +869,8 @@ impl MessageHandler> for DocumentMes node_id: layer.to_node(), network_path: Vec::new(), alias: name, - skip_adding_history_step: false, + // Fold the name into the paste's single transaction so the whole paste is one undo step. + skip_adding_history_step: true, }); } if let Some((parent, insert_index)) = parent_and_insert_index { @@ -985,28 +1004,52 @@ impl MessageHandler> for DocumentMes DocumentMessage::SaveDocument | DocumentMessage::SaveDocumentAs => { responses.add(PortfolioMessage::AutoSaveActiveDocument); - let name = format!("{}.{}", self.name.clone(), FILE_EXTENSION); + let name = format!("{}.{}", self.name.clone(), GDD_FILE_EXTENSION); let path = if let DocumentMessage::SaveDocumentAs = message { None } else { self.path.clone() }; if path.is_some() { responses.add(DocumentMessage::MarkAsSaved); } let folder = self.path.as_ref().and_then(|path| path.parent()).map(|parent| parent.to_path_buf()); + // The clone shares the live working-copy container (Arc), so its storage reads the state the + // queued AutoSaveActiveDocument just committed. let mut document = self.clone(); let resources_load_handle = resource_storage.resources(); + let export_load_handle = resource_storage.resources(); responses.add(async move { document.resources.collect_garbage(document.used_resources(false).as_ref()); document.resources.embed_resources(resources_load_handle).await; - let content = document.serialize_document().into_bytes().into(); + // The legacy .graphite blob, resources embedded inline so it stays self-contained as the .gdd recovery fallback. + let legacy_document = document.serialize_document().into_bytes(); + + // Export the working copy as a .gdd with the legacy blob embedded; fall back to the bare legacy blob if there is no working copy or the export fails. + let content = match &document.storage { + Some(storage) => storage + .export_to_bytes( + document_format::ExportFormat::Xz, + document_format::ExportOptions::default(), + export_load_handle.as_ref(), + Some(&legacy_document), + ) + .await + .unwrap_or_else(|error| { + log::error!("Save: building .gdd export failed, falling back to legacy .graphite: {error}"); + legacy_document + }), + None => { + log::warn!("Save: working copy not mounted yet, saving legacy .graphite only"); + legacy_document + } + }; Message::Frontend(FrontendMessage::TriggerSaveDocument { document_id, name, path, folder, - content, + content: content.into(), }) }); } @@ -1252,6 +1295,11 @@ impl MessageHandler> for DocumentMes } // Note: A transaction should never be started in a scope that mutates the network interface, since it will only be run after that scope ends. DocumentMessage::StartTransaction => { + // A new undo step is beginning, so the previous interaction's staged hot ops are complete: + // retire them into one durable Gdd interaction. This aligns one Gdd interaction to one legacy + // undo step (a tool drag fires several `CommitTransaction`s but one `StartTransaction`). + self.retire_storage_interaction(); + self.network_interface.start_transaction(); let network_interface_clone = self.network_interface.clone(); self.document_undo_history.push_back(network_interface_clone); @@ -1281,6 +1329,14 @@ impl MessageHandler> for DocumentMes } self.network_interface.finish_transaction(); self.document_redo_history.clear(); + + // Stage this commit into the `Gdd` working copy. Retirement into a durable interaction happens + // at the undo-step boundary (`StartTransaction`), so several commits in one user action + // coalesce into one undo unit. Legacy snapshot undo stays authoritative. + if let Some(byte_store) = resource_storage.storage() { + self.commit_storage_snapshot(byte_store); + } + responses.add(PortfolioMessage::UpdateOpenDocumentsList); } DocumentMessage::AbortTransaction => match self.network_interface.transaction_status() { @@ -1707,6 +1763,40 @@ impl MessageHandler> for DocumentMes } impl DocumentMessageHandler { + /// Build a document handler from a `.gdd` working copy: the runtime `interface` rebuilt from the + /// stored registry plus the mounted `Gdd`. The caller converts the registry to `interface`; this + /// restores the document-level `ui::doc::*` settings and resource registry and takes ownership of + /// the working copy. Post-load fixups (`load_structure`, validation, graph run) are the loader's job. + pub fn from_storage(interface: NodeNetworkInterface, storage: document_format::GddV1, name: String, path: Option) -> Self { + let mut document = Self { + network_interface: interface, + name, + path, + ..Default::default() + }; + + document.apply_stored_document_settings(&storage.view_settings().clone()); + match storage.registry().to_resource_registry() { + Ok(resource_registry) => document.resources.registry = resource_registry, + Err(error) => log::error!("Opening .gdd: failed to rebuild resource registry: {error}"), + } + document.storage = Some(storage); + + document + } + + /// Post-load fixups for a document built from storage: per-node input/output metadata validation + /// and the layer-structure rebuild. Mirrors the essential part of the legacy load path; the + /// old-file layer-stacking realignment is skipped (a current-format `.gdd` doesn't need it). + pub fn finalize_storage_load(&mut self) { + for (node_id, node, path) in self.network_interface.document_network().clone().recursive_nodes() { + self.network_interface.validate_input_metadata(node_id, node, &path); + self.network_interface.validate_output_names(node_id, node, &path); + } + + self.network_interface.load_structure(); + } + /// Translates a viewport mouse position to a document-space transform, or uses the viewport center if no mouse position is given. fn document_transform_from_mouse(&self, mouse: Option<(f64, f64)>, viewport: &ViewportMessageHandler) -> DAffine2 { let viewport_pos: DVec2 = mouse.map_or_else(|| viewport.center_in_viewport_space().into_dvec2() + viewport.offset().into_dvec2(), |pos| pos.into()); @@ -1882,6 +1972,274 @@ impl DocumentMessageHandler { &self.selection_network_path } + /// Retire the pending staged hot ops (the current interaction) into durable Gdd history as one undo + /// unit. Called at each undo-step boundary (a new `StartTransaction`) and before undo/redo, so the + /// per-`CommitTransaction` staging coalesces into one retired interaction aligned with the legacy step. + pub(crate) fn retire_storage_interaction(&mut self) { + let Some(storage) = self.storage.as_mut() else { return }; + if let Err(error) = storage.retire_pending_interaction() { + log::error!("Storage interaction retirement failed: {error}"); + } + } + + /// Stage the runtime network into the document's `Gdd` working copy at each `CommitTransaction`. + /// No-op while the working copy is still unmounted (its container is built asynchronously on + /// document open); the mount picks up the current runtime state once it attaches. The staged hot ops + /// are retired into durable history by [`retire_storage_interaction`](Self::retire_storage_interaction) at + /// undo-step boundaries. Proto-node declaration bytes are persisted into `byte_store` (the app-global + /// resource cache). + pub fn commit_storage_snapshot(&mut self, byte_store: &dyn graph_craft::application_io::resource::ResourceStorage) { + use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{DocumentSettings, StorageMetadataView}; + + if self.storage.is_none() { + return; + } + + use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::collect_network_view_settings; + + let network = self.network_interface.document_network().clone(); + let view = StorageMetadataView::new(&self.network_interface); + + // Per-network view state (node-graph nav + previewing) is per-peer too, so collect it (keyed by the + // stable `NetworkId` the storage layer resolves) for `session.json`. Computed before the mutable + // `storage` borrow below. + let network_view_settings = self + .storage + .as_ref() + .and_then(|storage| storage.network_ids(&network, &view).ok()) + .map(|network_ids| collect_network_view_settings(&self.network_interface, &network_ids)); + + // Per-peer view settings (PTZ, rulers, ...) persist in `session.json`, not the registry, so they + // stay out of the CRDT/history (not undoable, not synced, may differ per viewer). + let view_settings = DocumentSettings { + document_ptz: &self.document_ptz, + render_mode: &self.render_mode, + overlays_visibility: &self.overlays_visibility_settings, + rulers_visible: self.rulers_visible, + snapping_state: &self.snapping_state, + collapsed: &self.collapsed, + } + .to_view_map(); + + // Serialize the legacy document from the same `self` state captured above, so the embedded + // blob and the registry snapshot describe one consistent document (no interleaved edit). + let legacy_document = self.serialize_document(); + + // `view` borrows disjoint `self` fields, so the mutable `storage` borrow is independent. + let storage = self.storage.as_mut().expect("checked present above"); + // Stage into the working copy without retiring: a tool drag fires several `CommitTransaction`s + // but is one legacy undo step, so the deltas accumulate as hot ops and coalesce into one retired + // interaction at the next undo-step boundary (`retire_pending_interaction`). + if let Err(error) = storage.stage_runtime_snapshot(&network, &view, &self.resources.registry, byte_store) { + log::error!("Storage snapshot staging failed: {error}"); + return; + } + + if let Err(error) = storage.set_view_settings(view_settings) { + log::error!("Persisting view settings failed: {error}"); + } + + if let Some(network_view_settings) = network_view_settings + && let Err(error) = storage.set_network_view_settings(network_view_settings) + { + log::error!("Persisting per-network view settings failed: {error}"); + } + + // Dual-write soak: embed the legacy `.graphite` bytes inside the `.gdd` working copy so the new + // format can be validated against (and recovered from) the old one on open. + if let Err(error) = storage.store_legacy_document(legacy_document.as_bytes()) { + log::error!("Embedding legacy document into working copy failed: {error}"); + } + + #[cfg(debug_assertions)] + self.verify_storage_round_trip(&network, &view); + } + + /// Restore the per-peer view settings persisted in `session.json` (the `ui::doc::*`-keyed + /// `view_settings` map) into the runtime handler fields. Each setting is applied only if present and + /// decodable; missing or undecodable keys leave the current field untouched. Inverse of the view- + /// settings half of `commit_storage_snapshot`; used when the `.gdd` is the load source. + pub fn apply_stored_document_settings(&mut self, view_settings: &std::collections::BTreeMap) { + use graph_storage::attr::session::doc; + + fn decode(view_settings: &std::collections::BTreeMap, key: &str) -> Option { + view_settings.get(key).and_then(|value| serde_json::from_value(value.clone()).ok()) + } + + if let Some(value) = decode(view_settings, doc::PTZ) { + self.document_ptz = value; + } + if let Some(value) = decode(view_settings, doc::RENDER_MODE) { + self.render_mode = value; + } + if let Some(value) = decode(view_settings, doc::OVERLAYS) { + self.overlays_visibility_settings = value; + } + if let Some(value) = decode(view_settings, doc::RULERS_VISIBLE) { + self.rulers_visible = value; + } + if let Some(value) = decode(view_settings, doc::SNAPPING) { + self.snapping_state = value; + } + if let Some(value) = decode(view_settings, doc::COLLAPSED) { + self.collapsed = value; + } + } + + /// Debug-only: stored registry should equal a fresh `from_runtime`, and a `to_runtime` of the + /// stored registry should equal the original network. Panics on drift so the dual-write soak + /// fails loud in dev (and in tests); release builds skip this entirely and autosave never crashes. + #[cfg(debug_assertions)] + fn verify_storage_round_trip( + &self, + network: &graph_craft::document::NodeNetwork, + view: &crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::StorageMetadataView, + ) { + let Some(storage) = &self.storage else { return }; + let peer = storage.session().peer(); + + let conversion = graph_storage::Registry::convert_from_runtime(network, view, &self.resources.registry, peer).expect("storage round-trip: from_runtime failed"); + let target = &conversion.registry; + let declarations = conversion.declarations().expect("storage round-trip: declaration rebuild failed"); + + let stored = storage.registry(); + assert!(stored.value_equal(target), "storage round-trip: registry value drift after commit\n{}", diff_registries(stored, target)); + assert!(stored.order_consistent(target), "storage round-trip: timestamp order inconsistent between stored and target"); + + let (round_tripped, _entries) = stored.to_runtime_with_metadata(&declarations).expect("storage round-trip: to_runtime failed"); + assert!( + &round_tripped == network, + "storage round-trip: network drift after to_runtime\n{}", + diff_networks(network, &round_tripped) + ); + } + + /// Move the `Gdd` undo/redo cursor (the authoritative path) and spawn the async future that rebuilds the + /// interface from the cursor and swaps it in. Synchronous: flushes the open interaction, moves the cursor + /// (persisting `session.json`), then clones the post-move `Gdd` and queues the rebuild. `had_oracle` + /// records whether the legacy snapshot already applied, so the completion can compare; it travels with + /// the spawned message. Returns whether the cursor moved (so callers know a rebuild is pending). + fn drive_storage_undo_redo(&mut self, document_id: DocumentId, resource_storage: &ResourceStorageMessageHandler, had_oracle: bool, undo: bool, responses: &mut VecDeque) -> bool { + // Flush any open interaction into retired history first: undo/redo operate on the retired chain, so + // the most recent edit must be a committed interaction before the cursor moves. + self.retire_storage_interaction(); + + let Some(storage) = self.storage.as_mut() else { return false }; + + let moved = if undo { + if !storage.can_undo() { + return false; + } + storage.undo().map(|_| ()) + } else { + if !storage.can_redo() { + return false; + } + storage.redo().map(|_| ()) + }; + if let Err(error) = moved { + log::error!("Storage undo/redo cursor move failed: {error}"); + return false; + } + + let Some(store_handle) = resource_storage.store_handle() else { + log::error!("Storage undo/redo: resource storage not initialized; cannot rebuild interface"); + return false; + }; + + // Clone the post-move `Gdd` (a `Session` snapshot at the new cursor; the container is `Arc`-shared) + // so the `'static` rebuild future reads the rewound state while the live document keeps its cursor. + let gdd = storage.clone(); + responses.add(crate::messages::portfolio::portfolio_message_handler::rebuild_gdd_cursor_future( + gdd, + store_handle, + document_id, + had_oracle, + )); + true + } + + /// Swap in the interface rebuilt from the `Gdd` cursor (authoritative), preserving transient state the + /// registry doesn't model (navigation metadata, resolved types) by copying it from the current live + /// interface. When `had_oracle` is set (the legacy snapshot applied synchronously), debug builds compare + /// the rebuilt network against the legacy-restored one and log drift before overwriting. Always + /// overwrites: the `Gdd` cursor is the source of truth, including the across-reopen case where no legacy + /// snapshot exists. + pub(crate) fn apply_gdd_cursor_rebuild(&mut self, mut rebuilt: NodeNetworkInterface, had_oracle: bool, responses: &mut VecDeque) { + // The registry models document content only, so the rebuilt interface has default view and selection + // state. Carry the user's current view, selection, and resolved types from the live interface so undo + // changes the document without moving the camera, clearing the selection, or jumping the node graph. + rebuilt.copy_all_transient_view_state(&self.network_interface); + std::mem::swap(&mut rebuilt.resolved_types, &mut self.network_interface.resolved_types); + rebuilt.load_structure(); + + #[cfg(debug_assertions)] + if had_oracle { + self.compare_rebuild_against_legacy(&rebuilt); + } + + self.network_interface = rebuilt; + + // The live `Gdd` cursor's registry should match a fresh `from_runtime` of the now-swapped interface. + #[cfg(debug_assertions)] + self.verify_storage_cursor_matches_runtime(); + + responses.add(PortfolioMessage::UpdateOpenDocumentsList); + responses.add(NodeGraphMessage::SelectedNodesUpdated); + responses.add(NodeGraphMessage::ForceRunDocumentGraph); + responses.add(NodeGraphMessage::UnloadWires); + responses.add(NodeGraphMessage::SendWires); + } + + /// Debug-only: the rebuilt network should equal the legacy-restored one. Logs drift (does not panic) so + /// cursor/conversion bugs surface in dev without crashing the live editor while the path hardens. + #[cfg(debug_assertions)] + fn compare_rebuild_against_legacy(&self, rebuilt: &NodeNetworkInterface) { + let legacy = self.network_interface.document_network(); + let candidate = rebuilt.document_network(); + if candidate != legacy { + log::error!("undo/redo rebuild diverged from the legacy snapshot\n{}", diff_networks(legacy, candidate)); + #[cfg(test)] + panic!() + } + } + + /// Debug-only: after a `Gdd` cursor move, the cursor's registry should equal a fresh `from_runtime` + /// of the current (legacy-restored) interface. Logs drift (see the call site for why it does not + /// panic) so cursor bugs surface in dev without crashing the editor. + #[cfg(debug_assertions)] + fn verify_storage_cursor_matches_runtime(&self) { + use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::StorageMetadataView; + + let Some(storage) = &self.storage else { return }; + let peer = storage.session().peer(); + + let network = self.network_interface.document_network().clone(); + let view = StorageMetadataView::new(&self.network_interface); + let Ok(mut conversion) = graph_storage::Registry::convert_from_runtime(&network, &view, &self.resources.registry, peer) else { + log::error!("undo/redo shadow: from_runtime failed"); + return; + }; + + let stored = storage.registry(); + + // The runtime keeps resources alive while they're referenced by undo/redo history (so legacy redo + // can restore them), but the `Gdd` cursor reverts the interaction's `AddResource`. So a resource the + // runtime carries which the cursor dropped is expected, not drift, as long as it's history-only + // (not referenced by the current network). Drop those from the conversion before comparing. + let current_resources: std::collections::HashSet<_> = self.used_resources(false).iter().copied().collect(); + conversion.registry.resources.retain(|id, _| stored.resources.contains_key(id) || current_resources.contains(id)); + + if !stored.value_equal(&conversion.registry) { + // Logged, not panicked: any remaining drift is a real cursor or conversion bug to triage, but + // the shadow must not crash the live editor while we harden it toward a hard panic. + log::error!( + "undo/redo shadow: cursor registry diverged from the restored interface\n{}", + diff_registries(stored, &conversion.registry) + ); + } + } + pub fn serialize_document(&self) -> String { let val = serde_json::to_string(self); // We fully expect the serialization to succeed @@ -2159,13 +2517,23 @@ impl DocumentMessageHandler { paths } - pub fn undo_with_history(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque) { - let Some(previous_network) = self.undo(viewport, responses) else { return }; + pub fn undo_with_history(&mut self, document_id: DocumentId, viewport: &ViewportMessageHandler, resource_storage: &ResourceStorageMessageHandler, responses: &mut VecDeque) { + // Apply the legacy snapshot synchronously so its `VecDeque` stays consistent and can serve as the + // rebuild's oracle. The `Gdd` cursor then moves + persists and spawns the async rebuild that becomes + // authoritative, overwriting the live interface when it lands. When no legacy snapshot applied (e.g. + // an across-reopen undo where the legacy stack is empty but the persisted `Gdd` cursor can still + // move), the rebuild overwrites with no comparison. + let legacy_applied = if let Some(previous_network) = self.undo(viewport, responses) { + self.document_redo_history.push_back(previous_network); + if self.document_redo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN { + self.document_redo_history.pop_front(); + } + true + } else { + false + }; - self.document_redo_history.push_back(previous_network); - if self.document_redo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN { - self.document_redo_history.pop_front(); - } + self.drive_storage_undo_redo(document_id, resource_storage, legacy_applied, true, responses); } pub fn undo(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque) -> Option { @@ -2195,14 +2563,20 @@ impl DocumentMessageHandler { Some(previous_network) } - pub fn redo_with_history(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque) { - // Push the UpdateOpenDocumentsList message to the queue in order to update the save status of the open documents - let Some(previous_network) = self.redo(viewport, responses) else { return }; + pub fn redo_with_history(&mut self, document_id: DocumentId, viewport: &ViewportMessageHandler, resource_storage: &ResourceStorageMessageHandler, responses: &mut VecDeque) { + // Mirror `undo_with_history`: apply the legacy snapshot synchronously (oracle), then move the `Gdd` + // cursor and spawn the authoritative async rebuild. + let legacy_applied = if let Some(previous_network) = self.redo(viewport, responses) { + self.document_undo_history.push_back(previous_network); + if self.document_undo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN { + self.document_undo_history.pop_front(); + } + true + } else { + false + }; - self.document_undo_history.push_back(previous_network); - if self.document_undo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN { - self.document_undo_history.pop_front(); - } + self.drive_storage_undo_redo(document_id, resource_storage, legacy_applied, false, responses); } pub fn redo(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque) -> Option { @@ -3549,6 +3923,240 @@ impl DocumentMessageHandler { } } +#[cfg(debug_assertions)] +pub(crate) fn diff_registries(stored: &graph_storage::Registry, target: &graph_storage::Registry) -> String { + use std::fmt::Write; + let mut out = String::new(); + + let stored_node_ids: std::collections::BTreeSet<_> = stored.node_instances.keys().copied().collect(); + let target_node_ids: std::collections::BTreeSet<_> = target.node_instances.keys().copied().collect(); + let missing_nodes: Vec<_> = target_node_ids.difference(&stored_node_ids).collect(); + let extra_nodes: Vec<_> = stored_node_ids.difference(&target_node_ids).collect(); + let shared_node_diffs: Vec<_> = stored_node_ids + .intersection(&target_node_ids) + .filter(|id| !stored.node_instances[id].value_equal(&target.node_instances[id])) + .collect(); + + let stored_network_ids: std::collections::BTreeSet<_> = stored.networks.keys().copied().collect(); + let target_network_ids: std::collections::BTreeSet<_> = target.networks.keys().copied().collect(); + let missing_networks: Vec<_> = target_network_ids.difference(&stored_network_ids).collect(); + let extra_networks: Vec<_> = stored_network_ids.difference(&target_network_ids).collect(); + let shared_network_diffs: Vec<_> = stored_network_ids + .intersection(&target_network_ids) + .filter(|id| !stored.networks[id].value_equal(&target.networks[id])) + .collect(); + + let _ = writeln!(out, " nodes: stored={} target={}", stored_node_ids.len(), target_node_ids.len()); + if !missing_nodes.is_empty() { + let _ = writeln!(out, " missing from stored: {missing_nodes:?}"); + } + if !extra_nodes.is_empty() { + let _ = writeln!(out, " extra in stored: {extra_nodes:?}"); + } + if !shared_node_diffs.is_empty() { + let _ = writeln!(out, " differing payloads: {shared_node_diffs:?}"); + for id in &shared_node_diffs { + let stored_node = &stored.node_instances[id]; + let target_node = &target.node_instances[id]; + let _ = writeln!(out, " node {id}:"); + diff_node(&mut out, stored_node, target_node); + } + } + + let _ = writeln!(out, " networks: stored={} target={}", stored_network_ids.len(), target_network_ids.len()); + if !missing_networks.is_empty() { + let _ = writeln!(out, " missing from stored: {missing_networks:?}"); + } + if !extra_networks.is_empty() { + let _ = writeln!(out, " extra in stored: {extra_networks:?}"); + } + if !shared_network_diffs.is_empty() { + let _ = writeln!(out, " differing payloads: {shared_network_diffs:?}"); + for id in &shared_network_diffs { + let stored_network = &stored.networks[id]; + let target_network = &target.networks[id]; + let _ = writeln!(out, " network {id}:"); + diff_network(&mut out, stored_network, target_network); + } + } + + let stored_resources: std::collections::BTreeSet<_> = stored.resources.keys().copied().collect(); + let target_resources: std::collections::BTreeSet<_> = target.resources.keys().copied().collect(); + let missing_resources: Vec<_> = target_resources.difference(&stored_resources).collect(); + let extra_resources: Vec<_> = stored_resources.difference(&target_resources).collect(); + if !missing_resources.is_empty() || !extra_resources.is_empty() { + let _ = writeln!(out, " resources: stored={} target={}", stored_resources.len(), target_resources.len()); + if !missing_resources.is_empty() { + let _ = writeln!(out, " missing from stored: {missing_resources:?}"); + } + if !extra_resources.is_empty() { + let _ = writeln!(out, " extra in stored: {extra_resources:?}"); + } + } + + if stored.attributes != target.attributes { + let stored_keys: std::collections::BTreeSet<_> = stored.attributes.keys().collect(); + let target_keys: std::collections::BTreeSet<_> = target.attributes.keys().collect(); + let _ = writeln!(out, " document attributes differ: stored_keys={stored_keys:?} target_keys={target_keys:?}"); + } + + out +} + +#[cfg(debug_assertions)] +fn diff_node(out: &mut String, stored: &graph_storage::Node, target: &graph_storage::Node) { + use std::fmt::Write; + + if stored.implementation() != target.implementation() { + let _ = writeln!(out, " implementation: stored={:?} target={:?}", stored.implementation(), target.implementation()); + } + if stored.network() != target.network() { + let _ = writeln!(out, " network back-pointer: stored={} target={}", stored.network(), target.network()); + } + + let stored_inputs = stored.inputs(); + let target_inputs = target.inputs(); + if stored_inputs.len() != target_inputs.len() { + let _ = writeln!(out, " inputs.len: stored={} target={}", stored_inputs.len(), target_inputs.len()); + } + for (i, (s, t)) in stored_inputs.iter().zip(target_inputs.iter()).enumerate() { + if s != t { + let value_differs = s.input != t.input; + let timestamp_differs = s.timestamp != t.timestamp; + let _ = writeln!(out, " input[{i}]: value_differs={value_differs} timestamp_differs={timestamp_differs}"); + if value_differs { + let _ = writeln!(out, " stored.value={:?}\n target.value={:?}", s.input, t.input); + } + if s.attributes != t.attributes { + diff_attributes(out, &format!(" input[{i}].attributes"), &s.attributes, &t.attributes); + } + } + } + + if stored.attributes() != target.attributes() { + diff_attributes(out, " attributes", stored.attributes(), target.attributes()); + } +} + +#[cfg(debug_assertions)] +fn diff_network(out: &mut String, stored: &graph_storage::Network, target: &graph_storage::Network) { + use std::fmt::Write; + + if stored.exports.len() != target.exports.len() { + let _ = writeln!(out, " exports.len: stored={} target={}", stored.exports.len(), target.exports.len()); + } + for (i, (s, t)) in stored.exports.iter().zip(target.exports.iter()).enumerate() { + if s != t { + let target_differs = s.target != t.target; + let timestamp_differs = s.timestamp != t.timestamp; + let _ = writeln!(out, " export[{i}]: target_differs={target_differs} timestamp_differs={timestamp_differs}"); + if target_differs { + let _ = writeln!(out, " stored.target={:?}\n target.target={:?}", s.target, t.target); + } + } + } + + if stored.attributes != target.attributes { + diff_attributes(out, " attributes", &stored.attributes, &target.attributes); + } +} + +#[cfg(debug_assertions)] +fn diff_attributes(out: &mut String, label: &str, stored: &graph_storage::Attributes, target: &graph_storage::Attributes) { + use std::fmt::Write; + + let stored_keys: std::collections::BTreeSet<_> = stored.keys().collect(); + let target_keys: std::collections::BTreeSet<_> = target.keys().collect(); + let missing: Vec<_> = target_keys.difference(&stored_keys).collect(); + let extra: Vec<_> = stored_keys.difference(&target_keys).collect(); + let differing: Vec<_> = stored_keys.intersection(&target_keys).filter(|k| stored.get(**k) != target.get(**k)).collect(); + + let _ = writeln!(out, "{label}: missing_from_stored={missing:?} extra_in_stored={extra:?} differing_values={differing:?}"); +} + +/// Human-readable summary of how two networks differ (exports, node set, per-node payloads, scope +/// injections). Used by the debug-only `verify_storage_round_trip` and by compare-on-open, which runs +/// in release too, so this is not debug-gated. +pub(crate) fn diff_networks(expected: &graph_craft::document::NodeNetwork, actual: &graph_craft::document::NodeNetwork) -> String { + use std::fmt::Write; + let mut out = String::new(); + + if expected.exports != actual.exports { + let _ = writeln!(out, " exports differ: expected={} actual={}", expected.exports.len(), actual.exports.len()); + for (i, (exp, act)) in expected.exports.iter().zip(actual.exports.iter()).enumerate() { + if exp != act { + let _ = writeln!(out, " [{i}] expected={exp:?}\n actual= {act:?}"); + } + } + } + + let expected_ids: std::collections::BTreeSet<_> = expected.nodes.keys().copied().collect(); + let actual_ids: std::collections::BTreeSet<_> = actual.nodes.keys().copied().collect(); + let missing: Vec<_> = expected_ids.difference(&actual_ids).collect(); + let extra: Vec<_> = actual_ids.difference(&expected_ids).collect(); + let differing: Vec<_> = expected_ids.intersection(&actual_ids).filter(|id| expected.nodes.get(id) != actual.nodes.get(id)).collect(); + + if !missing.is_empty() || !extra.is_empty() || !differing.is_empty() { + let _ = writeln!(out, " nodes: expected={} actual={}", expected_ids.len(), actual_ids.len()); + if !missing.is_empty() { + let _ = writeln!(out, " missing from actual: {missing:?}"); + } + if !extra.is_empty() { + let _ = writeln!(out, " extra in actual: {extra:?}"); + } + if !differing.is_empty() { + let _ = writeln!(out, " differing payloads: {differing:?}"); + for id in &differing { + if let (Some(exp), Some(act)) = (expected.nodes.get(id), actual.nodes.get(id)) { + let _ = writeln!(out, " node {id}:"); + diff_document_node(&mut out, exp, act); + } + } + } + } + + if expected.scope_injections != actual.scope_injections { + let _ = writeln!(out, " scope_injections differ"); + } + + out +} + +/// Field-level diff between two runtime `DocumentNode`s with the same ID, so the compare-on-open log +/// names *which* field diverged rather than just the node ID. `original_location` is `#[serde(skip)]` +/// and recomputed at load, so it's a likely culprit for a payload mismatch that doesn't affect behavior. +fn diff_document_node(out: &mut String, expected: &graph_craft::document::DocumentNode, actual: &graph_craft::document::DocumentNode) { + use std::fmt::Write; + + if expected.inputs != actual.inputs { + let _ = writeln!(out, " inputs differ (len expected={} actual={})", expected.inputs.len(), actual.inputs.len()); + for (i, (e, a)) in expected.inputs.iter().zip(actual.inputs.iter()).enumerate() { + if e != a { + let _ = writeln!(out, " input[{i}]: expected={e:?}\n actual= {a:?}"); + } + } + } + if expected.call_argument != actual.call_argument { + let _ = writeln!(out, " call_argument: expected={:?} actual={:?}", expected.call_argument, actual.call_argument); + } + if expected.implementation != actual.implementation { + let _ = writeln!(out, " implementation: expected={:?} actual={:?}", expected.implementation, actual.implementation); + } + if expected.visible != actual.visible { + let _ = writeln!(out, " visible: expected={} actual={}", expected.visible, actual.visible); + } + if expected.skip_deduplication != actual.skip_deduplication { + let _ = writeln!(out, " skip_deduplication: expected={} actual={}", expected.skip_deduplication, actual.skip_deduplication); + } + if expected.context_features != actual.context_features { + let _ = writeln!(out, " context_features: expected={:?} actual={:?}", expected.context_features, actual.context_features); + } + if expected.original_location != actual.original_location { + let _ = writeln!(out, " original_location differs (recomputed at load, not stored):"); + let _ = writeln!(out, " expected={:?}\n actual= {:?}", expected.original_location, actual.original_location); + } +} + /// Create a network interface with a single export fn default_document_network_interface() -> NodeNetworkInterface { let mut network_interface = NodeNetworkInterface::default(); diff --git a/editor/src/messages/portfolio/document/mod.rs b/editor/src/messages/portfolio/document/mod.rs index 09e98cc995..8e21b913a6 100644 --- a/editor/src/messages/portfolio/document/mod.rs +++ b/editor/src/messages/portfolio/document/mod.rs @@ -1,5 +1,7 @@ mod document_message; mod document_message_handler; +#[cfg(test)] +mod storage_round_trip_tests; pub mod data_panel; pub mod graph_operation; @@ -12,5 +14,6 @@ pub mod utility_types; #[doc(inline)] pub use document_message::{DocumentMessage, DocumentMessageDiscriminant}; +pub(crate) use document_message_handler::diff_networks; #[doc(inline)] pub use document_message_handler::{DocumentMessageContext, DocumentMessageHandler}; diff --git a/editor/src/messages/portfolio/document/storage_round_trip_tests.rs b/editor/src/messages/portfolio/document/storage_round_trip_tests.rs new file mode 100644 index 0000000000..b2f9f8fee3 --- /dev/null +++ b/editor/src/messages/portfolio/document/storage_round_trip_tests.rs @@ -0,0 +1,783 @@ +//! End-to-end storage round-trip tests: drive real edits through the editor, push the document +//! through a fresh in-memory `Gdd` (stage → retire → persist), reopen from the same container, and +//! assert the reopened document matches. Exercises the full persistence pipeline (conversion, +//! MessagePack codecs, hot-op retirement, file layout, replay-on-open) that the debug-only +//! `verify_storage_round_trip` only checks in-process without an actual save/reopen. + +use document_container::AnyContainer; +use document_container::backends::memory::MemoryBackend; +use document_format::{GddV1, GddV1Layout}; +use graph_craft::application_io::resource::HashMapResourceStorage; +use graph_craft::document::{DocumentNodeImplementation, NodeId}; +use graph_storage::{NodeMetadataSource, PeerId}; + +use crate::messages::portfolio::document::document_message_handler::DocumentMessageHandler; +use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; +use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; +use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{DocumentSettings, StorageMetadataView, build_interface_from_storage}; +use crate::test_utils::test_prelude::*; +use graphene_std::vector::style::RenderMode; + +/// Result of round-tripping a document through a `Gdd`: the runtime interface rebuilt from the +/// reopened storage, the reopened registry, and the reopened per-peer view settings (`ui::doc::*`). +struct RoundTrip { + rebuilt: NodeNetworkInterface, + registry: graph_storage::Registry, + view_settings: std::collections::BTreeMap, +} + +/// Push `document`'s current runtime state through a fresh in-memory `Gdd` and reopen it. The commit +/// goes through `commit_from_runtime` (the real autosave path: stage hot ops → retire → persist); +/// the reopen reads the working copy back from the same container bytes, so the whole codec / file +/// / replay pipeline is exercised, not just the in-memory conversion. +async fn round_trip_through_gdd(document: &DocumentMessageHandler) -> RoundTrip { + let byte_store = HashMapResourceStorage::new(); + + let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into()) + .await + .expect("create_in"); + + let network = document.network_interface.document_network().clone(); + let view = StorageMetadataView::new(&document.network_interface); + gdd.commit_from_runtime(&network, &view, &document.resources.registry, &byte_store).expect("commit_from_runtime"); + + // Per-peer view settings persist in session.json, separate from the registry commit. + let view_settings = DocumentSettings { + document_ptz: &document.document_ptz, + render_mode: &document.render_mode, + overlays_visibility: &document.overlays_visibility_settings, + rulers_visible: document.rulers_visible, + snapping_state: &document.snapping_state, + collapsed: &document.collapsed, + } + .to_view_map(); + gdd.set_view_settings(view_settings).expect("set_view_settings"); + + let (working, layout) = gdd.into_storage(); + let reopened = GddV1::open_in(working, layout).await.expect("open_in"); + let declarations = reopened.declarations(&byte_store).await; + let registry = reopened.registry().clone(); + let (rebuilt_network, node_entries, network_entries) = registry.to_runtime_with_full_metadata(&declarations).expect("to_runtime_with_full_metadata"); + let rebuilt = build_interface_from_storage(rebuilt_network, node_entries, network_entries).expect("build_interface_from_storage"); + let view_settings = reopened.view_settings().clone(); + + RoundTrip { rebuilt, registry, view_settings } +} + +/// Every node addressable in `original` resolves identically through the round-tripped interface: +/// the compute graph itself, then per-node positions, layer flags, names, locked/pinned. +fn assert_documents_match(original: &NodeNetworkInterface, round_trip: &RoundTrip) { + let original_view = StorageMetadataView::new(original); + let rebuilt_view = StorageMetadataView::new(&round_trip.rebuilt); + + assert_eq!( + round_trip.rebuilt.document_network(), + original.document_network(), + "compute graph drifted through the storage round-trip" + ); + + for (network_path, local_id) in node_paths(original) { + let at = format!("node {local_id:?} in network {network_path:?}"); + assert_eq!(rebuilt_view.position(&network_path, local_id), original_view.position(&network_path, local_id), "position: {at}"); + assert_eq!(rebuilt_view.is_layer(&network_path, local_id), original_view.is_layer(&network_path, local_id), "is_layer: {at}"); + assert_eq!( + rebuilt_view.display_name(&network_path, local_id), + original_view.display_name(&network_path, local_id), + "display_name: {at}" + ); + assert_eq!(rebuilt_view.locked(&network_path, local_id), original_view.locked(&network_path, local_id), "locked: {at}"); + assert_eq!(rebuilt_view.pinned(&network_path, local_id), original_view.pinned(&network_path, local_id), "pinned: {at}"); + } +} + +/// Walk every node in every nested network, collecting `(network_path, local_id)` pairs. +fn node_paths(interface: &NodeNetworkInterface) -> Vec<(Vec, NodeId)> { + fn walk(interface: &NodeNetworkInterface, path: Vec, out: &mut Vec<(Vec, NodeId)>) { + let Some(network) = interface.nested_network(&path) else { return }; + for (&local_id, node) in &network.nodes { + out.push((path.clone(), local_id)); + if matches!(node.implementation, DocumentNodeImplementation::Network(_)) { + let mut child = path.clone(); + child.push(local_id); + walk(interface, child, out); + } + } + } + let mut out = Vec::new(); + walk(interface, Vec::new(), &mut out); + out +} + +#[tokio::test] +async fn round_trip_drawn_shapes() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.draw_rect(100., 200., 300., 400.).await; + editor.draw_ellipse(120., 220., 280., 380.).await; + editor.draw_polygon(50., 60., 250., 360.).await; + + let document = editor.active_document(); + let round_trip = round_trip_through_gdd(document).await; + assert_documents_match(&document.network_interface, &round_trip); +} + +#[tokio::test] +async fn round_trip_after_reorder() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.draw_rect(0., 0., 100., 100.).await; + editor.draw_rect(50., 50., 150., 150.).await; + editor.draw_rect(100., 100., 200., 200.).await; + + // Reorder: raise the bottom layer so the layer stack differs from creation order. + let bottom = editor.active_document().metadata().all_layers().next_back().expect("at least one layer"); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![bottom.to_node()] }).await; + editor.handle_message(DocumentMessage::SelectedLayersRaise).await; + + let document = editor.active_document(); + let round_trip = round_trip_through_gdd(document).await; + assert_documents_match(&document.network_interface, &round_trip); +} + +#[tokio::test] +async fn round_trip_grouped_into_nested_network() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.draw_rect(0., 0., 100., 100.).await; + editor.draw_ellipse(50., 50., 150., 150.).await; + + // Group both layers into a folder (a nested network), exercising flat-Registry addressing across + // depth and `NetworkId` round-tripping. + let layers: Vec<_> = editor.active_document().metadata().all_layers().map(|layer| layer.to_node()).collect(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: layers }).await; + editor + .handle_message(DocumentMessage::GroupSelectedLayers { + group_folder_type: GroupFolderType::Layer, + }) + .await; + + let document = editor.active_document(); + let round_trip = round_trip_through_gdd(document).await; + assert_documents_match(&document.network_interface, &round_trip); +} + +/// Reproduces the first-commit-after-open failure: after reopening a `.gdd` and rebuilding the +/// runtime, re-converting that runtime with `from_runtime` must reproduce the reopened registry. +/// This is exactly what `verify_storage_round_trip` does on the first autosave after opening a `.gdd`. +/// Uses a grouped document (nested network) because the bug is `from_runtime` assigning `NetworkId`s +/// by traversal order rather than reproducing the stored ones, which cascades into node-path hashes. +#[tokio::test] +async fn recommit_after_open_is_stable() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.draw_rect(0., 0., 100., 100.).await; + editor.draw_ellipse(50., 50., 150., 150.).await; + + let layers: Vec<_> = editor.active_document().metadata().all_layers().map(|layer| layer.to_node()).collect(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: layers }).await; + editor + .handle_message(DocumentMessage::GroupSelectedLayers { + group_folder_type: GroupFolderType::Layer, + }) + .await; + + let document = editor.active_document(); + let round_trip = round_trip_through_gdd(document).await; + + // Re-convert the rebuilt runtime and assert it reproduces the reopened registry. `round_trip_through_gdd` + // builds the `Gdd` with `PeerId(1)`, which the reopened registry inherits, so the re-conversion must + // use that same peer for deterministic node-ID derivation. (`ui::doc::*` settings live in session.json, + // not the registry, so they don't participate in this comparison.) + let rebuilt_network = round_trip.rebuilt.document_network().clone(); + let view = StorageMetadataView::new(&round_trip.rebuilt); + let reconverted = graph_storage::Registry::convert_from_runtime(&rebuilt_network, &view, &Default::default(), PeerId(1)).expect("re-convert from_runtime"); + + assert!( + round_trip.registry.value_equal(&reconverted.registry), + "re-converting the reopened runtime drifted from the reopened registry (network/node IDs not stable across to_runtime -> from_runtime)\n{}", + crate::messages::portfolio::document::document_message_handler::diff_registries(&round_trip.registry, &reconverted.registry) + ); +} + +/// Reproduces the live "edit after opening a .gdd fails to commit" bug: build a grouped document, +/// persist + reopen it as the editor does (registry -> runtime), then edit and commit through the +/// reopened document. The commit must not fail with "Target node does not exist". +#[tokio::test] +async fn edit_after_open_commits_cleanly() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.draw_rect(0., 0., 100., 100.).await; + editor.draw_ellipse(50., 50., 150., 150.).await; + + let layers: Vec<_> = editor.active_document().metadata().all_layers().map(|layer| layer.to_node()).collect(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: layers }).await; + editor + .handle_message(DocumentMessage::GroupSelectedLayers { + group_folder_type: GroupFolderType::Layer, + }) + .await; + + // Persist the document into a fresh Gdd and reopen it, then build a runtime document from the + // reopened registry — the editor's .gdd-open path. + let byte_store = HashMapResourceStorage::new(); + let source = editor.active_document(); + let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into()) + .await + .expect("create_in"); + let source_network = source.network_interface.document_network().clone(); + let source_view = StorageMetadataView::new(&source.network_interface); + gdd.commit_from_runtime(&source_network, &source_view, &source.resources.registry, &byte_store) + .expect("commit_from_runtime"); + + let (working, layout) = gdd.into_storage(); + let reopened = GddV1::open_in(working, layout).await.expect("open_in"); + let declarations = reopened.declarations(&byte_store).await; + let (rebuilt_network, node_entries, network_entries) = reopened.registry().to_runtime_with_full_metadata(&declarations).expect("to_runtime"); + let rebuilt = build_interface_from_storage(rebuilt_network, node_entries, network_entries).expect("build_interface_from_storage"); + + // Install the rebuilt interface + reopened storage and finalize like the editor's open path does. + { + let document = editor.active_document_mut(); + document.network_interface = rebuilt; + document.storage = Some(reopened); + document.finalize_storage_load(); + } + + // Mirror the live sequence via the real `commit_storage_snapshot` (which uses the full document- + // settings view + the document's own resource registry, and runs `verify_storage_round_trip` in + // debug — panicking on any drift). Initial commit after open, then a deletion, then commit again. + // Deletion (not addition) is the trigger: it emits a `RemoveNode` whose retire/reverse is where + // "Target node does not exist" surfaced live. + editor.active_document_mut().commit_storage_snapshot(&byte_store); + + let layer = editor.active_document().metadata().all_layers().next().expect("at least one layer"); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + editor.handle_message(DocumentMessage::DeleteSelectedLayers).await; + + // `commit_storage_snapshot` only logs commit failures, so assert on the underlying `commit_from_runtime` + // result directly to make the deletion commit a hard test failure rather than a silent log line. + let document = editor.active_document_mut(); + let network = document.network_interface.document_network().clone(); + let view = StorageMetadataView::new(&document.network_interface); + document + .storage + .as_mut() + .expect("storage mounted") + .commit_from_runtime(&network, &view, &Default::default(), &byte_store) + .expect("commit after deleting a layer post-open must not fail"); +} + +/// Drives the per-interaction undo/redo cursor: commit two interactions into a `Gdd`, then undo back to the +/// one-interaction state and redo forward, asserting the registry's node set matches at each cursor +/// position. Exercises `Gdd::commit_from_runtime` (inline interaction-end mark) + `Session::undo/redo`. +#[tokio::test] +async fn gdd_undo_redo_walks_interactions() { + let byte_store = HashMapResourceStorage::new(); + let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into()) + .await + .expect("create_in"); + + // Commit the active document's current runtime state as one interaction. + async fn commit_interaction(gdd: &mut GddV1, document: &DocumentMessageHandler, byte_store: &HashMapResourceStorage) { + let network = document.network_interface.document_network().clone(); + let view = StorageMetadataView::new(&document.network_interface); + gdd.commit_from_runtime(&network, &view, &document.resources.registry, byte_store).expect("commit_from_runtime"); + } + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + editor.draw_rect(0., 0., 100., 100.).await; + commit_interaction(&mut gdd, editor.active_document(), &byte_store).await; + let nodes_after_one: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect(); + + editor.draw_ellipse(50., 50., 150., 150.).await; + commit_interaction(&mut gdd, editor.active_document(), &byte_store).await; + let nodes_after_two: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect(); + + assert!(nodes_after_two.len() > nodes_after_one.len(), "second interaction should add nodes"); + assert!(gdd.can_undo(), "two committed interactions should be undoable"); + assert!(!gdd.can_redo()); + + // Undo the second interaction: registry returns to the one-interaction node set. + gdd.undo().expect("undo"); + let undone: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect(); + assert_eq!(undone, nodes_after_one, "undo should restore the post-first-interaction node set"); + assert!(gdd.can_redo(), "after undo, redo must be available"); + + // Redo: back to the two-interaction state. + gdd.redo().expect("redo"); + let redone: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect(); + assert_eq!(redone, nodes_after_two, "redo should restore the post-second-interaction node set"); + + // A new edit after undo clears the redo stack. + gdd.undo().expect("undo"); + editor.draw_rect(200., 200., 300., 300.).await; + commit_interaction(&mut gdd, editor.active_document(), &byte_store).await; + assert!(!gdd.can_redo(), "a new edit after undo must abandon the redo branch"); +} + +/// Reopen must restore the cursor *and* a working registry consistent with it. `Session::load` trusts +/// the persisted `registry.bin` to match the persisted `head`, so an undo that rewinds the working +/// registry has to re-snapshot it. Repro of the live "undo acts like redo after reload" bug: commit +/// three interactions, undo one (registry at two), reopen, and assert the reopened registry is the +/// two-interaction state (not the three-interaction state the last retirement wrote) and that undo from there +/// still walks back to the one-interaction node set. +#[tokio::test] +async fn reopen_after_undo_restores_consistent_registry() { + let byte_store = HashMapResourceStorage::new(); + let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into()) + .await + .expect("create_in"); + + async fn commit_interaction(gdd: &mut GddV1, document: &DocumentMessageHandler, byte_store: &HashMapResourceStorage) { + let network = document.network_interface.document_network().clone(); + let view = StorageMetadataView::new(&document.network_interface); + gdd.commit_from_runtime(&network, &view, &document.resources.registry, byte_store).expect("commit_from_runtime"); + } + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + editor.draw_rect(0., 0., 100., 100.).await; + commit_interaction(&mut gdd, editor.active_document(), &byte_store).await; + let nodes_one: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect(); + + editor.draw_ellipse(50., 50., 150., 150.).await; + commit_interaction(&mut gdd, editor.active_document(), &byte_store).await; + let nodes_two: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect(); + + editor.draw_rect(200., 200., 300., 300.).await; + commit_interaction(&mut gdd, editor.active_document(), &byte_store).await; + let nodes_three: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect(); + + // Undo the third interaction: in-memory registry is now the two-interaction state and head moved back, but the + // last retirement wrote the three-interaction registry to disk. The undo must re-snapshot it. + gdd.undo().expect("undo"); + assert_eq!(gdd.registry().node_instances.keys().copied().collect::>(), nodes_two); + + // Reopen from the same container: the cursor and the working registry are read back from disk. + let (working, layout) = gdd.into_storage(); + let mut reopened = GddV1::open_in(working, layout).await.expect("open_in"); + + assert_eq!( + reopened.registry().node_instances.keys().copied().collect::>(), + nodes_two, + "reopened registry must match the undone (two-interaction) state, not the three-interaction state on disk" + ); + assert!(reopened.can_undo(), "head is still above the first interaction, so undo is available after reopen"); + assert!(reopened.can_redo(), "the undone third interaction is still redoable after reopen"); + + // Redo after reopen: the persisted redo stack must still reach the three-interaction state (redo across + // reopen must not be capped at the open point). + reopened.redo().expect("redo after reopen"); + assert_eq!( + reopened.registry().node_instances.keys().copied().collect::>(), + nodes_three, + "redo after reopen must restore the third interaction, not stop at the reopened (two-interaction) state" + ); + + // Undo twice from there: back through the third interaction to the one-interaction state, not a redo. + reopened.undo().expect("first undo after reopen redo"); + reopened.undo().expect("second undo after reopen redo"); + assert_eq!( + reopened.registry().node_instances.keys().copied().collect::>(), + nodes_one, + "undo after reopen must rewind toward the one-interaction state, not act like a redo" + ); +} + +/// Drives the *live* shadow path: a real edit through the message pipeline (which fires +/// `CommitTransaction`, committing one interaction into the `Gdd`), then a real `DocumentMessage::Undo` +/// (which runs `shadow_storage_undo_redo` + the debug cursor-vs-runtime check). A clean run asserts +/// the `Gdd` undo cursor reproduces the legacy-restored interface. +#[tokio::test] +async fn live_undo_shadows_storage_cursor() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.draw_rect(0., 0., 100., 100.).await; + + let byte_store = mount_in_memory_storage(&mut editor).await; + // Capture the loaded state as the base interaction, then make a real edit (fires CommitTransaction). + editor.active_document_mut().commit_storage_snapshot(&byte_store); + let before_edit = editor.active_document().network_interface.document_network().clone(); + editor.draw_ellipse(50., 50., 150., 150.).await; + assert_ne!(&before_edit, editor.active_document().network_interface.document_network(), "edit should change the network"); + + // Real undo: the shadow drives gdd.undo() and (debug) asserts the cursor matches the restored interface. + editor.handle_message(DocumentMessage::Undo).await; + assert_eq!(editor.active_document().network_interface.document_network(), &before_edit, "undo should restore the pre-edit network"); + + // Real redo: shadow drives gdd.redo() + checks again. + editor.handle_message(DocumentMessage::Redo).await; +} + +#[tokio::test] +async fn round_trip_document_settings() { + use graph_storage::attr::session::doc; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.draw_rect(0., 0., 100., 100.).await; + + // Set distinctive, non-default document-level settings so the round-trip proves real values move. + { + let document = editor.active_document_mut(); + document.render_mode = RenderMode::Outline; + document.rulers_visible = false; + document.snapping_state.snapping_enabled = !document.snapping_state.snapping_enabled; + } + + let document = editor.active_document(); + let round_trip = round_trip_through_gdd(document).await; + assert_documents_match(&document.network_interface, &round_trip); + + // The `ui::doc::*` view settings survived the persist/reopen cycle (in session.json, not the registry). + let settings = &round_trip.view_settings; + assert_eq!(settings.get(doc::RENDER_MODE), Some(&serde_json::to_value(RenderMode::Outline).unwrap()), "render_mode"); + assert_eq!(settings.get(doc::RULERS_VISIBLE), Some(&serde_json::to_value(false).unwrap()), "rulers_visible"); + assert_eq!(settings.get(doc::SNAPPING), Some(&serde_json::to_value(&document.snapping_state).unwrap()), "snapping_state"); +} + +/// The exported `.gdd` archive must carry `session.json` so view settings (notably the document PTZ) +/// survive a save/open on another machine. `session.json` is working-copy-only otherwise, so without it +/// the archive opens with empty `view_settings` and the viewport resets to default. Exports to an archive +/// and reopens via `open_from_archive`, asserting the PTZ round-trips. +#[tokio::test] +async fn gdd_archive_round_trips_view_settings() { + use graph_storage::attr::session::doc; + + let byte_store = HashMapResourceStorage::new(); + let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into()) + .await + .expect("create_in"); + + // Stage a distinctive PTZ into the working copy's `view_settings`, as `commit_storage_snapshot` does. + let mut ptz = crate::messages::portfolio::document::utility_types::misc::PTZ::default(); + ptz.pan = glam::DVec2::new(-960., -540.); + ptz.set_zoom(0.459); + let view_settings = std::collections::BTreeMap::from([(doc::PTZ.to_string(), serde_json::to_value(&ptz).unwrap())]); + gdd.set_view_settings(view_settings).expect("set_view_settings"); + + // Export to an in-memory archive, then reopen it from bytes into a fresh container. + let archive = gdd + .export_to_bytes(document_format::ExportFormat::Zip, document_format::ExportOptions::default(), &byte_store, None) + .await + .expect("export_to_bytes"); + + let reopened = GddV1::open_from_archive(&archive, AnyContainer::Memory(MemoryBackend::new()), GddV1Layout) + .await + .expect("open_from_archive"); + + assert_eq!( + reopened.view_settings().get(doc::PTZ), + Some(&serde_json::to_value(&ptz).unwrap()), + "the document PTZ must survive a .gdd archive export/open (session.json travels in the archive)" + ); +} + +/// Per-network node-graph navigation is per-peer view state: it must round-trip through `session.json` +/// (keyed by stable `NetworkId`), not the registry/history. Sets a distinctive node-graph PTZ on the root +/// network, persists + reopens through a `Gdd`, and asserts the nav is restored on the rebuilt interface +/// while being absent from the stored registry's attributes. +#[tokio::test] +async fn per_network_navigation_round_trips_via_session_not_registry() { + use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{apply_network_view_settings, collect_network_view_settings, network_ids_from_entries}; + use graph_storage::attr::session::network; + + let byte_store = HashMapResourceStorage::new(); + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.draw_rect(0., 0., 100., 100.).await; + + // Set a distinctive node-graph PTZ on the root network (path `[]`). + let ptz = editor.active_document_mut().network_interface.node_graph_ptz_mut(&[]).expect("root network ptz"); + ptz.pan = glam::DVec2::new(-321., 654.); + ptz.set_zoom(0.75); + let expected_pan = editor.active_document().network_interface.node_graph_ptz(&[]).unwrap().pan; + + // Commit the document into a fresh `Gdd`, collecting the per-network view state the editor persists. + let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into()) + .await + .expect("create_in"); + let document = editor.active_document(); + let network = document.network_interface.document_network().clone(); + let view = StorageMetadataView::new(&document.network_interface); + gdd.commit_from_runtime(&network, &view, &document.resources.registry, &byte_store).expect("commit_from_runtime"); + + let network_ids = gdd.network_ids(&network, &view).expect("network_ids"); + let network_view_settings = collect_network_view_settings(&document.network_interface, &network_ids); + assert!(!network_view_settings.is_empty(), "the root network's non-default nav should be collected"); + gdd.set_network_view_settings(network_view_settings).expect("set_network_view_settings"); + + // The registry must NOT carry the node-graph nav (it's per-peer, not document content). + let root_network = gdd.registry().networks.get(&graph_storage::ROOT_NETWORK).expect("root network in registry"); + assert!(root_network.attributes.get(network::NAV_PTZ).is_none(), "node-graph nav must not be stored in the registry attributes"); + + // Reopen, rebuild the interface, and apply the persisted per-network view state. + let (working, layout) = gdd.into_storage(); + let reopened = GddV1::open_in(working, layout).await.expect("open_in"); + let declarations = reopened.declarations(&byte_store).await; + let (rebuilt_network, node_entries, network_entries) = reopened.registry().to_runtime_with_full_metadata(&declarations).expect("to_runtime"); + let mut rebuilt = build_interface_from_storage(rebuilt_network, node_entries, network_entries.clone()).expect("build_interface_from_storage"); + + let reopened_ids = network_ids_from_entries(&network_entries); + apply_network_view_settings(&mut rebuilt, &reopened_ids, reopened.network_view_settings()); + + assert_eq!( + rebuilt.node_graph_ptz(&[]).map(|ptz| ptz.pan), + Some(expected_pan), + "the node-graph PTZ must be restored from session.json on reopen" + ); +} + +/// Mirror the real "new document, draw a rect, press undo" flow: mount storage and capture the +/// new-document base as the mount-time snapshot (as `DocumentStorageMounted` does), then draw a rect +/// (one `CommitTransaction` interaction) and undo. The shadow must reproduce the legacy-restored interface. +#[tokio::test] +async fn live_undo_new_document_draw_rect() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let byte_store = mount_in_memory_storage(&mut editor).await; + // Mount-time snapshot: capture the new-document graph as the base interaction. + editor.active_document_mut().commit_storage_snapshot(&byte_store); + + let before_rect = editor.active_document().network_interface.document_network().clone(); + editor.draw_rect(0., 0., 100., 100.).await; + assert_ne!(&before_rect, editor.active_document().network_interface.document_network(), "drawing a rect should change the network"); + + editor.handle_message(DocumentMessage::Undo).await; + assert_eq!(editor.active_document().network_interface.document_network(), &before_rect, "undo should restore the pre-rect network"); +} + +/// Pasting an image is one user action and must be one undo step. The paste handler emits a single +/// `AddTransaction` bracketing the layer add, name set, reparent, and transform, so a single undo has +/// to remove the whole layer. When the name set opens its own nested transaction (the historical +/// wart), the first undo only reverts the name and leaves the layer behind, so this asserts the layer +/// count returns to its pre-paste value after exactly one undo. Matches the `Gdd`'s one-interaction +/// coalescing, which is the behavior the undo shadow validates against. +#[tokio::test] +async fn paste_image_with_name_is_one_undo_step() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let layers_before = editor.active_document().metadata().all_layers().count(); + + // Paste with a name so the handler emits the `SetDisplayName` sub-step that historically opened its + // own transaction. `create_raster_image` passes `name: None` and so wouldn't exercise this path. + let image = Image::new(2, 2, Color::WHITE); + editor + .handle_message(PortfolioMessage::PasteImage { + name: Some("pasted".into()), + image, + mouse: None, + parent_and_insert_index: None, + }) + .await; + assert_eq!( + editor.active_document().metadata().all_layers().count(), + layers_before + 1, + "pasting an image should add exactly one layer" + ); + + editor.handle_message(DocumentMessage::Undo).await; + assert_eq!( + editor.active_document().metadata().all_layers().count(), + layers_before, + "one undo should remove the whole pasted layer, not just its name" + ); +} + +/// Pasting an image registers a resource; undoing the paste reverts the interaction's `AddResource` in the +/// `Gdd` cursor while the runtime keeps the resource alive for legacy redo. So the cursor legitimately +/// holds fewer resources than a fresh `from_runtime`, and the shadow must tolerate that. This drives +/// the relaxed comparison: every resource the current network references must be present in the cursor, +/// and any extra the runtime carries must be history-only (not referenced by the restored network). +#[tokio::test] +async fn undo_image_paste_resources_subset_of_runtime() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let byte_store = mount_in_memory_storage(&mut editor).await; + editor.active_document_mut().commit_storage_snapshot(&byte_store); + + let image = Image::new(2, 2, Color::WHITE); + editor + .handle_message(PortfolioMessage::PasteImage { + name: Some("pasted".into()), + image, + mouse: None, + parent_and_insert_index: None, + }) + .await; + + editor.handle_message(DocumentMessage::Undo).await; + + let document = editor.active_document(); + let storage = document.storage.as_ref().expect("storage mounted"); + let stored: std::collections::BTreeSet<_> = storage.registry().resources.keys().copied().collect(); + + // Every resource the restored network still references must be in the cursor. + let current: std::collections::BTreeSet<_> = document.used_resources(false).iter().copied().collect(); + assert!( + current.is_subset(&stored), + "cursor is missing a resource the restored network references: current={current:?} stored={stored:?}" + ); + + // The runtime, which retains the undone paste's resource for redo, may carry strictly more. + let runtime: std::collections::BTreeSet<_> = document.resources.registry.ids().collect(); + assert!(stored.is_subset(&runtime), "cursor holds a resource the runtime dropped: stored={stored:?} runtime={runtime:?}"); +} + +/// Two distinct edits (draw an ellipse, then paste an image) on an *opened* document are two undo +/// steps. Undoing twice must step the `Gdd` cursor back two interactions in lockstep with the legacy +/// snapshot path. The document is opened with existing content (a real demo artwork), so its non-empty +/// base is the loaded state, which is *not* a legacy undo step. The mount-time base must therefore not +/// become an undoable `Gdd` interaction, or the cursor gains one more interaction than the legacy path has undo +/// steps and lags by a interaction once undo reaches back toward the base. Reproduces the live "open, draw, +/// paste image, undo twice" divergence. +#[tokio::test] +async fn undo_twice_steps_cursor_two_interactions() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let byte_store = mount_in_memory_storage(&mut editor).await; + editor.active_document_mut().commit_storage_snapshot(&byte_store); + let base = editor.active_document().network_interface.document_network().clone(); + + editor.draw_rect(0., 0., 100., 100.).await; + let after_rect = editor.active_document().network_interface.document_network().clone(); + + let image = Image::new(2, 2, Color::WHITE); + editor + .handle_message(PortfolioMessage::PasteImage { + name: Some("pasted".into()), + image, + mouse: None, + parent_and_insert_index: None, + }) + .await; + + // First undo: removes the paste, back to the post-rectangle network. Cursor and legacy must agree. + editor.handle_message(DocumentMessage::Undo).await; + assert_eq!( + editor.active_document().network_interface.document_network(), + &after_rect, + "first undo should restore the post-rectangle network (paste removed)" + ); + assert_cursor_matches_runtime(editor.active_document(), "after first undo"); + + // Re-stage after the undo, reproducing the live editor's post-undo graph re-evaluation. The pasted + // image's resource is still in the runtime cache (kept alive for redo) though its node is gone, so a + // naive snapshot would re-detect it as a new `AddResource` and retire it as a phantom interaction, which + // would knock the cursor out of lockstep on the next undo. Scoping the snapshot to network-referenced + // resources must keep this a no-op. + editor.active_document_mut().commit_storage_snapshot(&byte_store); + + // Second undo: removes the rectangle, back to the loaded base. The cursor must step a second interaction + // and land on the base, not one interaction short (paste resource resurfaced as a phantom interaction). + editor.handle_message(DocumentMessage::Undo).await; + assert_eq!( + editor.active_document().network_interface.document_network(), + &base, + "second undo should restore the loaded base network" + ); + assert_cursor_matches_runtime(editor.active_document(), "after second undo"); + + // Back at the loaded base, neither path should be able to undo further: the load is not an undo step. + // If the `Gdd` retired the mount base as its own interaction, the cursor can still undo here while legacy + // cannot, which is the off-by-one that makes the cursor lag the legacy path by a interaction. + let storage = editor.active_document().storage.as_ref().expect("storage mounted"); + assert!(!storage.can_undo(), "cursor must not undo past the loaded base (the load is not an undo step)"); +} + +/// Assert the `Gdd` cursor's stored registry matches a fresh `from_runtime` of the current interface, +/// mirroring the live shadow check (`verify_storage_cursor_matches_runtime`): node set, network set, +/// and per-network export wiring must agree. The runtime keeps resources alive across undo (for legacy +/// redo) while the cursor reverts the interaction's `AddResource`, so the runtime's resource set is allowed +/// to be a superset of the cursor's, but a resource the current network references must be present. +fn assert_cursor_matches_runtime(document: &DocumentMessageHandler, at: &str) { + let storage = document.storage.as_ref().expect("storage mounted"); + let peer = storage.session().peer(); + + let network = document.network_interface.document_network().clone(); + let view = StorageMetadataView::new(&document.network_interface); + let target = graph_storage::Registry::convert_from_runtime(&network, &view, &document.resources.registry, peer).expect("from_runtime"); + + let stored = storage.registry(); + + let stored_nodes: std::collections::BTreeSet<_> = stored.node_instances.keys().copied().collect(); + let target_nodes: std::collections::BTreeSet<_> = target.registry.node_instances.keys().copied().collect(); + assert_eq!(stored_nodes, target_nodes, "cursor node set diverged from the restored interface {at}"); + + let stored_networks: std::collections::BTreeSet<_> = stored.networks.keys().copied().collect(); + let target_networks: std::collections::BTreeSet<_> = target.registry.networks.keys().copied().collect(); + assert_eq!(stored_networks, target_networks, "cursor network set diverged from the restored interface {at}"); + + // Compare export wiring per network: the paste rewires the document export, and undo must revert it. + for (id, stored_network) in &stored.networks { + let target_network = target.registry.networks.get(id).expect("network present in both (checked above)"); + let stored_targets: Vec<_> = stored_network.exports.iter().map(|export| export.target.clone()).collect(); + let target_targets: Vec<_> = target_network.exports.iter().map(|export| export.target.clone()).collect(); + assert_eq!(stored_targets, target_targets, "cursor export wiring diverged for network {id} {at}"); + } +} + +/// Mount a fresh in-memory `Gdd` onto the active document so `commit_storage_snapshot` (the real +/// autosave path) runs against it. Returns the byte store the document's resources resolve through. +async fn mount_in_memory_storage(editor: &mut EditorTestUtils) -> HashMapResourceStorage { + let gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0x5EED, "test".into(), "test".into()) + .await + .expect("create_in"); + editor.active_document_mut().storage = Some(gdd); + HashMapResourceStorage::new() +} + +/// Open a real demo artwork, mount storage, edit it, and trigger autosave. The autosave runs +/// `verify_storage_round_trip`, which panics (in debug, which tests are) on any conversion or +/// round-trip drift — so a clean run is itself the assertion that the whole pipeline holds on real, +/// non-synthetic document data. Also checks that committing edits grows the retired-delta history +/// and that the editor's undo reverts the modification. +#[tokio::test] +async fn demo_artwork_edit_autosaves_and_round_trips() { + let mut editor = EditorTestUtils::create(); + + // Open a real demo artwork through the normal open path and let it render. + let content = std::fs::read_to_string("../demo-artwork/changing-seasons.graphite").expect("read demo artwork"); + editor + .handle_message(PortfolioMessage::OpenFile { + path: "changing-seasons.graphite".into(), + content: content.bytes().collect(), + }) + .await; + + let byte_store = mount_in_memory_storage(&mut editor).await; + + // First autosave: captures the loaded document. `verify_storage_round_trip` panics on drift. + editor.active_document_mut().commit_storage_snapshot(&byte_store); + let history_after_open = editor.active_document().storage.as_ref().unwrap().session().history().count(); + + // Snapshot the network, then make a real modification. + let before_edit = editor.active_document().network_interface.document_network().clone(); + editor.draw_rect(64., 64., 192., 192.).await; + let after_edit = editor.active_document().network_interface.document_network().clone(); + assert_ne!(before_edit, after_edit, "drawing a rectangle should change the document network"); + + // Second autosave: again verifies the round-trip, and the edit must produce new retired history. + editor.active_document_mut().commit_storage_snapshot(&byte_store); + let history_after_edit = editor.active_document().storage.as_ref().unwrap().session().history().count(); + assert!( + history_after_edit > history_after_open, + "committing an edit should append retired deltas: {history_after_open} -> {history_after_edit}" + ); + + // The editor's undo stack reverts the modification on a real document. + editor.handle_message(DocumentMessage::Undo).await; + let after_undo = editor.active_document().network_interface.document_network().clone(); + assert_eq!(after_undo, before_edit, "undo should restore the pre-edit network"); + + // Autosaving the undone state still round-trips cleanly (no drift panic). + editor.active_document_mut().commit_storage_snapshot(&byte_store); +} diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 0ac69d677a..a1eb839dfc 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -1,6 +1,7 @@ mod deserialization; mod memo_network; mod resolved_types; +pub mod storage_metadata; use super::document_metadata::{DocumentMetadata, LayerNodeIdentifier, NodeRelations}; use super::misc::PTZ; @@ -3435,6 +3436,37 @@ impl NodeNetworkInterface { } } + /// Copy every piece of transient view and selection state from `other_interface` onto `self`, for each + /// network at every nesting level: navigation metadata (pan/zoom), selection undo/redo history, and the + /// document-to-viewport canvas camera. Used when an interface is rebuilt from storage (the `Gdd` undo/redo + /// cursor) and must keep the user's current view and selection rather than reset them, since the registry + /// models document content only. `resolved_types` is a separate runtime cache the caller handles. + pub fn copy_all_transient_view_state(&mut self, other_interface: &NodeNetworkInterface) { + let mut stack = vec![vec![]]; + while let Some(path) = stack.pop() { + let Some(self_network_metadata) = self.network_metadata_mut(&path) else { + continue; + }; + + if let Some(other_network_metadata) = other_interface.network_metadata(&path) { + let self_persistent = &mut self_network_metadata.persistent_metadata; + let other_persistent = &other_network_metadata.persistent_metadata; + self_persistent.navigation_metadata = other_persistent.navigation_metadata.clone(); + self_persistent.previewing = other_persistent.previewing; + self_persistent.selection_undo_history = other_persistent.selection_undo_history.clone(); + self_persistent.selection_redo_history = other_persistent.selection_redo_history.clone(); + } + + stack.extend(self_network_metadata.persistent_metadata.node_metadata.keys().map(|node_id| { + let mut current_path: Vec = path.clone(); + current_path.push(*node_id); + current_path + })); + } + + self.document_metadata.document_to_viewport = other_interface.document_metadata.document_to_viewport; + } + pub fn set_transform(&mut self, transform: DAffine2, network_path: &[NodeId]) { let Some(network_metadata) = self.network_metadata_mut(network_path) else { log::error!("Could not get nested network in set_transform"); @@ -6708,6 +6740,9 @@ impl NodePersistentMetadata { pub fn new(position: NodePosition) -> Self { Self { position } } + pub fn position(&self) -> &NodePosition { + &self.position + } } /// A layer can either be position as Absolute or in a Stack diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs new file mode 100644 index 0000000000..4a714b1438 --- /dev/null +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs @@ -0,0 +1,721 @@ +//! Bridge between `NodeNetworkInterface` and `graph-storage`'s `NodeMetadataSource` trait, plus +//! an integration round-trip test against a demo `.graphite` document. +//! +//! The trait impl lives on the [`StorageMetadataView`] wrapper (not on `NodeNetworkInterface` +//! directly) because several trait method names collide with inherent methods, and Rust silently +//! resolves bare calls to the inherent ones. + +use std::collections::{BTreeMap, HashMap}; + +use glam::IVec2; +use graph_craft::document::{DocumentNodeImplementation, NodeId, NodeNetwork}; +use graph_storage::attr::session; +use graph_storage::{InputMetadataEntry, NetworkMetadataEntry, NodeMetadataEntry, NodeMetadataSource, Position}; +use graphene_std::vector::style::RenderMode; + +use super::memo_network::MemoNetwork; +use super::{ + DocumentNodePersistentMetadata, DocumentNodeTransientMetadata, InputMetadata, InputPersistentMetadata, LayerPosition, NodeNetworkInterface, NodeNetworkMetadata, NodePersistentMetadata, + NodePosition, NodeTypePersistentMetadata, PTZ, Previewing, +}; +use crate::messages::portfolio::document::overlays::utility_types::OverlaysVisibilitySettings; +use crate::messages::portfolio::document::utility_types::misc::SnappingState; +use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers; + +/// Fired when the storage entry list disagrees with the converted `NodeNetwork`. +#[derive(Debug, thiserror::Error)] +pub enum InterfaceRebuildError { + #[error("input_metadata length {got} does not match inputs length {expected} for node {node:?} in network {network_path:?}")] + InputMetadataLengthMismatch { node: NodeId, network_path: Vec, expected: usize, got: usize }, +} + +/// Per-peer view settings persisted in `session.json` under `ui::doc::*` (viewport view, render mode, +/// overlay/ruler visibility, snapping, collapsed layers). Not part of the registry/CRDT/history; see +/// [`DocumentSettings::to_view_map`]. +pub struct DocumentSettings<'a> { + pub document_ptz: &'a PTZ, + pub render_mode: &'a RenderMode, + pub overlays_visibility: &'a OverlaysVisibilitySettings, + pub rulers_visible: bool, + pub snapping_state: &'a SnappingState, + pub collapsed: &'a CollapsedLayers, +} + +/// Adapts a `&NodeNetworkInterface` to `graph-storage`'s `NodeMetadataSource` (node/network metadata +/// only; document-level view settings live in `session.json`, not the registry). +pub struct StorageMetadataView<'a> { + interface: &'a NodeNetworkInterface, +} + +impl<'a> StorageMetadataView<'a> { + pub fn new(interface: &'a NodeNetworkInterface) -> Self { + Self { interface } + } +} + +impl StorageMetadataView<'_> { + fn persistent(&self, network_path: &[NodeId], local_id: NodeId) -> Option<&DocumentNodePersistentMetadata> { + Some(&self.interface.node_metadata(&local_id, network_path)?.persistent_metadata) + } + + fn input_persistent(&self, network_path: &[NodeId], local_id: NodeId, input_index: usize) -> Option<&InputPersistentMetadata> { + Some(&self.persistent(network_path, local_id)?.input_metadata.get(input_index)?.persistent_metadata) + } +} + +impl NodeMetadataSource for StorageMetadataView<'_> { + fn position(&self, network_path: &[NodeId], local_id: NodeId) -> Option { + match &self.persistent(network_path, local_id)?.node_type_metadata { + NodeTypePersistentMetadata::Layer(layer) => match layer.position { + LayerPosition::Absolute(v) => Some(Position::Absolute([v.x, v.y])), + LayerPosition::Stack(offset) => Some(Position::Stack(offset)), + }, + NodeTypePersistentMetadata::Node(node) => match *node.position() { + NodePosition::Absolute(v) => Some(Position::Absolute([v.x, v.y])), + NodePosition::Chain => Some(Position::Chain), + }, + } + } + + fn is_layer(&self, network_path: &[NodeId], local_id: NodeId) -> bool { + self.persistent(network_path, local_id) + .is_some_and(|p| matches!(p.node_type_metadata, NodeTypePersistentMetadata::Layer(_))) + } + + fn display_name(&self, network_path: &[NodeId], local_id: NodeId) -> Option<&str> { + Some(self.persistent(network_path, local_id)?.display_name.as_str()) + } + + fn locked(&self, network_path: &[NodeId], local_id: NodeId) -> bool { + self.persistent(network_path, local_id).is_some_and(|p| p.locked) + } + + fn pinned(&self, network_path: &[NodeId], local_id: NodeId) -> bool { + self.persistent(network_path, local_id).is_some_and(|p| p.pinned) + } + + fn input_name(&self, network_path: &[NodeId], local_id: NodeId, input_index: usize) -> Option<&str> { + Some(self.input_persistent(network_path, local_id, input_index)?.input_name.as_str()) + } + + fn input_description(&self, network_path: &[NodeId], local_id: NodeId, input_index: usize) -> Option<&str> { + Some(self.input_persistent(network_path, local_id, input_index)?.input_description.as_str()) + } + + fn widget_override(&self, network_path: &[NodeId], local_id: NodeId, input_index: usize) -> Option<&str> { + self.input_persistent(network_path, local_id, input_index)?.widget_override.as_deref() + } + + fn input_data(&self, network_path: &[NodeId], local_id: NodeId, input_index: usize) -> HashMap { + self.input_persistent(network_path, local_id, input_index).map(|p| p.input_data.clone()).unwrap_or_default() + } + + fn output_names(&self, network_path: &[NodeId], local_id: NodeId) -> Vec { + self.persistent(network_path, local_id).map(|p| p.output_names.clone()).unwrap_or_default() + } + + fn reference(&self, network_path: &[NodeId]) -> Option<&str> { + let network_metadata = self.interface.network_metadata.nested_metadata(network_path)?; + network_metadata.persistent_metadata.reference.as_deref() + } +} + +impl DocumentSettings<'_> { + /// Serialize the per-peer view settings into the `ui::doc::*`-keyed map persisted in `session.json` + /// (via `Gdd::set_view_settings`). A field that fails to serialize is skipped, not fatal. + pub fn to_view_map(&self) -> BTreeMap { + let entries = [ + (session::doc::PTZ, serde_json::to_value(self.document_ptz)), + (session::doc::RENDER_MODE, serde_json::to_value(self.render_mode)), + (session::doc::OVERLAYS, serde_json::to_value(self.overlays_visibility)), + (session::doc::RULERS_VISIBLE, serde_json::to_value(self.rulers_visible)), + (session::doc::SNAPPING, serde_json::to_value(self.snapping_state)), + (session::doc::COLLAPSED, serde_json::to_value(self.collapsed)), + ]; + + entries + .into_iter() + .filter_map(|(key, value)| match value { + Ok(value) => Some((key.to_string(), value)), + Err(error) => { + log::error!("Failed to serialize document setting {key}: {error}"); + None + } + }) + .collect() + } +} + +/// Inverse of the position extraction in `NodeMetadataSource::position`. +/// `(Stack, !is_layer)` and `(Chain, is_layer)` shouldn't arise from a faithful round-trip; they fall back to a default of the matching variant. +pub fn position_to_runtime(position: Position, is_layer: bool) -> NodeTypePersistentMetadata { + match (position, is_layer) { + (Position::Absolute([x, y]), true) => NodeTypePersistentMetadata::layer(IVec2::new(x, y)), + (Position::Absolute([x, y]), false) => NodeTypePersistentMetadata::node(IVec2::new(x, y)), + (Position::Stack(offset), _) => { + let mut metadata = NodeTypePersistentMetadata::layer(IVec2::ZERO); + if let NodeTypePersistentMetadata::Layer(layer) = &mut metadata { + layer.position = LayerPosition::Stack(offset); + } + metadata + } + (Position::Chain, _) => NodeTypePersistentMetadata::Node(NodePersistentMetadata::new(NodePosition::Chain)), + } +} + +/// Builds a `NodeNetworkInterface` from a `NodeNetwork` plus the metadata vecs `Registry::to_runtime_with_full_metadata` emits. +/// +/// Sets private fields directly (rather than via public setters) because we're constructing a fresh self-consistent snapshot; +/// the setters' transient-cache invalidation isn't needed. +pub fn build_interface_from_storage(network: NodeNetwork, node_entries: Vec, network_entries: Vec) -> Result { + let mut network_metadata = NodeNetworkMetadata::default(); + seed_metadata_tree(&network, &mut network_metadata); + apply_entries_into_tree(&network, &mut network_metadata, node_entries)?; + apply_network_entries_into_tree(&mut network_metadata, network_entries); + + let mut interface = NodeNetworkInterface::default(); + interface.network = MemoNetwork::new(network); + interface.network_metadata = network_metadata; + Ok(interface) +} + +/// Build the runtime-`network_path` -> stable-`NetworkId` map from the `NetworkMetadataEntry`s that +/// `to_runtime_with_full_metadata` emits, so the open path can apply per-network view settings. +pub fn network_ids_from_entries(network_entries: &[NetworkMetadataEntry]) -> HashMap, graph_storage::NetworkId> { + network_entries.iter().map(|entry| (entry.network_path.clone(), entry.network_id)).collect() +} + +/// Collect the per-network, per-peer view state (node-graph nav + previewing) from `interface` into a +/// map keyed by the stable storage [`NetworkId`](graph_storage::NetworkId). `network_ids` maps each +/// runtime `network_path` to its id (from the `from_runtime`/`to_runtime` conversion). This is what the +/// editor persists into `session.json` so it stays out of the CRDT/history. Networks at their default +/// nav with no preview produce no entry. +pub fn collect_network_view_settings( + interface: &NodeNetworkInterface, + network_ids: &HashMap, graph_storage::NetworkId>, +) -> BTreeMap> { + let mut out = BTreeMap::new(); + + for (network_path, &network_id) in network_ids { + let Some(network_metadata) = interface.network_metadata.nested_metadata(network_path) else { + continue; + }; + let navigation = &network_metadata.persistent_metadata.navigation_metadata; + + let mut settings = BTreeMap::new(); + if let Ok(value) = serde_json::to_value(navigation.node_graph_ptz) { + settings.insert(session::network::NAV_PTZ.to_string(), value); + } + if let Ok(value) = serde_json::to_value(navigation.node_graph_to_viewport) { + settings.insert(session::network::NAV_TRANSFORM.to_string(), value); + } + if let Ok(value) = serde_json::to_value(navigation.node_graph_width) { + settings.insert(session::network::NAV_WIDTH.to_string(), value); + } + + // Skip the inert `Previewing::No` default so a network that has never been previewed stays empty. + if !matches!(network_metadata.persistent_metadata.previewing, Previewing::No) + && let Ok(value) = serde_json::to_value(&network_metadata.persistent_metadata.previewing) + { + settings.insert(session::network::PREVIEWING.to_string(), value); + } + + if !settings.is_empty() { + out.insert(network_id, settings); + } + } + + out +} + +/// Apply persisted per-network view state (node-graph nav + previewing) from `session.json` onto +/// `interface`. Inverse of [`collect_network_view_settings`]: `network_ids` resolves each runtime +/// `network_path` to its [`NetworkId`](graph_storage::NetworkId), and the matching inner map is decoded +/// back onto the network's navigation/previewing metadata. +pub fn apply_network_view_settings( + interface: &mut NodeNetworkInterface, + network_ids: &HashMap, graph_storage::NetworkId>, + network_view_settings: &BTreeMap>, +) { + for (network_path, network_id) in network_ids { + let Some(settings) = network_view_settings.get(network_id) else { continue }; + let Some(network_metadata) = interface.network_metadata.nested_metadata_mut(network_path) else { + continue; + }; + let persistent = &mut network_metadata.persistent_metadata; + + if let Some(value) = settings.get(session::network::NAV_PTZ) + && let Ok(ptz) = serde_json::from_value::(value.clone()) + { + persistent.navigation_metadata.node_graph_ptz = ptz; + } + if let Some(value) = settings.get(session::network::NAV_TRANSFORM) + && let Ok(transform) = serde_json::from_value(value.clone()) + { + persistent.navigation_metadata.node_graph_to_viewport = transform; + } + if let Some(value) = settings.get(session::network::NAV_WIDTH) + && let Ok(width) = serde_json::from_value(value.clone()) + { + persistent.navigation_metadata.node_graph_width = width; + } + if let Some(value) = settings.get(session::network::PREVIEWING) + && let Ok(previewing) = serde_json::from_value::(value.clone()) + { + persistent.previewing = previewing; + } + } +} + +/// Entries whose `network_path` doesn't resolve are skipped with a warning (per-network drift is more often a stale path than corruption). +fn apply_network_entries_into_tree(metadata: &mut NodeNetworkMetadata, entries: Vec) { + for entry in entries { + let Some(network_metadata) = metadata.nested_metadata_mut(&entry.network_path) else { + log::warn!("apply_network_entries_into_tree: nested network at {:?} not found, skipping", entry.network_path); + continue; + }; + + if let Some(reference) = entry.reference { + network_metadata.persistent_metadata.reference = Some(reference); + } + } +} + +/// Walks `network` recursively, ensuring `metadata` has a default `DocumentNodeMetadata` slot for every node at every nesting level. +/// Mirrors the editor invariant that `NodeNetworkPersistentMetadata::node_metadata` contains every document-node key. +fn seed_metadata_tree(network: &NodeNetwork, metadata: &mut NodeNetworkMetadata) { + for (&local_id, node) in &network.nodes { + let node_metadata = metadata.persistent_metadata.node_metadata.entry(local_id).or_default(); + + if let DocumentNodeImplementation::Network(nested) = &node.implementation { + let child = node_metadata.persistent_metadata.network_metadata.get_or_insert_with(NodeNetworkMetadata::default); + seed_metadata_tree(nested, child); + } + } +} + +/// Patches entries onto the metadata tree previously seeded by [`seed_metadata_tree`]. +/// Entries with stale `(network_path, local_id)` are skipped with a warning; `input_metadata` length mismatches escalate to `InterfaceRebuildError`. +fn apply_entries_into_tree(network: &NodeNetwork, metadata: &mut NodeNetworkMetadata, entries: Vec) -> Result<(), InterfaceRebuildError> { + // Group entries by network_path so we do one nested_metadata_mut lookup per network. + let mut by_path: HashMap, Vec> = HashMap::new(); + for entry in entries { + by_path.entry(entry.network_path.clone()).or_default().push(entry); + } + + for (path, entries) in by_path { + let nested_network = nested_network_at(network, &path); + + let Some(network_metadata) = metadata.nested_metadata_mut(&path) else { + log::warn!("apply_entries_into_tree: nested network at {path:?} not found, skipping {} entries", entries.len()); + continue; + }; + + for entry in entries { + let Some(document_node_metadata) = network_metadata.persistent_metadata.node_metadata.get_mut(&entry.local_id) else { + log::warn!("apply_entries_into_tree: node {:?} not seeded under network {path:?}, skipping", entry.local_id); + continue; + }; + + let persistent = &mut document_node_metadata.persistent_metadata; + + if let Some(position) = entry.position { + persistent.node_type_metadata = position_to_runtime(position, entry.is_layer); + } else if entry.is_layer { + persistent.node_type_metadata = NodeTypePersistentMetadata::layer(IVec2::ZERO); + } + + if let Some(name) = entry.display_name { + persistent.display_name = name; + } + persistent.locked = entry.locked; + persistent.pinned = entry.pinned; + + // The converted runtime is the source of truth for input count; drift indicates a bug worth surfacing. + let expected_inputs = nested_network.and_then(|net| net.nodes.get(&entry.local_id)).map(|doc_node| doc_node.inputs.len()); + if let Some(expected) = expected_inputs + && expected != entry.input_metadata.len() + { + return Err(InterfaceRebuildError::InputMetadataLengthMismatch { + node: entry.local_id, + network_path: path.clone(), + expected, + got: entry.input_metadata.len(), + }); + } + + persistent.input_metadata = entry.input_metadata.into_iter().map(input_metadata_entry_to_runtime).collect(); + + if !entry.output_names.is_empty() { + persistent.output_names = entry.output_names; + } + + document_node_metadata.transient_metadata = DocumentNodeTransientMetadata::default(); + } + } + + Ok(()) +} + +/// `None` when the path is invalid; callers treat that as "skip silently" since the surrounding `nested_metadata_mut` already warns. +fn nested_network_at<'a>(root: &'a NodeNetwork, path: &[NodeId]) -> Option<&'a NodeNetwork> { + let mut current = root; + for segment in path { + let node = current.nodes.get(segment)?; + let DocumentNodeImplementation::Network(nested) = &node.implementation else { return None }; + current = nested; + } + Some(current) +} + +/// Storage-side `None` restores as `""` (the runtime's "unset" sentinel for `input_name` / `input_description`). +fn input_metadata_entry_to_runtime(entry: InputMetadataEntry) -> InputMetadata { + InputMetadata { + persistent_metadata: InputPersistentMetadata { + input_name: entry.input_name.unwrap_or_default(), + input_description: entry.input_description.unwrap_or_default(), + widget_override: entry.widget_override, + input_data: entry.input_data, + }, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use graph_storage::{NodeMetadataSource, PeerId, Registry}; + + use super::*; + use crate::messages::portfolio::document::document_message_handler::DocumentMessageHandler; + + /// Load a demo `.graphite` straight into a `DocumentMessageHandler` for inspection. + fn load_demo(file_name: &str) -> DocumentMessageHandler { + let path = format!("../demo-artwork/{file_name}"); + let content = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("Failed to read {path}: {e}")); + DocumentMessageHandler::deserialize_document(&content).unwrap_or_else(|e| panic!("Failed to deserialize {path}: {e:?}")) + } + + /// Walk every node in every nested network and collect `(network_path, local_id)` pairs so the + /// test can iterate every node addressable from the metadata side. + fn collect_all_node_paths(interface: &NodeNetworkInterface) -> Vec<(Vec, NodeId)> { + fn walk(interface: &NodeNetworkInterface, path: Vec, out: &mut Vec<(Vec, NodeId)>) { + let Some(network) = interface.nested_network(&path) else { return }; + for (&local_id, node) in &network.nodes { + out.push((path.clone(), local_id)); + + if matches!(&node.implementation, graph_craft::document::DocumentNodeImplementation::Network(_)) { + let mut child = path.clone(); + child.push(local_id); + walk(interface, child, out); + } + } + } + + let mut out = Vec::new(); + walk(interface, Vec::new(), &mut out); + out + } + + /// Loads a demo artwork, round-trips its `NodeNetwork + NodeNetworkInterface metadata` through + /// `Registry`, and asserts every node's `ui::*` attributes survive unchanged. + /// + /// Uses one demo (rather than the full set) because this is an exhaustive per-node check. + #[test] + fn editor_metadata_round_trip_against_demo() { + let document = load_demo("changing-seasons.graphite"); + let interface = &document.network_interface; + let source = StorageMetadataView::new(interface); + + let network = interface.document_network().clone(); + + let conversion = Registry::convert_from_runtime(&network, &source, &Default::default(), PeerId(0)).expect("convert_from_runtime failed"); + let declarations = conversion.declarations().expect("rebuild declarations"); + let registry = conversion.registry; + + let (_converted_network, entries) = registry.to_runtime_with_metadata(&declarations).expect("to_runtime_with_metadata failed"); + + // Index emitted entries by their (network_path, local_id) address. + let entries_by_address: HashMap<(Vec, NodeId), &graph_storage::NodeMetadataEntry> = entries.iter().map(|e| ((e.network_path.clone(), e.local_id), e)).collect(); + + let mut checked_any_position = false; + let mut checked_any_layer = false; + let mut checked_any_input_metadata = false; + + for (network_path, local_id) in collect_all_node_paths(interface) { + let expected_position = source.position(&network_path, local_id); + let expected_is_layer = source.is_layer(&network_path, local_id); + let expected_display = source.display_name(&network_path, local_id).map(str::to_owned); + let expected_locked = source.locked(&network_path, local_id); + let expected_pinned = source.pinned(&network_path, local_id); + let expected_output_names = source.output_names(&network_path, local_id); + + // Pull per-input metadata directly off the runtime side. We use the runtime invariant + // (`input_metadata.len() == inputs.len()`) as the iteration bound rather than calling the + // trait per index, since the trait would return `None` past the end and we want a strict + // slot-by-slot comparison. + let input_count = interface.document_node(&local_id, &network_path).map(|node| node.inputs.len()).unwrap_or(0); + let any_input_metadata_present = (0..input_count).any(|i| { + source.input_name(&network_path, local_id, i).is_some_and(|s| !s.is_empty()) + || source.input_description(&network_path, local_id, i).is_some_and(|s| !s.is_empty()) + || source.widget_override(&network_path, local_id, i).is_some() + || !source.input_data(&network_path, local_id, i).is_empty() + }); + + let any_metadata = expected_position.is_some() + || expected_is_layer + || expected_display.as_deref().is_some_and(|s| !s.is_empty()) + || expected_locked + || expected_pinned + || any_input_metadata_present + || !expected_output_names.is_empty(); + + if !any_metadata { + assert!( + entries_by_address.get(&(network_path.clone(), local_id)).is_none(), + "node {local_id:?} in network {network_path:?} has no editor metadata but produced an entry" + ); + continue; + } + + let entry = entries_by_address + .get(&(network_path.clone(), local_id)) + .unwrap_or_else(|| panic!("missing entry for node {local_id:?} in network {network_path:?}")); + + assert_eq!(entry.position, expected_position, "position mismatch for node {local_id:?} in network {network_path:?}"); + assert_eq!(entry.is_layer, expected_is_layer, "is_layer mismatch for node {local_id:?} in network {network_path:?}"); + // The trait round-trip drops empty display names (treats them as "unset") so the entry's + // `display_name` is `None` when the runtime carries `""`. Normalize before comparing. + let normalized_expected_display = expected_display.as_deref().filter(|s| !s.is_empty()).map(str::to_owned); + assert_eq!( + entry.display_name, normalized_expected_display, + "display_name mismatch for node {local_id:?} in network {network_path:?}" + ); + assert_eq!(entry.locked, expected_locked, "locked mismatch for node {local_id:?} in network {network_path:?}"); + assert_eq!(entry.pinned, expected_pinned, "pinned mismatch for node {local_id:?} in network {network_path:?}"); + assert_eq!(entry.output_names, expected_output_names, "output_names mismatch for node {local_id:?} in network {network_path:?}"); + + // Per-input metadata: the entry's `input_metadata` vec has the same length as the node's + // input slot count, and each slot matches the trait's per-field view (with the same + // "empty string ↔ None" normalization as `display_name`). + assert_eq!( + entry.input_metadata.len(), + input_count, + "input_metadata length mismatch for node {local_id:?} in network {network_path:?}: entry has {}, node has {input_count}", + entry.input_metadata.len(), + ); + for (index, input_entry) in entry.input_metadata.iter().enumerate() { + let expected_name = source.input_name(&network_path, local_id, index).filter(|s| !s.is_empty()).map(str::to_owned); + let expected_description = source.input_description(&network_path, local_id, index).filter(|s| !s.is_empty()).map(str::to_owned); + let expected_widget = source.widget_override(&network_path, local_id, index).map(str::to_owned); + let expected_data = source.input_data(&network_path, local_id, index); + + assert_eq!( + input_entry.input_name, expected_name, + "input_name mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + input_entry.input_description, expected_description, + "input_description mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + input_entry.widget_override, expected_widget, + "widget_override mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + input_entry.input_data, expected_data, + "input_data mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + } + + if entry.position.is_some() { + checked_any_position = true; + } + if entry.is_layer { + checked_any_layer = true; + } + if any_input_metadata_present { + checked_any_input_metadata = true; + } + } + + // Sanity: a real artwork should exercise at least these two shapes; otherwise the test is + // just iterating empty metadata and proving nothing. + assert!(checked_any_position, "demo artwork produced no positioned nodes — fixture is wrong or extraction is broken"); + assert!(checked_any_layer, "demo artwork produced no layer nodes — fixture is wrong or extraction is broken"); + // Demo artworks always have some custom widget overrides / input names; if not, the + // per-input round-trip isn't actually being exercised here. + assert!(checked_any_input_metadata, "demo artwork produced no per-input metadata — fixture is wrong or extraction is broken"); + } + + /// Full editor-side round-trip: original interface → Registry → (NodeNetwork, Vec) → + /// freshly-built interface. Asserts the rebuilt interface presents the same `ui::*` state as + /// the original when read through `StorageMetadataView`. + #[test] + fn editor_interface_rebuild_round_trip() { + let document = load_demo("changing-seasons.graphite"); + let original = &document.network_interface; + let original_view = StorageMetadataView::new(original); + + let network = original.document_network().clone(); + let conversion = Registry::convert_from_runtime(&network, &original_view, &Default::default(), PeerId(0)).expect("convert_from_runtime failed"); + let declarations = conversion.declarations().expect("rebuild declarations"); + let registry = conversion.registry; + let (rebuilt_network, node_entries, network_entries) = registry.to_runtime_with_full_metadata(&declarations).expect("to_runtime_with_full_metadata failed"); + + let rebuilt = build_interface_from_storage(rebuilt_network, node_entries, network_entries).expect("build_interface_from_storage failed"); + let rebuilt_view = StorageMetadataView::new(&rebuilt); + + // Every node the original carried must also resolve identically through the rebuilt view. + // Iterating over the *rebuilt* interface verifies that the rebuild covered every node, not + // just the ones the entries vec mentioned. + for (network_path, local_id) in collect_all_node_paths(&rebuilt) { + assert_eq!( + rebuilt_view.position(&network_path, local_id), + original_view.position(&network_path, local_id), + "position mismatch for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.is_layer(&network_path, local_id), + original_view.is_layer(&network_path, local_id), + "is_layer mismatch for node {local_id:?} in network {network_path:?}" + ); + // Original display names are returned by the source as-is (including `""`). After + // round-trip the rebuilt interface also stores `""` for nodes that had no name set, + // so this comparison is exact. + assert_eq!( + rebuilt_view.display_name(&network_path, local_id), + original_view.display_name(&network_path, local_id), + "display_name mismatch for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.locked(&network_path, local_id), + original_view.locked(&network_path, local_id), + "locked mismatch for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.pinned(&network_path, local_id), + original_view.pinned(&network_path, local_id), + "pinned mismatch for node {local_id:?} in network {network_path:?}" + ); + + // Per-input metadata: walk both interfaces' input slots in lockstep. + let original_inputs = original.document_node(&local_id, &network_path).map(|n| n.inputs.len()).unwrap_or(0); + let rebuilt_inputs = rebuilt.document_node(&local_id, &network_path).map(|n| n.inputs.len()).unwrap_or(0); + assert_eq!( + rebuilt_inputs, original_inputs, + "input count mismatch for node {local_id:?} in network {network_path:?}: rebuilt={rebuilt_inputs} original={original_inputs}" + ); + + for index in 0..rebuilt_inputs { + assert_eq!( + rebuilt_view.input_name(&network_path, local_id, index), + original_view.input_name(&network_path, local_id, index), + "input_name mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.input_description(&network_path, local_id, index), + original_view.input_description(&network_path, local_id, index), + "input_description mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.widget_override(&network_path, local_id, index), + original_view.widget_override(&network_path, local_id, index), + "widget_override mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.input_data(&network_path, local_id, index), + original_view.input_data(&network_path, local_id, index), + "input_data mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + } + + assert_eq!( + rebuilt_view.output_names(&network_path, local_id), + original_view.output_names(&network_path, local_id), + "output_names mismatch for node {local_id:?} in network {network_path:?}" + ); + } + + // Symmetric: every node in the original must also exist in the rebuilt interface. + for (network_path, local_id) in collect_all_node_paths(original) { + assert!( + rebuilt.nested_network(&network_path).and_then(|n| n.nodes.get(&local_id)).is_some(), + "original node {local_id:?} in network {network_path:?} missing after rebuild" + ); + } + + // Per-network metadata: every nested network path (including the root) must resolve to the same + // `reference` in the rebuilt interface. Node-graph nav + previewing are per-peer view state that + // lives in `session.json`, not the registry, so they're not round-tripped here. + let mut network_paths_to_check: Vec> = vec![Vec::new()]; + for (network_path, local_id) in collect_all_node_paths(original) { + let mut child = network_path.clone(); + child.push(local_id); + if original.nested_network(&child).is_some() { + network_paths_to_check.push(child); + } + } + + let mut checked_any_reference = false; + for path in &network_paths_to_check { + assert_eq!(rebuilt_view.reference(path), original_view.reference(path), "reference mismatch for network {path:?}"); + if original_view.reference(path).is_some() { + checked_any_reference = true; + } + } + + assert!(checked_any_reference, "demo artwork produced no reference metadata — fixture is wrong or extraction is broken"); + } + + /// Per-peer view settings (`ui::doc::*`) survive the `session.json` round-trip: serialize them into + /// the view map, then apply it onto a fresh handler and confirm each field matches. + #[test] + fn document_settings_round_trip() { + let mut document = load_demo("changing-seasons.graphite"); + + // Set distinctive, non-default values so the round-trip proves real data moved, not defaults. + document.render_mode = RenderMode::Outline; + document.rulers_visible = false; + document.collapsed = CollapsedLayers(vec![vec![NodeId(7)], vec![NodeId(7), NodeId(42)]]); + + let view_settings = DocumentSettings { + document_ptz: &document.document_ptz, + render_mode: &document.render_mode, + overlays_visibility: &document.overlays_visibility_settings, + rulers_visible: document.rulers_visible, + snapping_state: &document.snapping_state, + collapsed: &document.collapsed, + } + .to_view_map(); + + // Apply the serialized view settings onto a fresh handler and compare each field's serialized + // form (avoids requiring `PartialEq` on every setting type). + let mut restored = DocumentMessageHandler::default(); + restored.apply_stored_document_settings(&view_settings); + + assert_eq!(serde_json::to_value(restored.render_mode).unwrap(), serde_json::to_value(document.render_mode).unwrap(), "render_mode"); + assert_eq!( + serde_json::to_value(restored.rulers_visible).unwrap(), + serde_json::to_value(document.rulers_visible).unwrap(), + "rulers_visible" + ); + assert_eq!( + serde_json::to_value(restored.document_ptz).unwrap(), + serde_json::to_value(document.document_ptz).unwrap(), + "document_ptz" + ); + assert_eq!( + serde_json::to_value(restored.overlays_visibility_settings).unwrap(), + serde_json::to_value(document.overlays_visibility_settings).unwrap(), + "overlays" + ); + assert_eq!( + serde_json::to_value(restored.snapping_state).unwrap(), + serde_json::to_value(document.snapping_state).unwrap(), + "snapping_state" + ); + assert_eq!(serde_json::to_value(restored.collapsed).unwrap(), serde_json::to_value(document.collapsed).unwrap(), "collapsed"); + } +} diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 596afae458..4845ab669d 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -9,7 +9,8 @@ use graphene_std::raster::Image; use std::path::PathBuf; #[impl_message(Message, Portfolio)] -#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(derivative::Derivative, serde::Serialize, serde::Deserialize)] +#[derivative(Clone, Debug, PartialEq)] pub enum PortfolioMessage { // Sub-messages #[child] @@ -48,6 +49,20 @@ pub enum PortfolioMessage { DeleteDocument { document_id: DocumentId, }, + /// Delivers an asynchronously-built `Gdd` working copy into its document. Emitted by the mount + /// future spawned in `load_document` once the working-copy container is ready. The payload is + /// not serializable and a clone carries no `Gdd` (`clone_to_none`); it only ever travels once, + /// from the mount future to the receiving handler. `reopened` is true when an existing working copy + /// was opened (not freshly created): the persisted cursor is trusted as-is and the mount-time + /// re-commit is skipped, since re-committing the legacy runtime would stack a spurious interaction on the + /// restored cursor and make the first undo a no-op. + DocumentStorageMounted { + document_id: DocumentId, + reopened: bool, + #[serde(skip, default)] + #[derivative(Debug = "ignore", PartialEq = "ignore", Clone(clone_with = "clone_to_none"))] + gdd: Option, + }, DestroyAllDocuments, EditorPreferences, GarbageCollectResources, @@ -97,6 +112,34 @@ pub enum PortfolioMessage { document_path: Option, document_serialized_content: String, }, + /// Open a `.gdd` document container (archive bytes), building the runtime from its stored registry. + OpenGddDocument { + document_name: Option, + document_path: Option, + content: Vec, + }, + /// Delivers a document built asynchronously from a `.gdd` archive (registry → runtime, working copy + /// mounted) into the portfolio. Non-serializable payload that travels once, like `DocumentStorageMounted`. + GddDocumentLoaded { + document_id: DocumentId, + document_name: Option, + document_path: Option, + #[serde(skip, default)] + #[derivative(Debug = "ignore", PartialEq = "ignore", Clone(clone_with = "clone_to_none"))] + document: Option>, + }, + /// Delivers the interface rebuilt from the `Gdd` undo/redo cursor (which moved + persisted synchronously), + /// so the async registry rebuild can swap into the live document. `interface` is `None` if the rebuild + /// failed (logged at the source). `had_oracle` records whether the legacy snapshot applied synchronously, + /// so the swap can debug-compare the rebuild against it. Non-serializable payload that travels once, like + /// `GddDocumentLoaded`. + GddUndoRedoRebuilt { + document_id: DocumentId, + had_oracle: bool, + #[serde(skip, default)] + #[derivative(Debug = "ignore", PartialEq = "ignore", Clone(clone_with = "clone_to_none"))] + interface: Option>, + }, LoadDocument { document_id: DocumentId, document_name: Option, @@ -199,3 +242,8 @@ pub enum PortfolioMessage { sizes: Vec, }, } + +/// Clone helper for the non-serializable `gdd` payload: a cloned mount message carries no `Gdd`. +fn clone_to_none(_: &Option) -> Option { + None +} diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 98b3012e61..ecee7384e9 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -3,7 +3,7 @@ use super::document::utility_types::network_interface; use super::persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageHandler}; use super::utility_types::{PanelLayoutSubdivision, PanelType, WorkspacePanelLayout}; use crate::application::{Editor, generate_uuid}; -use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION}; +use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION, GDD_FILE_EXTENSION}; use crate::messages::animation::TimingInformation; use crate::messages::clipboard::utility_types::ClipboardContent; use crate::messages::dialog::simple_dialogs; @@ -73,6 +73,16 @@ pub struct PortfolioMessageHandler { pub selection_mode: SelectionMode, pub reset_node_definitions_on_open: bool, pub workspace_panel_layout: WorkspacePanelLayout, + /// Parent directory that holds every document's `Gdd` working copy; a given document mounts at + /// `working_copy_root.join(format!("{id:x}"))`. A filesystem path on native; on web it is + /// converted to an OPFS directory name at the container-construction seam. `None` means no + /// persistent location is configured (tests, headless) — documents mount an in-memory working + /// copy instead, so the mount path still runs but writes nowhere durable. + working_copy_root: Option, + /// Number of `.gdd` opens whose async build is in flight. While non-zero, resource GC is skipped: + /// the opening document's resources are being extracted into the cache but it isn't in `documents` + /// yet, so GC would wrongly evict them (notably proto-node declaration bytes). + pending_gdd_opens: usize, } #[message_handler_data] @@ -206,7 +216,9 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::GarbageCollectResources); } PortfolioMessage::AutoSaveDocument { document_id } => { - let Some(document) = self.document(document_id) else { return }; + let Some(byte_store) = resource_storage.storage() else { return }; + let Some(document) = self.documents.get_mut(&document_id) else { return }; + document.commit_storage_snapshot(byte_store); responses.add(PersistentStateMessage::WriteDocument { document_id, document: document.serialize_document(), @@ -381,6 +393,25 @@ impl MessageHandler> for Portfolio responses.add(PortfolioMessage::SelectDocument { document_id }); } } + PortfolioMessage::DocumentStorageMounted { document_id, reopened, gdd } => { + let Some(document) = self.documents.get_mut(&document_id) else { + // Document was closed before its working copy finished mounting; drop the `Gdd`. + return; + }; + document.storage = gdd; + // On a fresh mount, capture the current runtime state into the working copy: edits made + // during the mount window were skipped by `commit_storage_snapshot` (no-op while unmounted), + // so this initial commit brings the working copy up to date. On a reopen the working copy + // already holds the persisted cursor; re-committing here would stack a spurious interaction on it + // and make the first undo a no-op, so trust the restored cursor and skip the commit. + if !reopened && let Some(byte_store) = resource_storage.storage() { + document.commit_storage_snapshot(byte_store); + // Seal the freshly-converted document as the base revision: the conversion is a completed + // import, not an in-progress interaction, so retire it into history rather than leaving it in + // the hot log (where it would never reach an interaction boundary on a save without edits). + document.retire_storage_interaction(); + } + } PortfolioMessage::DestroyAllDocuments => { // Empty the list of internal document data self.documents.clear(); @@ -396,6 +427,11 @@ impl MessageHandler> for Portfolio // We don't know what can be safely garbage collected return; } + if self.pending_gdd_opens > 0 { + // A .gdd open is extracting its resources into the cache but isn't in `documents` yet, + // so its hashes would look unused. Skip this cycle; GC resumes once the open lands. + return; + } let mut used_resources = HashSet::new(); for (id, info) in self.unloaded_documents.iter() { @@ -409,6 +445,13 @@ impl MessageHandler> for Portfolio for document in self.documents.values_mut() { document.garbage_collect_resources(); used_resources.extend(document.resources.registry.resolved().filter_map(|info| info.hash.cloned())); + // Also keep resources referenced by the `.gdd` storage anywhere in history (notably proto-node + // declaration bytes), which the runtime resource registry doesn't track but the working copy + // and `.gdd` export need in the global cache. History-wide, not just the current registry: + // an undo drops an interaction's resources from the working registry, but redo still needs them. + if let Some(storage) = &document.storage { + used_resources.extend(storage.all_referenced_resource_hashes()); + } } used_resources.extend(self.fonts.used_resources()); responses.add(ResourceStorageMessage::GarbageCollect { @@ -594,7 +637,7 @@ impl MessageHandler> for Portfolio responses.add(NavigationMessage::CanvasPan { delta: (0., 0.).into() }); } - self.load_document(new_document, document_id, responses); + self.load_document(new_document, document_id, resource_storage, responses); responses.add(PortfolioMessage::SelectDocument { document_id }); } PortfolioMessage::MoveAllPanelTabs { @@ -731,6 +774,14 @@ impl MessageHandler> for Portfolio document_serialized_content: content, }); } + FileContent::GddDocument(content) => { + let document_path = if path.is_absolute() { Some(path) } else { None }; + responses.add(PortfolioMessage::OpenGddDocument { + document_name: name, + document_path, + content, + }); + } FileContent::Svg(svg) => { responses.add(PortfolioMessage::OpenSvg { name, svg }); } @@ -758,6 +809,14 @@ impl MessageHandler> for Portfolio document_serialized_content: content, }); } + FileContent::GddDocument(content) => { + // Treat importing a `.gdd` document as opening it (same as the legacy path above). + responses.add(PortfolioMessage::OpenGddDocument { + document_name: name, + document_path: Some(path), + content, + }); + } FileContent::Svg(svg) => { responses.add(PortfolioMessage::PasteSvg { name, @@ -799,6 +858,66 @@ impl MessageHandler> for Portfolio }); responses.add(PortfolioMessage::SelectDocument { document_id }); } + PortfolioMessage::OpenGddDocument { + document_name, + document_path, + content, + } => { + let document_id = DocumentId(generate_uuid()); + let Some(store_handle) = resource_storage.store_handle() else { + log::error!("Cannot open .gdd: resource storage not initialized"); + return; + }; + // Suppress resource GC until the open completes: the document's resources are extracted + // into the cache before it joins `documents`, so GC would otherwise evict them mid-open. + self.pending_gdd_opens += 1; + responses.add(open_gdd_document_future( + self.working_copy_root.clone(), + document_id, + document_name, + document_path, + content, + store_handle, + )); + } + PortfolioMessage::GddDocumentLoaded { + document_id, + document_name, + document_path, + document, + } => { + self.pending_gdd_opens = self.pending_gdd_opens.saturating_sub(1); + + let Some(mut document) = document.map(|boxed| *boxed) else { + // The build failed and already logged; nothing to insert. + return; + }; + document.finalize_storage_load(); + document.set_save_state(true); + + let name = document_name + .filter(|name| !name.trim().is_empty()) + .or_else(|| document_path.as_ref().and_then(|path| path.file_stem()).map(|stem| stem.to_string_lossy().into_owned())) + .unwrap_or_else(|| DEFAULT_DOCUMENT_NAME.to_string()); + document.name = self.resolve_document_name(name, None); + document.path = document_path; + + // The working copy is already mounted (we opened the .gdd), so skip the async re-mount. + self.load_document(document, document_id, resource_storage, responses); + responses.add(PortfolioMessage::SelectDocument { document_id }); + } + PortfolioMessage::GddUndoRedoRebuilt { document_id, had_oracle, interface } => { + let Some(document) = self.documents.get_mut(&document_id) else { + // Document was closed before its undo/redo rebuild completed; drop the payload. + return; + }; + let Some(interface) = interface.map(|boxed| *boxed) else { + // The rebuild failed and already logged; leave the live document untouched. + return; + }; + + document.apply_gdd_cursor_rebuild(interface, had_oracle, responses); + } PortfolioMessage::ToggleResetNodesToDefinitionsOnOpen => { self.reset_node_definitions_on_open = !self.reset_node_definitions_on_open; responses.add(MenuBarMessage::SendLayout); @@ -944,7 +1063,7 @@ impl MessageHandler> for Portfolio document.name = self.resolve_document_name(candidate_name, None); // Load the document into the portfolio so it opens in the editor - self.load_document(document, document_id, responses); + self.load_document(document, document_id, resource_storage, responses); responses.add(AppWindowMessage::Focus); @@ -1844,6 +1963,12 @@ impl PortfolioMessageHandler { Self { executor, ..Default::default() } } + /// Set the parent directory under which each document's `Gdd` working copy is mounted. `None` + /// mounts in-memory working copies (no durable location configured). + pub fn set_working_copy_root(&mut self, root: Option) { + self.working_copy_root = root; + } + pub fn document(&self, document_id: DocumentId) -> Option<&DocumentMessageHandler> { self.documents.get(&document_id) } @@ -1941,6 +2066,7 @@ impl PortfolioMessageHandler { Ok(content) => FileContent::Document(content), Err(_) => FileContent::Unsupported, }, + GDD_FILE_EXTENSION => FileContent::GddDocument(content), "svg" => match String::from_utf8(content) { Ok(content) => FileContent::Svg(content), Err(_) => FileContent::Unsupported, @@ -1960,7 +2086,7 @@ impl PortfolioMessageHandler { } } - fn load_document(&mut self, mut new_document: DocumentMessageHandler, document_id: DocumentId, responses: &mut VecDeque) { + fn load_document(&mut self, mut new_document: DocumentMessageHandler, document_id: DocumentId, resource_storage: &ResourceStorageMessageHandler, responses: &mut VecDeque) { let is_new_document = !self.document_ids.contains(&document_id); if is_new_document { self.document_ids.push_back(document_id); @@ -1987,6 +2113,59 @@ impl PortfolioMessageHandler { if is_new_document { responses.add(PortfolioMessage::UpdateOpenDocumentsList); } + + // Mount the per-document `Gdd` working copy asynchronously (its container is built off-thread). + // The document is already usable; the working copy attaches once the future resolves. With no + // configured root the working copy is in-memory, so the mount path still runs uniformly. + // Pass the just-loaded runtime network + a resource handle so a reopen can compare the stored + // `.gdd` against this legacy load (compare-on-open). + let legacy_network = self + .documents + .get(&document_id) + .map(|document| document.network_interface.document_network().clone()) + .unwrap_or_default(); + responses.add(Self::mount_document_storage(self.working_copy_root.clone(), document_id, legacy_network, resource_storage.resources())); + } + + /// Build the `FutureMessage` that constructs (or reopens) the document's `Gdd` working copy and + /// delivers it back via [`PortfolioMessage::DocumentStorageMounted`]. With a configured root the + /// working copy lives at `/`; without one it is in-memory. + /// + /// On a *reopen* (existing working copy), the freshly-read `.gdd` is converted back to a runtime + /// network and compared against `legacy_network` (the document the user actually loaded) before + /// the working copy attaches. This is the dual-write soak's compare-on-open: it validates the + /// `.gdd` *read* path (deserialize + hot-log replay), which the in-process `verify_storage_round_trip` + /// can't, since that never touches persisted bytes. Legacy stays authoritative; divergence is logged. + fn mount_document_storage( + working_copy_root: Option, + document_id: DocumentId, + legacy_network: graph_craft::document::NodeNetwork, + byte_store: Box, + ) -> Message { + let path = working_copy_root.map(|root| root.join(format!("{:x}", document_id.0))); + let editor_version = crate::application::GRAPHITE_GIT_COMMIT_HASH.to_string(); + let peer = graph_storage::PeerId(generate_uuid()); + + let future = async move { + let (gdd, reopened) = match build_or_open_working_copy(path.as_deref(), peer, document_id.0, editor_version).await { + Ok(result) => result, + Err(error) => { + log::error!("Failed to mount document storage for {document_id:?}: {error}"); + return Message::NoOp; + } + }; + + if reopened { + compare_storage_against_runtime(&gdd, &legacy_network, byte_store.as_ref(), document_id).await; + } + + Message::Portfolio(PortfolioMessage::DocumentStorageMounted { + document_id, + reopened, + gdd: Some(gdd), + }) + }; + future.into() } /// Returns an iterator over the open documents in order. @@ -2153,6 +2332,301 @@ fn display_name_with_fallback(info: &DocumentInfo) -> String { } } +/// Build a document's `Gdd` working copy. With `Some(path)` it opens an existing on-disk working +/// copy (reopen / autosave recovery) or creates a fresh one bound to `peer`, choosing the backend +/// per platform: a folder on native, an OPFS directory on web (where `path` is the OPFS directory +/// name). With `None` it creates a fresh in-memory working copy (tests, headless) that persists +/// nowhere durable. +/// +/// Returns the working copy plus whether it was reopened from an existing on-disk working copy +/// (`true`) versus freshly created (`false`). Only a reopen has independently-read stored state and +/// an embedded legacy blob worth comparing against the legacy load (compare-on-open). +async fn build_or_open_working_copy( + path: Option<&std::path::Path>, + peer: graph_storage::PeerId, + document_uuid: u64, + version: String, +) -> Result<(document_format::GddV1, bool), document_format::Error> { + use document_container::AnyContainer; + use document_format::{GddV1, GddV1Layout}; + + let Some(path) = path else { + let container = AnyContainer::Memory(document_container::backends::memory::MemoryBackend::new()); + let gdd = GddV1::create_in(container, GddV1Layout, peer, document_uuid, version.clone(), version).await?; + return Ok((gdd, false)); + }; + + #[cfg(not(target_family = "wasm"))] + let (container, exists) = { + use document_container::backends::folder::FolderBackend; + let exists = path.join("manifest.json").is_file(); + let backend = if exists { FolderBackend::open(path)? } else { FolderBackend::create(path)? }; + (AnyContainer::Folder(backend), exists) + }; + + #[cfg(target_family = "wasm")] + let (container, exists) = { + use document_container::AsyncContainer; + use document_container::backends::opfs::OpfsBackend; + let directory_name = path + .to_str() + .ok_or(document_format::Error::Container(document_container::ContainerError::InvalidPath(path.display().to_string())))?; + let backend = OpfsBackend::open(directory_name).await?; + let exists = backend.exists("manifest.json").await; + (AnyContainer::Opfs(backend), exists) + }; + + let gdd = if exists { + GddV1::open_in(container, GddV1Layout).await? + } else { + GddV1::create_in(container, GddV1Layout, peer, document_uuid, version.clone(), version).await? + }; + Ok((gdd, exists)) +} + +/// Build the per-document container for a working copy at `path` (folder native, OPFS web), or an +/// in-memory container when `path` is `None`. Does not open or create a `Gdd` — just the backend. +async fn build_per_document_container(path: Option<&std::path::Path>) -> Result { + use document_container::AnyContainer; + + let Some(path) = path else { + return Ok(AnyContainer::Memory(document_container::backends::memory::MemoryBackend::new())); + }; + + #[cfg(not(target_family = "wasm"))] + { + use document_container::backends::folder::FolderBackend; + let backend = if path.join("manifest.json").is_file() { + FolderBackend::open(path)? + } else { + FolderBackend::create(path)? + }; + Ok(AnyContainer::Folder(backend)) + } + + #[cfg(target_family = "wasm")] + { + use document_container::backends::opfs::OpfsBackend; + let directory_name = path + .to_str() + .ok_or(document_format::Error::Container(document_container::ContainerError::InvalidPath(path.display().to_string())))?; + let backend = OpfsBackend::open(directory_name).await?; + Ok(AnyContainer::Opfs(backend)) + } +} + +/// Build the `FutureMessage` that opens a `.gdd` archive: materialize it into the per-document working +/// copy, build the runtime from the stored registry, validate it against the embedded legacy blob, and +/// deliver the finished document via [`PortfolioMessage::GddDocumentLoaded`]. Resource bytes carried in +/// the archive are extracted into `store_handle` (the app-global cache) so declarations and the runtime +/// resolve. The registry build is authoritative; the embedded legacy blob is the always-checked oracle +/// and the fallback if the build fails. +fn open_gdd_document_future( + working_copy_root: Option, + document_id: DocumentId, + document_name: Option, + document_path: Option, + content: Vec, + store_handle: crate::messages::resource_storage::ResourcesHandle, +) -> Message { + let path = working_copy_root.map(|root| root.join(format!("{:x}", document_id.0))); + + let future = async move { + let document = build_document_from_gdd(path.as_deref(), &content, &store_handle, document_id).await; + Message::Portfolio(PortfolioMessage::GddDocumentLoaded { + document_id, + document_name, + document_path, + document: document.map(Box::new), + }) + }; + future.into() +} + +/// Build the `FutureMessage` that rebuilds a document's interface from its `Gdd` undo/redo cursor. The +/// cursor already moved and persisted synchronously in the handler; `gdd` is a snapshot clone at that +/// post-move position. This loads the cursor's declarations, converts the registry back to a runtime +/// network with full metadata, builds the interface, and delivers it via [`PortfolioMessage::GddUndoRedoRebuilt`] +/// so the handler can swap it in (preserving transients) and compare against the legacy oracle. `interface` +/// is `None` on failure (logged here), in which case the handler leaves the live document untouched. +pub(crate) fn rebuild_gdd_cursor_future(gdd: document_format::GddV1, store_handle: crate::messages::resource_storage::ResourcesHandle, document_id: DocumentId, had_oracle: bool) -> Message { + use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::build_interface_from_storage; + + let future = async move { + let declarations = gdd.declarations(&store_handle).await; + let interface = match gdd.registry().to_runtime_with_full_metadata(&declarations) { + Ok((network, node_entries, network_entries)) => match build_interface_from_storage(network, node_entries, network_entries) { + Ok(interface) => Some(Box::new(interface)), + Err(error) => { + log::error!("Gdd undo/redo rebuild for {document_id:?}: failed to build interface: {error}"); + None + } + }, + Err(error) => { + log::error!("Gdd undo/redo rebuild for {document_id:?}: failed to convert registry to runtime: {error}"); + None + } + }; + + Message::Portfolio(PortfolioMessage::GddUndoRedoRebuilt { document_id, had_oracle, interface }) + }; + future.into() +} + +/// Core of the `.gdd` open: archive -> working copy -> `Gdd` -> runtime interface, with the embedded +/// legacy blob loaded for validation (always compared) and fallback (used if the registry build fails). +/// Returns the built document, or `None` if neither the registry build nor the legacy fallback worked. +async fn build_document_from_gdd( + path: Option<&std::path::Path>, + content: &[u8], + store_handle: &impl graph_craft::application_io::resource::ResourceStorage, + document_id: DocumentId, +) -> Option { + use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{apply_network_view_settings, build_interface_from_storage, network_ids_from_entries}; + use document_format::{GddV1, GddV1Layout}; + + let container = match build_per_document_container(path).await { + Ok(container) => container, + Err(error) => { + log::error!("Opening .gdd for {document_id:?}: failed to build working copy container: {error}"); + return None; + } + }; + + let gdd = match GddV1::open_from_archive(content, container, GddV1Layout).await { + Ok(gdd) => gdd, + Err(error) => { + log::error!("Opening .gdd for {document_id:?}: failed to open archive: {error}"); + return None; + } + }; + + // Extract resource bytes carried in the archive into the global cache so declarations + runtime resolve. + match gdd.resource_hashes().await { + Ok(hashes) => { + for hash in hashes { + match gdd.read_resource(&hash).await { + Ok(holder) => { + store_handle.store(holder.as_slice()); + } + Err(error) => log::error!("Opening .gdd for {document_id:?}: failed to read resource {hash}: {error}"), + } + } + } + Err(error) => log::error!("Opening .gdd for {document_id:?}: failed to list resources: {error}"), + } + + // Full legacy load of the embedded blob: serves as both the validation oracle and the fallback. + let legacy_document = gdd + .read_legacy_document() + .await + .and_then(|holder| String::from_utf8(holder.as_slice().to_vec()).ok()) + .and_then(|serialized| DocumentMessageHandler::deserialize_document(&document_migration_string_preprocessing(serialized)).ok()); + + // Build the runtime from the stored registry (authoritative). + let declarations = gdd.declarations(store_handle).await; + let interface = match gdd.registry().to_runtime_with_full_metadata(&declarations) { + Ok((network, node_entries, network_entries)) => { + let network_ids = network_ids_from_entries(&network_entries); + match build_interface_from_storage(network, node_entries, network_entries) { + Ok(mut interface) => { + // Restore the per-network, per-peer view state (node-graph nav + previewing) from + // `session.json`; it travels in the archive but isn't in the registry/history. + apply_network_view_settings(&mut interface, &network_ids, gdd.network_view_settings()); + Some(interface) + } + Err(error) => { + log::error!("Opening .gdd for {document_id:?}: failed to build interface: {error}"); + None + } + } + } + Err(error) => { + log::error!("Opening .gdd for {document_id:?}: failed to convert registry to runtime: {error}"); + None + } + }; + + // Compare the registry build against the legacy oracle (always, when both are available). Normalize the + // benign generic-vs-resolved `call_argument` difference (see `normalize_call_arguments_for_compare`) so + // it doesn't flag. + if let Some(interface) = interface { + if let Some(legacy) = &legacy_document { + let mut built = interface.document_network().clone(); + let mut oracle = legacy.network_interface.document_network().clone(); + normalize_call_arguments_for_compare(&mut built); + normalize_call_arguments_for_compare(&mut oracle); + if built != oracle { + log::error!( + "Open-.gdd divergence for {document_id:?}: registry-built document differs from the embedded legacy blob\n{}", + crate::messages::portfolio::document::diff_networks(&oracle, &built) + ); + } + } + return Some(DocumentMessageHandler::from_storage(interface, gdd, String::new(), None)); + } + + // Registry build failed: fall back to the embedded legacy document. + log::warn!("Opening .gdd for {document_id:?}: registry build failed, falling back to embedded legacy document"); + if legacy_document.is_none() { + log::error!("Opening .gdd for {document_id:?}: no embedded legacy document to fall back to"); + } + legacy_document +} + +/// Compare-on-open: convert the reopened `.gdd`'s stored registry back to a runtime network and diff +/// it against `legacy_network` (the document the user actually loaded). Validates the `.gdd` read path +/// against the legacy load. Legacy is authoritative during the soak, so this only logs divergence; it +/// never swaps the candidate in. Cold-path (runs once per open, off the edit hot path). +async fn compare_storage_against_runtime( + gdd: &document_format::GddV1, + legacy_network: &graph_craft::document::NodeNetwork, + byte_store: &dyn graph_craft::application_io::resource::LoadResource, + document_id: DocumentId, +) { + let declarations = gdd.declarations(byte_store).await; + let mut candidate = match gdd.registry().to_runtime_with_metadata(&declarations) { + Ok((network, _entries)) => network, + Err(error) => { + log::error!("Compare-on-open for {document_id:?}: .gdd registry failed to convert to runtime: {error}"); + return; + } + }; + + // One open/restore path resolves a node's generic `call_argument` (e.g. a layer's inner monitor node's + // `T`) to a concrete `Context`, while storage faithfully keeps the authored `T`. That generic-vs-resolved + // difference is benign (it's recomputed during compilation), so normalize it on both sides before + // comparing to keep the soak signal focused on real drift. + let mut legacy = legacy_network.clone(); + normalize_call_arguments_for_compare(&mut legacy); + normalize_call_arguments_for_compare(&mut candidate); + + if candidate != legacy { + log::error!( + "Compare-on-open divergence for {document_id:?}: the reopened .gdd does not match the legacy load\n{}", + crate::messages::portfolio::document::diff_networks(&legacy, &candidate) + ); + } +} + +/// Canonicalize every node's `call_argument` (recursively, including nested networks) so a generic `T` +/// and its compilation-resolved concrete `Context` compare equal. Storage keeps the authored generic +/// form, but one load/restore path resolves it to `Context`; that difference is benign for the +/// compare-on-open soak check. Concrete `call_arguments` other than `Context` are left untouched. +fn normalize_call_arguments_for_compare(network: &mut graph_craft::document::NodeNetwork) { + use graph_craft::Type; + + let context = graph_craft::concrete!(graphene_std::Context); + for node in network.nodes.values_mut() { + if node.call_argument == context { + node.call_argument = Type::Generic(std::borrow::Cow::Borrowed("T")); + } + if let graph_craft::document::DocumentNodeImplementation::Network(inner) = &mut node.implementation { + normalize_call_arguments_for_compare(inner); + } + } +} + /// Returns `None` if the name has no safe filename characters left or matches a Windows reserved device name, so callers fall back to an ID-based stem. // TODO: Eventually remove this document upgrade code fn sanitize_filename_stem(name: &str) -> Option { diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index 3e3f464ec0..a02ce1d435 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -789,8 +789,10 @@ impl PanelLayoutSubdivision { } pub enum FileContent { - /// A Graphite document. + /// A legacy `.graphite` document (serialized runtime JSON). Document(String), + /// A `.gdd` document container (archive bytes). + GddDocument(Vec), /// A bitmap image. Image(Image), /// An SVG file string. diff --git a/editor/src/messages/resource_storage/resource_storage_message_handler.rs b/editor/src/messages/resource_storage/resource_storage_message_handler.rs index d4dd7405b8..93ee7e292f 100644 --- a/editor/src/messages/resource_storage/resource_storage_message_handler.rs +++ b/editor/src/messages/resource_storage/resource_storage_message_handler.rs @@ -13,6 +13,20 @@ impl LoadResource for ResourcesHandle { } } +impl ResourceStorage for ResourcesHandle { + fn store(&self, data: &[u8]) -> ResourceHash { + self.inner.store(data) + } + + fn contains(&self, hash: &ResourceHash) -> bool { + self.inner.contains(hash) + } + + fn garbage_collect(&self, used: &[ResourceHash]) { + self.inner.garbage_collect(used) + } +} + #[derive(ExtractField)] pub struct ResourceStorageMessageHandler { storage: Option>, @@ -28,6 +42,18 @@ impl ResourceStorageMessageHandler { inner: self.storage.clone().expect("Resource storage not initialized"), }) } + + /// The backing store as a `&dyn ResourceStorage`, for write paths (e.g. persisting declaration + /// bytes on commit). `None` before initialization. + pub fn storage(&self) -> Option<&dyn ResourceStorage> { + self.storage.as_deref() + } + + /// An owned, cloneable handle that both loads and stores, for `'static` async tasks that need to + /// read and write the cache off-thread. `None` before initialization. + pub fn store_handle(&self) -> Option { + self.storage.clone().map(|inner| ResourcesHandle { inner }) + } } impl std::fmt::Debug for ResourceStorageMessageHandler { diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 8665ccda7e..3de2524d18 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -94,7 +94,7 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: }); subscriptions.subscribeFrontendMessage("TriggerOpen", async () => { - const files = await upload(`image/*,.${editor.fileExtension()}`, "data", true); + const files = await upload(`image/*,.${editor.fileExtension()},.${editor.gddFileExtension()}`, "data", true); files.forEach((file) => editor.openFile(file.filename, file.content)); }); diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 2b91cf1125..9f6a80158e 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -13,7 +13,7 @@ use crate::{EDITOR_HAS_CRASHED, Error, FRONTEND_READY, MESSAGE_BUFFER}; #[cfg(not(feature = "native"))] #[cfg(all(not(feature = "native"), target_family = "wasm"))] use editor::application::{Editor, Environment, Host, Platform}; -use editor::consts::FILE_EXTENSION; +use editor::consts::{FILE_EXTENSION, GDD_FILE_EXTENSION}; use editor::messages::clipboard::utility_types::ClipboardContentRaw; use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta}; @@ -95,7 +95,10 @@ impl EditorWrapper { let application_io = PlatformApplicationIo::new().await; let wake = crate::helpers::async_wake_callback(); - let editor = Editor::new(Environment { platform: Platform::Web, host }, uuid_random_seed, storage, application_io, wake); + // On web the working-copy root is an OPFS directory name (no real filesystem path); each + // document mounts under `documents/`. + let working_copy_root = Some(std::path::PathBuf::from("documents")); + let editor = Editor::new(Environment { platform: Platform::Web, host }, uuid_random_seed, storage, working_copy_root, application_io, wake); if EDITOR.with(|slot| slot.lock().ok().map(|mut guard| *guard = Some(editor))).is_none() { log::error!("Attempted to initialize the editor more than once"); @@ -333,6 +336,12 @@ impl EditorWrapper { FILE_EXTENSION.into() } + /// Get the constant `GDD_FILE_EXTENSION` + #[wasm_bindgen(js_name = gddFileExtension)] + pub fn gdd_file_extension(&self) -> String { + GDD_FILE_EXTENSION.into() + } + /// Update the value of a given UI widget, but don't commit it to the history (unless `commit_layout()` is called, which handles that) #[wasm_bindgen(js_name = widgetValueUpdate)] pub fn widget_value_update(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { From bd8d302f631ba8a6bdb529b38999aeb58247f380 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Mon, 22 Jun 2026 10:41:57 +0200 Subject: [PATCH 2/3] Address review feedback: drop redundant clones and dedupe metadata traversal --- .../document/document_message_handler.rs | 14 ++--- .../utility_types/network_interface.rs | 52 +++++++++++-------- .../network_interface/storage_metadata.rs | 10 ++-- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 48a51b8649..1b1ce10acc 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1775,7 +1775,7 @@ impl DocumentMessageHandler { ..Default::default() }; - document.apply_stored_document_settings(&storage.view_settings().clone()); + document.apply_stored_document_settings(storage.view_settings()); match storage.registry().to_resource_registry() { Ok(resource_registry) => document.resources.registry = resource_registry, Err(error) => log::error!("Opening .gdd: failed to rebuild resource registry: {error}"), @@ -1997,7 +1997,7 @@ impl DocumentMessageHandler { use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::collect_network_view_settings; - let network = self.network_interface.document_network().clone(); + let network = self.network_interface.document_network(); let view = StorageMetadataView::new(&self.network_interface); // Per-network view state (node-graph nav + previewing) is per-peer too, so collect it (keyed by the @@ -2006,7 +2006,7 @@ impl DocumentMessageHandler { let network_view_settings = self .storage .as_ref() - .and_then(|storage| storage.network_ids(&network, &view).ok()) + .and_then(|storage| storage.network_ids(network, &view).ok()) .map(|network_ids| collect_network_view_settings(&self.network_interface, &network_ids)); // Per-peer view settings (PTZ, rulers, ...) persist in `session.json`, not the registry, so they @@ -2030,7 +2030,7 @@ impl DocumentMessageHandler { // Stage into the working copy without retiring: a tool drag fires several `CommitTransaction`s // but is one legacy undo step, so the deltas accumulate as hot ops and coalesce into one retired // interaction at the next undo-step boundary (`retire_pending_interaction`). - if let Err(error) = storage.stage_runtime_snapshot(&network, &view, &self.resources.registry, byte_store) { + if let Err(error) = storage.stage_runtime_snapshot(network, &view, &self.resources.registry, byte_store) { log::error!("Storage snapshot staging failed: {error}"); return; } @@ -2052,7 +2052,7 @@ impl DocumentMessageHandler { } #[cfg(debug_assertions)] - self.verify_storage_round_trip(&network, &view); + self.verify_storage_round_trip(network, &view); } /// Restore the per-peer view settings persisted in `session.json` (the `ui::doc::*`-keyed @@ -2214,9 +2214,9 @@ impl DocumentMessageHandler { let Some(storage) = &self.storage else { return }; let peer = storage.session().peer(); - let network = self.network_interface.document_network().clone(); + let network = self.network_interface.document_network(); let view = StorageMetadataView::new(&self.network_interface); - let Ok(mut conversion) = graph_storage::Registry::convert_from_runtime(&network, &view, &self.resources.registry, peer) else { + let Ok(mut conversion) = graph_storage::Registry::convert_from_runtime(network, &view, &self.resources.registry, peer) else { log::error!("undo/redo shadow: from_runtime failed"); return; }; diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index a1eb839dfc..a13aa5e706 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -3418,37 +3418,49 @@ impl NodeNetworkInterface { // Public mutable methods impl NodeNetworkInterface { - pub fn copy_all_navigation_metadata(&mut self, other_interface: &NodeNetworkInterface) { + /// Walk every network at every nesting level, invoking `visit` with each network's path and a mutable + /// reference to its metadata. Only nodes that contain a nested network are recursed into. + fn for_each_network_metadata_mut(&mut self, mut visit: impl FnMut(&[NodeId], &mut NodeNetworkMetadata)) { let mut stack = vec![vec![]]; while let Some(path) = stack.pop() { let Some(self_network_metadata) = self.network_metadata_mut(&path) else { continue; }; - if let Some(other_network_metadata) = other_interface.network_metadata(&path) { - self_network_metadata.persistent_metadata.navigation_metadata = other_network_metadata.persistent_metadata.navigation_metadata.clone(); - } - stack.extend(self_network_metadata.persistent_metadata.node_metadata.keys().map(|node_id| { - let mut current_path: Vec = path.clone(); - current_path.push(*node_id); - current_path - })); + visit(&path, self_network_metadata); + + // Only nodes that contain a nested network have metadata to recurse into, so skip leaf nodes. + stack.extend( + self_network_metadata + .persistent_metadata + .node_metadata + .iter() + .filter(|(_, node_metadata)| node_metadata.persistent_metadata.network_metadata.is_some()) + .map(|(node_id, _)| { + let mut current_path: Vec = path.clone(); + current_path.push(*node_id); + current_path + }), + ); } } + pub fn copy_all_navigation_metadata(&mut self, other_interface: &NodeNetworkInterface) { + self.for_each_network_metadata_mut(|path, self_network_metadata| { + if let Some(other_network_metadata) = other_interface.network_metadata(path) { + self_network_metadata.persistent_metadata.navigation_metadata = other_network_metadata.persistent_metadata.navigation_metadata.clone(); + } + }); + } + /// Copy every piece of transient view and selection state from `other_interface` onto `self`, for each /// network at every nesting level: navigation metadata (pan/zoom), selection undo/redo history, and the /// document-to-viewport canvas camera. Used when an interface is rebuilt from storage (the `Gdd` undo/redo /// cursor) and must keep the user's current view and selection rather than reset them, since the registry /// models document content only. `resolved_types` is a separate runtime cache the caller handles. pub fn copy_all_transient_view_state(&mut self, other_interface: &NodeNetworkInterface) { - let mut stack = vec![vec![]]; - while let Some(path) = stack.pop() { - let Some(self_network_metadata) = self.network_metadata_mut(&path) else { - continue; - }; - - if let Some(other_network_metadata) = other_interface.network_metadata(&path) { + self.for_each_network_metadata_mut(|path, self_network_metadata| { + if let Some(other_network_metadata) = other_interface.network_metadata(path) { let self_persistent = &mut self_network_metadata.persistent_metadata; let other_persistent = &other_network_metadata.persistent_metadata; self_persistent.navigation_metadata = other_persistent.navigation_metadata.clone(); @@ -3456,13 +3468,7 @@ impl NodeNetworkInterface { self_persistent.selection_undo_history = other_persistent.selection_undo_history.clone(); self_persistent.selection_redo_history = other_persistent.selection_redo_history.clone(); } - - stack.extend(self_network_metadata.persistent_metadata.node_metadata.keys().map(|node_id| { - let mut current_path: Vec = path.clone(); - current_path.push(*node_id); - current_path - })); - } + }); self.document_metadata.document_to_viewport = other_interface.document_metadata.document_to_viewport; } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs index 4a714b1438..f4d0e3b3f1 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs @@ -173,9 +173,11 @@ pub fn build_interface_from_storage(network: NodeNetwork, node_entries: Vec Date: Mon, 22 Jun 2026 13:10:53 +0200 Subject: [PATCH 3/3] Extract DocumentHistory and move storage-metadata tests to their own file --- .../portfolio/document/document_history.rs | 262 +++++++++++++ .../document/document_message_handler.rs | 238 +++--------- editor/src/messages/portfolio/document/mod.rs | 4 + .../document/storage_metadata_tests.rs | 345 ++++++++++++++++++ .../document/storage_round_trip_tests.rs | 24 +- .../network_interface/storage_metadata.rs | 343 +---------------- .../portfolio/portfolio_message_handler.rs | 4 +- 7 files changed, 688 insertions(+), 532 deletions(-) create mode 100644 editor/src/messages/portfolio/document/document_history.rs create mode 100644 editor/src/messages/portfolio/document/storage_metadata_tests.rs diff --git a/editor/src/messages/portfolio/document/document_history.rs b/editor/src/messages/portfolio/document/document_history.rs new file mode 100644 index 0000000000..e6d5880db5 --- /dev/null +++ b/editor/src/messages/portfolio/document/document_history.rs @@ -0,0 +1,262 @@ +use std::collections::VecDeque; +use std::collections::{BTreeMap, HashSet}; + +use graph_craft::application_io::resource::{ResourceId, ResourceRegistry, ResourceStorage}; +use graph_craft::document::NodeNetwork; +use graph_storage::Registry; + +use super::utility_types::network_interface::NodeNetworkInterface; +use super::utility_types::network_interface::storage_metadata::{StorageMetadataView, collect_network_view_settings}; + +/// The document-derived inputs a snapshot stage needs from the live handler, gathered before the +/// mutable cursor borrow so the staging logic can live on [`DocumentHistory`] without reaching back +/// into document state. `network`/`view`/`registry` describe the runtime graph; `interface` resolves +/// the per-network ids; `view_settings` and `legacy_document` are the already-serialized per-peer +/// view map and dual-write legacy blob. +pub struct SnapshotInputs<'a> { + pub network: &'a NodeNetwork, + pub view: &'a StorageMetadataView<'a>, + pub interface: &'a NodeNetworkInterface, + pub registry: &'a ResourceRegistry, + pub view_settings: BTreeMap, + pub legacy_document: String, +} + +/// Per-document undo/redo state: the legacy snapshot stacks plus the `Gdd` working-copy cursor that +/// is becoming the authoritative history. Owns the dual-stack bookkeeping (push, trim to +/// [`MAX_UNDO_HISTORY_LEN`](crate::consts::MAX_UNDO_HISTORY_LEN), pop, clear), the cursor's pending +/// interaction, and the stage/retire/cursor-move/verify lifecycle, so the document handler drives +/// history through one cohesive surface instead of poking at three loose fields. +/// +/// Not serialized: the legacy stacks are runtime-only, and the cursor shares the working-copy +/// container by `Arc`, so a clone keeps reading the live working copy. +#[derive(derivative::Derivative)] +#[derivative(Clone, Debug, Default)] +pub struct DocumentHistory { + /// Stack of document network snapshots for previous history states. + undo: VecDeque, + /// Stack of document network snapshots for future history states. + redo: VecDeque, + /// Per-document `Gdd` working copy: owns the CRDT `Session` and mirrors edits to disk. `None` + /// until the mount future built by `load_document` resolves (the working-copy container is + /// constructed asynchronously). A clone shares the same working-copy container. + #[derivative(Debug = "ignore")] + storage: Option, +} + +impl DocumentHistory { + // ===== Legacy snapshot stacks ===== + + /// Push a snapshot onto the undo stack, evicting the oldest entry past the history cap. + pub fn push_undo(&mut self, snapshot: NodeNetworkInterface) { + Self::push_capped(&mut self.undo, snapshot); + } + + /// Push a snapshot onto the redo stack, evicting the oldest entry past the history cap. + pub fn push_redo(&mut self, snapshot: NodeNetworkInterface) { + Self::push_capped(&mut self.redo, snapshot); + } + + /// Pop the most recent undo snapshot, or `None` when the stack is empty. + pub fn pop_undo(&mut self) -> Option { + self.undo.pop_back() + } + + /// Pop the most recent redo snapshot, or `None` when the stack is empty. + pub fn pop_redo(&mut self) -> Option { + self.redo.pop_back() + } + + /// Drop the most recently pushed undo snapshot (used to cancel a transaction that ended up unmodified). + pub fn discard_last_undo(&mut self) { + self.undo.pop_back(); + } + + /// Clear the redo stack, called when a fresh edit invalidates the redo future. + pub fn clear_redo(&mut self) { + self.redo.clear(); + } + + /// Add the resources referenced by every snapshot in both history stacks into `resources`, so + /// history-only resources stay alive for legacy undo/redo. + pub fn collect_used_resources(&self, resources: &mut HashSet) { + for interface in self.undo.iter().chain(&self.redo) { + interface.collect_used_resources(resources); + } + } + + // ===== Gdd working-copy cursor ===== + + /// The `Gdd` working copy, or `None` until the mount future resolves. + pub fn storage(&self) -> Option<&document_format::GddV1> { + self.storage.as_ref() + } + + /// Mutable access to the `Gdd` working copy, or `None` until the mount future resolves. + pub fn storage_mut(&mut self) -> Option<&mut document_format::GddV1> { + self.storage.as_mut() + } + + /// Attach (or clear) the `Gdd` working copy once the mount future resolves. + pub fn set_storage(&mut self, storage: Option) { + self.storage = storage; + } + + /// Retire the pending staged hot ops (the current interaction) into durable Gdd history as one undo + /// unit. Called at each undo-step boundary (a new `StartTransaction`) and before undo/redo, so the + /// per-`CommitTransaction` staging coalesces into one retired interaction aligned with the legacy step. + /// No-op while the working copy is unmounted. + pub fn retire_storage_interaction(&mut self) { + let Some(storage) = self.storage.as_mut() else { return }; + if let Err(error) = storage.retire_pending_interaction() { + log::error!("Storage interaction retirement failed: {error}"); + } + } + + /// Stage the runtime snapshot into the `Gdd` working copy at each `CommitTransaction`. No-op while + /// the working copy is still unmounted (its container is built asynchronously on document open). The + /// staged hot ops are retired into durable history by [`retire_storage_interaction`](Self::retire_storage_interaction) + /// at undo-step boundaries. Proto-node declaration bytes are persisted into `byte_store` (the + /// app-global resource cache). Caller gathers [`SnapshotInputs`] from the live handler first. + pub fn stage_snapshot(&mut self, inputs: SnapshotInputs, byte_store: &dyn ResourceStorage) { + if self.storage.is_none() { + return; + } + + // Per-network view state (node-graph nav + previewing) is per-peer too, so collect it (keyed by the + // stable `NetworkId` the storage layer resolves) for `session.json`. Computed before the mutable + // `storage` borrow below. + let network_view_settings = self + .storage + .as_ref() + .and_then(|storage| storage.network_ids(inputs.network, inputs.view).ok()) + .map(|network_ids| collect_network_view_settings(inputs.interface, &network_ids)); + + let storage = self.storage.as_mut().expect("checked present above"); + + // Stage into the working copy without retiring: a tool drag fires several `CommitTransaction`s + // but is one legacy undo step, so the deltas accumulate as hot ops and coalesce into one retired + // interaction at the next undo-step boundary (`retire_pending_interaction`). + if let Err(error) = storage.stage_runtime_snapshot(inputs.network, inputs.view, inputs.registry, byte_store) { + log::error!("Storage snapshot staging failed: {error}"); + return; + } + + if let Err(error) = storage.set_view_settings(inputs.view_settings) { + log::error!("Persisting view settings failed: {error}"); + } + + if let Some(network_view_settings) = network_view_settings + && let Err(error) = storage.set_network_view_settings(network_view_settings) + { + log::error!("Persisting per-network view settings failed: {error}"); + } + + // Dual-write soak: embed the legacy `.graphite` bytes inside the `.gdd` working copy so the new + // format can be validated against (and recovered from) the old one on open. + if let Err(error) = storage.store_legacy_document(inputs.legacy_document.as_bytes()) { + log::error!("Embedding legacy document into working copy failed: {error}"); + } + + #[cfg(debug_assertions)] + self.verify_round_trip(inputs.network, inputs.view, inputs.registry); + } + + /// Move the `Gdd` undo/redo cursor along the retired interaction chain. Flushes any open interaction + /// first (undo/redo operate on the retired chain), then moves the cursor and returns a clone of the + /// post-move `Gdd` (the container is `Arc`-shared) so a `'static` rebuild future can read the rewound + /// state while the live document keeps its cursor. `None` when there is nothing to move to, the cursor + /// is unmounted, or the move failed. + pub fn move_cursor(&mut self, undo: bool) -> Option { + self.retire_storage_interaction(); + + let storage = self.storage.as_mut()?; + + let moved = if undo { + if !storage.can_undo() { + return None; + } + storage.undo().map(|_| ()) + } else { + if !storage.can_redo() { + return None; + } + storage.redo().map(|_| ()) + }; + if let Err(error) = moved { + log::error!("Storage undo/redo cursor move failed: {error}"); + return None; + } + + Some(storage.clone()) + } + + // ===== Debug-only soak verification ===== + + /// Debug-only: stored registry should equal a fresh `from_runtime`, and a `to_runtime` of the + /// stored registry should equal the original network. Panics on drift so the dual-write soak + /// fails loud in dev (and in tests); release builds skip this entirely and autosave never crashes. + #[cfg(debug_assertions)] + pub fn verify_round_trip(&self, network: &NodeNetwork, view: &StorageMetadataView, registry: &ResourceRegistry) { + use super::document_message_handler::{diff_networks, diff_registries}; + + let Some(storage) = &self.storage else { return }; + let peer = storage.session().peer(); + + let conversion = Registry::convert_from_runtime(network, view, registry, peer).expect("storage round-trip: from_runtime failed"); + let target = &conversion.registry; + let declarations = conversion.declarations().expect("storage round-trip: declaration rebuild failed"); + + let stored = storage.registry(); + assert!(stored.value_equal(target), "storage round-trip: registry value drift after commit\n{}", diff_registries(stored, target)); + assert!(stored.order_consistent(target), "storage round-trip: timestamp order inconsistent between stored and target"); + + let (round_tripped, _entries) = stored.to_runtime_with_metadata(&declarations).expect("storage round-trip: to_runtime failed"); + assert!( + &round_tripped == network, + "storage round-trip: network drift after to_runtime\n{}", + diff_networks(network, &round_tripped) + ); + } + + /// Debug-only: after a `Gdd` cursor move, the cursor's registry should equal a fresh `from_runtime` + /// of the current (legacy-restored) interface. Logs drift (does not panic) so cursor bugs surface in + /// dev without crashing the editor. `current_resources` are the resources the live network references + /// (history-only resources the cursor dropped are expected, not drift). + #[cfg(debug_assertions)] + pub fn verify_cursor_matches_runtime(&self, network: &NodeNetwork, view: &StorageMetadataView, registry: &ResourceRegistry, current_resources: &HashSet) { + use super::document_message_handler::diff_registries; + + let Some(storage) = &self.storage else { return }; + let peer = storage.session().peer(); + + let Ok(mut conversion) = Registry::convert_from_runtime(network, view, registry, peer) else { + log::error!("undo/redo shadow: from_runtime failed"); + return; + }; + + let stored = storage.registry(); + + // The runtime keeps resources alive while they're referenced by undo/redo history (so legacy redo + // can restore them), but the `Gdd` cursor reverts the interaction's `AddResource`. So a resource the + // runtime carries which the cursor dropped is expected, not drift, as long as it's history-only + // (not referenced by the current network). Drop those from the conversion before comparing. + conversion.registry.resources.retain(|id, _| stored.resources.contains_key(id) || current_resources.contains(id)); + + if !stored.value_equal(&conversion.registry) { + // Logged, not panicked: any remaining drift is a real cursor or conversion bug to triage, but + // the shadow must not crash the live editor while we harden it toward a hard panic. + log::error!( + "undo/redo shadow: cursor registry diverged from the restored interface\n{}", + diff_registries(stored, &conversion.registry) + ); + } + } + + fn push_capped(stack: &mut VecDeque, snapshot: NodeNetworkInterface) { + stack.push_back(snapshot); + if stack.len() > crate::consts::MAX_UNDO_HISTORY_LEN { + stack.pop_front(); + } + } +} diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 1b1ce10acc..0d46646dc4 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1,3 +1,5 @@ +use super::DocumentHistory; +use super::document_history::SnapshotInputs; use super::node_graph::document_node_definitions; use super::utility_types::error::EditorError; use super::utility_types::misc::{GroupFolderType, SNAP_FUNCTIONS_FOR_BOUNDING_BOXES, SNAP_FUNCTIONS_FOR_PATHS, SnappingOptions, SnappingState}; @@ -92,13 +94,6 @@ pub struct DocumentMessageHandler { /// Resources embedded in the document. #[serde(default, skip_serializing_if = "ResourceMessageHandler::is_empty")] pub resources: ResourceMessageHandler, - /// Per-document `Gdd` working copy: owns the CRDT `Session` and mirrors edits to disk. `None` - /// until the mount future built by `load_document` resolves (the working-copy container is - /// constructed asynchronously). Not serialized. A clone shares the same working-copy container - /// (`Gdd` holds an `Arc`), so a cloned document still reads the live working copy. - #[serde(skip, default)] - #[derivative(Debug = "ignore")] - pub storage: Option, /// Tracks which layer occurrences are collapsed in the Layers panel, keyed by tree path. #[serde(deserialize_with = "deserialize_collapsed_layers", default)] pub collapsed: CollapsedLayers, @@ -143,12 +138,9 @@ pub struct DocumentMessageHandler { /// Path to network that is currently selected. Updated based on the most recently clicked panel. #[serde(skip)] selection_network_path: Vec, - /// Stack of document network snapshots for previous history states. + /// Undo/redo state: the legacy snapshot stacks plus the `Gdd` working-copy cursor. #[serde(skip)] - document_undo_history: VecDeque, - /// Stack of document network snapshots for future history states. - #[serde(skip)] - document_redo_history: VecDeque, + history: DocumentHistory, /// Hash of the document snapshot that was most recently saved to disk by the user. #[serde(skip)] saved_hash: Option, @@ -180,7 +172,6 @@ impl Default for DocumentMessageHandler { // ============================================ network_interface: default_document_network_interface(), resources: ResourceMessageHandler::default(), - storage: None, collapsed: CollapsedLayers::default(), commit_hash: GRAPHITE_GIT_COMMIT_HASH.to_string(), document_ptz: PTZ::default(), @@ -199,8 +190,7 @@ impl Default for DocumentMessageHandler { pending_gradient_migration: false, breadcrumb_network_path: Vec::new(), selection_network_path: Vec::new(), - document_undo_history: VecDeque::new(), - document_redo_history: VecDeque::new(), + history: DocumentHistory::default(), saved_hash: None, auto_saved_hash: None, layer_range_selection_reference: None, @@ -1025,7 +1015,7 @@ impl MessageHandler> for DocumentMes let legacy_document = document.serialize_document().into_bytes(); // Export the working copy as a .gdd with the legacy blob embedded; fall back to the bare legacy blob if there is no working copy or the export fails. - let content = match &document.storage { + let content = match document.history.storage() { Some(storage) => storage .export_to_bytes( document_format::ExportFormat::Xz, @@ -1301,11 +1291,7 @@ impl MessageHandler> for DocumentMes self.retire_storage_interaction(); self.network_interface.start_transaction(); - let network_interface_clone = self.network_interface.clone(); - self.document_undo_history.push_back(network_interface_clone); - if self.document_undo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN { - self.document_undo_history.pop_front(); - } + self.history.push_undo(self.network_interface.clone()); // Push the UpdateOpenDocumentsList message to the bus in order to update the save status of the open documents responses.add(PortfolioMessage::UpdateOpenDocumentsList); } @@ -1321,14 +1307,16 @@ impl MessageHandler> for DocumentMes }, DocumentMessage::CancelTransaction => { self.network_interface.finish_transaction(); - self.document_undo_history.pop_back(); + // No storage rollback needed: cancel only fires from the `Started` arm, where nothing was + // modified and so nothing was staged into the working copy. + self.history.discard_last_undo(); } DocumentMessage::CommitTransaction => { if self.network_interface.transaction_status() == TransactionStatus::Finished { return; } self.network_interface.finish_transaction(); - self.document_redo_history.clear(); + self.history.clear_redo(); // Stage this commit into the `Gdd` working copy. Retirement into a durable interaction happens // at the undo-step boundary (`StartTransaction`), so several commits in one user action @@ -1780,7 +1768,7 @@ impl DocumentMessageHandler { Ok(resource_registry) => document.resources.registry = resource_registry, Err(error) => log::error!("Opening .gdd: failed to rebuild resource registry: {error}"), } - document.storage = Some(storage); + document.history.set_storage(Some(storage)); document } @@ -1972,43 +1960,37 @@ impl DocumentMessageHandler { &self.selection_network_path } - /// Retire the pending staged hot ops (the current interaction) into durable Gdd history as one undo - /// unit. Called at each undo-step boundary (a new `StartTransaction`) and before undo/redo, so the - /// per-`CommitTransaction` staging coalesces into one retired interaction aligned with the legacy step. + /// The `Gdd` working copy, or `None` until the mount future resolves. + pub fn storage(&self) -> Option<&document_format::GddV1> { + self.history.storage() + } + + /// Mutable access to the `Gdd` working copy, or `None` until the mount future resolves. + pub fn storage_mut(&mut self) -> Option<&mut document_format::GddV1> { + self.history.storage_mut() + } + + /// Attach (or clear) the `Gdd` working copy once the mount future resolves. + pub fn set_storage(&mut self, storage: Option) { + self.history.set_storage(storage); + } + + /// Retire the pending staged hot ops (the current interaction) into durable Gdd history as one undo unit. pub(crate) fn retire_storage_interaction(&mut self) { - let Some(storage) = self.storage.as_mut() else { return }; - if let Err(error) = storage.retire_pending_interaction() { - log::error!("Storage interaction retirement failed: {error}"); - } + self.history.retire_storage_interaction(); } /// Stage the runtime network into the document's `Gdd` working copy at each `CommitTransaction`. /// No-op while the working copy is still unmounted (its container is built asynchronously on - /// document open); the mount picks up the current runtime state once it attaches. The staged hot ops - /// are retired into durable history by [`retire_storage_interaction`](Self::retire_storage_interaction) at - /// undo-step boundaries. Proto-node declaration bytes are persisted into `byte_store` (the app-global - /// resource cache). + /// document open); the mount picks up the current runtime state once it attaches. Gathers the + /// document-derived inputs the stage needs, then hands off to [`DocumentHistory::stage_snapshot`]. pub fn commit_storage_snapshot(&mut self, byte_store: &dyn graph_craft::application_io::resource::ResourceStorage) { use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{DocumentSettings, StorageMetadataView}; - if self.storage.is_none() { + if self.history.storage().is_none() { return; } - use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::collect_network_view_settings; - - let network = self.network_interface.document_network(); - let view = StorageMetadataView::new(&self.network_interface); - - // Per-network view state (node-graph nav + previewing) is per-peer too, so collect it (keyed by the - // stable `NetworkId` the storage layer resolves) for `session.json`. Computed before the mutable - // `storage` borrow below. - let network_view_settings = self - .storage - .as_ref() - .and_then(|storage| storage.network_ids(network, &view).ok()) - .map(|network_ids| collect_network_view_settings(&self.network_interface, &network_ids)); - // Per-peer view settings (PTZ, rulers, ...) persist in `session.json`, not the registry, so they // stay out of the CRDT/history (not undoable, not synced, may differ per viewer). let view_settings = DocumentSettings { @@ -2025,34 +2007,17 @@ impl DocumentMessageHandler { // blob and the registry snapshot describe one consistent document (no interleaved edit). let legacy_document = self.serialize_document(); - // `view` borrows disjoint `self` fields, so the mutable `storage` borrow is independent. - let storage = self.storage.as_mut().expect("checked present above"); - // Stage into the working copy without retiring: a tool drag fires several `CommitTransaction`s - // but is one legacy undo step, so the deltas accumulate as hot ops and coalesce into one retired - // interaction at the next undo-step boundary (`retire_pending_interaction`). - if let Err(error) = storage.stage_runtime_snapshot(network, &view, &self.resources.registry, byte_store) { - log::error!("Storage snapshot staging failed: {error}"); - return; - } - - if let Err(error) = storage.set_view_settings(view_settings) { - log::error!("Persisting view settings failed: {error}"); - } - - if let Some(network_view_settings) = network_view_settings - && let Err(error) = storage.set_network_view_settings(network_view_settings) - { - log::error!("Persisting per-network view settings failed: {error}"); - } - - // Dual-write soak: embed the legacy `.graphite` bytes inside the `.gdd` working copy so the new - // format can be validated against (and recovered from) the old one on open. - if let Err(error) = storage.store_legacy_document(legacy_document.as_bytes()) { - log::error!("Embedding legacy document into working copy failed: {error}"); - } + let view = StorageMetadataView::new(&self.network_interface); + let inputs = SnapshotInputs { + network: self.network_interface.document_network(), + view: &view, + interface: &self.network_interface, + registry: &self.resources.registry, + view_settings, + legacy_document, + }; - #[cfg(debug_assertions)] - self.verify_storage_round_trip(network, &view); + self.history.stage_snapshot(inputs, byte_store); } /// Restore the per-peer view settings persisted in `session.json` (the `ui::doc::*`-keyed @@ -2086,70 +2051,23 @@ impl DocumentMessageHandler { } } - /// Debug-only: stored registry should equal a fresh `from_runtime`, and a `to_runtime` of the - /// stored registry should equal the original network. Panics on drift so the dual-write soak - /// fails loud in dev (and in tests); release builds skip this entirely and autosave never crashes. - #[cfg(debug_assertions)] - fn verify_storage_round_trip( - &self, - network: &graph_craft::document::NodeNetwork, - view: &crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::StorageMetadataView, - ) { - let Some(storage) = &self.storage else { return }; - let peer = storage.session().peer(); - - let conversion = graph_storage::Registry::convert_from_runtime(network, view, &self.resources.registry, peer).expect("storage round-trip: from_runtime failed"); - let target = &conversion.registry; - let declarations = conversion.declarations().expect("storage round-trip: declaration rebuild failed"); - - let stored = storage.registry(); - assert!(stored.value_equal(target), "storage round-trip: registry value drift after commit\n{}", diff_registries(stored, target)); - assert!(stored.order_consistent(target), "storage round-trip: timestamp order inconsistent between stored and target"); - - let (round_tripped, _entries) = stored.to_runtime_with_metadata(&declarations).expect("storage round-trip: to_runtime failed"); - assert!( - &round_tripped == network, - "storage round-trip: network drift after to_runtime\n{}", - diff_networks(network, &round_tripped) - ); - } - /// Move the `Gdd` undo/redo cursor (the authoritative path) and spawn the async future that rebuilds the /// interface from the cursor and swaps it in. Synchronous: flushes the open interaction, moves the cursor /// (persisting `session.json`), then clones the post-move `Gdd` and queues the rebuild. `had_oracle` /// records whether the legacy snapshot already applied, so the completion can compare; it travels with /// the spawned message. Returns whether the cursor moved (so callers know a rebuild is pending). fn drive_storage_undo_redo(&mut self, document_id: DocumentId, resource_storage: &ResourceStorageMessageHandler, had_oracle: bool, undo: bool, responses: &mut VecDeque) -> bool { - // Flush any open interaction into retired history first: undo/redo operate on the retired chain, so - // the most recent edit must be a committed interaction before the cursor moves. - self.retire_storage_interaction(); - - let Some(storage) = self.storage.as_mut() else { return false }; - - let moved = if undo { - if !storage.can_undo() { - return false; - } - storage.undo().map(|_| ()) - } else { - if !storage.can_redo() { - return false; - } - storage.redo().map(|_| ()) - }; - if let Err(error) = moved { - log::error!("Storage undo/redo cursor move failed: {error}"); - return false; - } + // Move the cursor (flushing any open interaction first) and clone the post-move `Gdd`; `None` means + // nothing to move to, unmounted, or a failed move. + let Some(gdd) = self.history.move_cursor(undo) else { return false }; let Some(store_handle) = resource_storage.store_handle() else { log::error!("Storage undo/redo: resource storage not initialized; cannot rebuild interface"); return false; }; - // Clone the post-move `Gdd` (a `Session` snapshot at the new cursor; the container is `Arc`-shared) - // so the `'static` rebuild future reads the rewound state while the live document keeps its cursor. - let gdd = storage.clone(); + // The cloned post-move `Gdd` reads the rewound state in the `'static` rebuild future while the live + // document keeps its cursor. responses.add(crate::messages::portfolio::portfolio_message_handler::rebuild_gdd_cursor_future( gdd, store_handle, @@ -2182,7 +2100,14 @@ impl DocumentMessageHandler { // The live `Gdd` cursor's registry should match a fresh `from_runtime` of the now-swapped interface. #[cfg(debug_assertions)] - self.verify_storage_cursor_matches_runtime(); + { + use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::StorageMetadataView; + + let current_resources: std::collections::HashSet<_> = self.used_resources(false).iter().copied().collect(); + let view = StorageMetadataView::new(&self.network_interface); + self.history + .verify_cursor_matches_runtime(self.network_interface.document_network(), &view, &self.resources.registry, ¤t_resources); + } responses.add(PortfolioMessage::UpdateOpenDocumentsList); responses.add(NodeGraphMessage::SelectedNodesUpdated); @@ -2204,42 +2129,6 @@ impl DocumentMessageHandler { } } - /// Debug-only: after a `Gdd` cursor move, the cursor's registry should equal a fresh `from_runtime` - /// of the current (legacy-restored) interface. Logs drift (see the call site for why it does not - /// panic) so cursor bugs surface in dev without crashing the editor. - #[cfg(debug_assertions)] - fn verify_storage_cursor_matches_runtime(&self) { - use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::StorageMetadataView; - - let Some(storage) = &self.storage else { return }; - let peer = storage.session().peer(); - - let network = self.network_interface.document_network(); - let view = StorageMetadataView::new(&self.network_interface); - let Ok(mut conversion) = graph_storage::Registry::convert_from_runtime(network, &view, &self.resources.registry, peer) else { - log::error!("undo/redo shadow: from_runtime failed"); - return; - }; - - let stored = storage.registry(); - - // The runtime keeps resources alive while they're referenced by undo/redo history (so legacy redo - // can restore them), but the `Gdd` cursor reverts the interaction's `AddResource`. So a resource the - // runtime carries which the cursor dropped is expected, not drift, as long as it's history-only - // (not referenced by the current network). Drop those from the conversion before comparing. - let current_resources: std::collections::HashSet<_> = self.used_resources(false).iter().copied().collect(); - conversion.registry.resources.retain(|id, _| stored.resources.contains_key(id) || current_resources.contains(id)); - - if !stored.value_equal(&conversion.registry) { - // Logged, not panicked: any remaining drift is a real cursor or conversion bug to triage, but - // the shadow must not crash the live editor while we harden it toward a hard panic. - log::error!( - "undo/redo shadow: cursor registry diverged from the restored interface\n{}", - diff_registries(stored, &conversion.registry) - ); - } - } - pub fn serialize_document(&self) -> String { let val = serde_json::to_string(self); // We fully expect the serialization to succeed @@ -2524,10 +2413,7 @@ impl DocumentMessageHandler { // an across-reopen undo where the legacy stack is empty but the persisted `Gdd` cursor can still // move), the rebuild overwrites with no comparison. let legacy_applied = if let Some(previous_network) = self.undo(viewport, responses) { - self.document_redo_history.push_back(previous_network); - if self.document_redo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN { - self.document_redo_history.pop_front(); - } + self.history.push_redo(previous_network); true } else { false @@ -2538,7 +2424,7 @@ impl DocumentMessageHandler { pub fn undo(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque) -> Option { // If there is no history return and don't broadcast SelectionChanged - let mut network_interface = self.document_undo_history.pop_back()?; + let mut network_interface = self.history.pop_undo()?; // Set the previous network navigation metadata to the current navigation metadata network_interface.copy_all_navigation_metadata(&self.network_interface); @@ -2567,10 +2453,7 @@ impl DocumentMessageHandler { // Mirror `undo_with_history`: apply the legacy snapshot synchronously (oracle), then move the `Gdd` // cursor and spawn the authoritative async rebuild. let legacy_applied = if let Some(previous_network) = self.redo(viewport, responses) { - self.document_undo_history.push_back(previous_network); - if self.document_undo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN { - self.document_undo_history.pop_front(); - } + self.history.push_undo(previous_network); true } else { false @@ -2581,7 +2464,7 @@ impl DocumentMessageHandler { pub fn redo(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque) -> Option { // If there is no history return and don't broadcast SelectionChanged - let mut network_interface = self.document_redo_history.pop_back()?; + let mut network_interface = self.history.pop_redo()?; // Set the previous network navigation metadata to the current navigation metadata network_interface.copy_all_navigation_metadata(&self.network_interface); @@ -3916,8 +3799,7 @@ impl DocumentMessageHandler { let mut resources = HashSet::new(); self.network_interface.collect_used_resources(&mut resources); if include_history { - self.document_undo_history.iter().for_each(|interface| interface.collect_used_resources(&mut resources)); - self.document_redo_history.iter().for_each(|interface| interface.collect_used_resources(&mut resources)); + self.history.collect_used_resources(&mut resources); } resources.into_iter().collect::>().into_boxed_slice() } diff --git a/editor/src/messages/portfolio/document/mod.rs b/editor/src/messages/portfolio/document/mod.rs index 8e21b913a6..ff473500fe 100644 --- a/editor/src/messages/portfolio/document/mod.rs +++ b/editor/src/messages/portfolio/document/mod.rs @@ -1,6 +1,9 @@ +mod document_history; mod document_message; mod document_message_handler; #[cfg(test)] +mod storage_metadata_tests; +#[cfg(test)] mod storage_round_trip_tests; pub mod data_panel; @@ -12,6 +15,7 @@ pub mod properties_panel; pub mod resource; pub mod utility_types; +pub(crate) use document_history::DocumentHistory; #[doc(inline)] pub use document_message::{DocumentMessage, DocumentMessageDiscriminant}; pub(crate) use document_message_handler::diff_networks; diff --git a/editor/src/messages/portfolio/document/storage_metadata_tests.rs b/editor/src/messages/portfolio/document/storage_metadata_tests.rs new file mode 100644 index 0000000000..8ea6e2103b --- /dev/null +++ b/editor/src/messages/portfolio/document/storage_metadata_tests.rs @@ -0,0 +1,345 @@ +//! Conversion-level round-trip tests for the [`storage_metadata`](super::utility_types::network_interface::storage_metadata) +//! bridge: drive a demo `.graphite` document through `Registry` (and back through +//! `build_interface_from_storage`) without an actual save/reopen, asserting the editor's `ui::*` +//! metadata survives the conversion. The end-to-end save/reopen pipeline is covered separately in +//! [`storage_round_trip_tests`](super::storage_round_trip_tests). + +use std::collections::HashMap; + +use graph_storage::{NodeMetadataSource, PeerId, Registry}; + +use crate::messages::portfolio::document::document_message_handler::DocumentMessageHandler; +use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; +use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{DocumentSettings, StorageMetadataView, build_interface_from_storage}; +use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers; +use graph_craft::document::NodeId; +use graphene_std::vector::style::RenderMode; + +/// Load a demo `.graphite` straight into a `DocumentMessageHandler` for inspection. +fn load_demo(file_name: &str) -> DocumentMessageHandler { + let path = format!("../demo-artwork/{file_name}"); + let content = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("Failed to read {path}: {e}")); + DocumentMessageHandler::deserialize_document(&content).unwrap_or_else(|e| panic!("Failed to deserialize {path}: {e:?}")) +} + +/// Walk every node in every nested network and collect `(network_path, local_id)` pairs so the +/// test can iterate every node addressable from the metadata side. +fn collect_all_node_paths(interface: &NodeNetworkInterface) -> Vec<(Vec, NodeId)> { + fn walk(interface: &NodeNetworkInterface, path: Vec, out: &mut Vec<(Vec, NodeId)>) { + let Some(network) = interface.nested_network(&path) else { return }; + for (&local_id, node) in &network.nodes { + out.push((path.clone(), local_id)); + + if matches!(&node.implementation, graph_craft::document::DocumentNodeImplementation::Network(_)) { + let mut child = path.clone(); + child.push(local_id); + walk(interface, child, out); + } + } + } + + let mut out = Vec::new(); + walk(interface, Vec::new(), &mut out); + out +} + +/// Loads a demo artwork, round-trips its `NodeNetwork + NodeNetworkInterface metadata` through +/// `Registry`, and asserts every node's `ui::*` attributes survive unchanged. +/// +/// Uses one demo (rather than the full set) because this is an exhaustive per-node check. +#[test] +fn editor_metadata_round_trip_against_demo() { + let document = load_demo("changing-seasons.graphite"); + let interface = &document.network_interface; + let source = StorageMetadataView::new(interface); + + let network = interface.document_network().clone(); + + let conversion = Registry::convert_from_runtime(&network, &source, &Default::default(), PeerId(0)).expect("convert_from_runtime failed"); + let declarations = conversion.declarations().expect("rebuild declarations"); + let registry = conversion.registry; + + let (_converted_network, entries) = registry.to_runtime_with_metadata(&declarations).expect("to_runtime_with_metadata failed"); + + // Index emitted entries by their (network_path, local_id) address. + let entries_by_address: HashMap<(Vec, NodeId), &graph_storage::NodeMetadataEntry> = entries.iter().map(|e| ((e.network_path.clone(), e.local_id), e)).collect(); + + let mut checked_any_position = false; + let mut checked_any_layer = false; + let mut checked_any_input_metadata = false; + + for (network_path, local_id) in collect_all_node_paths(interface) { + let expected_position = source.position(&network_path, local_id); + let expected_is_layer = source.is_layer(&network_path, local_id); + let expected_display = source.display_name(&network_path, local_id).map(str::to_owned); + let expected_locked = source.locked(&network_path, local_id); + let expected_pinned = source.pinned(&network_path, local_id); + let expected_output_names = source.output_names(&network_path, local_id); + + // Pull per-input metadata directly off the runtime side. We use the runtime invariant + // (`input_metadata.len() == inputs.len()`) as the iteration bound rather than calling the + // trait per index, since the trait would return `None` past the end and we want a strict + // slot-by-slot comparison. + let input_count = interface.document_node(&local_id, &network_path).map(|node| node.inputs.len()).unwrap_or(0); + let any_input_metadata_present = (0..input_count).any(|i| { + source.input_name(&network_path, local_id, i).is_some_and(|s| !s.is_empty()) + || source.input_description(&network_path, local_id, i).is_some_and(|s| !s.is_empty()) + || source.widget_override(&network_path, local_id, i).is_some() + || !source.input_data(&network_path, local_id, i).is_empty() + }); + + let any_metadata = expected_position.is_some() + || expected_is_layer + || expected_display.as_deref().is_some_and(|s| !s.is_empty()) + || expected_locked + || expected_pinned + || any_input_metadata_present + || !expected_output_names.is_empty(); + + if !any_metadata { + assert!( + !entries_by_address.contains_key(&(network_path.clone(), local_id)), + "node {local_id:?} in network {network_path:?} has no editor metadata but produced an entry" + ); + continue; + } + + let entry = entries_by_address + .get(&(network_path.clone(), local_id)) + .unwrap_or_else(|| panic!("missing entry for node {local_id:?} in network {network_path:?}")); + + assert_eq!(entry.position, expected_position, "position mismatch for node {local_id:?} in network {network_path:?}"); + assert_eq!(entry.is_layer, expected_is_layer, "is_layer mismatch for node {local_id:?} in network {network_path:?}"); + // The trait round-trip drops empty display names (treats them as "unset") so the entry's + // `display_name` is `None` when the runtime carries `""`. Normalize before comparing. + let normalized_expected_display = expected_display.as_deref().filter(|s| !s.is_empty()).map(str::to_owned); + assert_eq!( + entry.display_name, normalized_expected_display, + "display_name mismatch for node {local_id:?} in network {network_path:?}" + ); + assert_eq!(entry.locked, expected_locked, "locked mismatch for node {local_id:?} in network {network_path:?}"); + assert_eq!(entry.pinned, expected_pinned, "pinned mismatch for node {local_id:?} in network {network_path:?}"); + assert_eq!(entry.output_names, expected_output_names, "output_names mismatch for node {local_id:?} in network {network_path:?}"); + + // Per-input metadata: the entry's `input_metadata` vec has the same length as the node's + // input slot count, and each slot matches the trait's per-field view (with the same + // "empty string ↔ None" normalization as `display_name`). + assert_eq!( + entry.input_metadata.len(), + input_count, + "input_metadata length mismatch for node {local_id:?} in network {network_path:?}: entry has {}, node has {input_count}", + entry.input_metadata.len(), + ); + for (index, input_entry) in entry.input_metadata.iter().enumerate() { + let expected_name = source.input_name(&network_path, local_id, index).filter(|s| !s.is_empty()).map(str::to_owned); + let expected_description = source.input_description(&network_path, local_id, index).filter(|s| !s.is_empty()).map(str::to_owned); + let expected_widget = source.widget_override(&network_path, local_id, index).map(str::to_owned); + let expected_data = source.input_data(&network_path, local_id, index); + + assert_eq!( + input_entry.input_name, expected_name, + "input_name mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + input_entry.input_description, expected_description, + "input_description mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + input_entry.widget_override, expected_widget, + "widget_override mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + input_entry.input_data, expected_data, + "input_data mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + } + + if entry.position.is_some() { + checked_any_position = true; + } + if entry.is_layer { + checked_any_layer = true; + } + if any_input_metadata_present { + checked_any_input_metadata = true; + } + } + + // Sanity: a real artwork should exercise at least these two shapes; otherwise the test is + // just iterating empty metadata and proving nothing. + assert!(checked_any_position, "demo artwork produced no positioned nodes — fixture is wrong or extraction is broken"); + assert!(checked_any_layer, "demo artwork produced no layer nodes — fixture is wrong or extraction is broken"); + // Demo artworks always have some custom widget overrides / input names; if not, the + // per-input round-trip isn't actually being exercised here. + assert!(checked_any_input_metadata, "demo artwork produced no per-input metadata — fixture is wrong or extraction is broken"); +} + +/// Full editor-side round-trip: original interface → Registry → (NodeNetwork, Vec) → +/// freshly-built interface. Asserts the rebuilt interface presents the same `ui::*` state as +/// the original when read through `StorageMetadataView`. +#[test] +fn editor_interface_rebuild_round_trip() { + let document = load_demo("changing-seasons.graphite"); + let original = &document.network_interface; + let original_view = StorageMetadataView::new(original); + + let network = original.document_network().clone(); + let conversion = Registry::convert_from_runtime(&network, &original_view, &Default::default(), PeerId(0)).expect("convert_from_runtime failed"); + let declarations = conversion.declarations().expect("rebuild declarations"); + let registry = conversion.registry; + let (rebuilt_network, node_entries, network_entries) = registry.to_runtime_with_full_metadata(&declarations).expect("to_runtime_with_full_metadata failed"); + + let rebuilt = build_interface_from_storage(rebuilt_network, node_entries, network_entries).expect("build_interface_from_storage failed"); + let rebuilt_view = StorageMetadataView::new(&rebuilt); + + // Every node the original carried must also resolve identically through the rebuilt view. + // Iterating over the *rebuilt* interface verifies that the rebuild covered every node, not + // just the ones the entries vec mentioned. + for (network_path, local_id) in collect_all_node_paths(&rebuilt) { + assert_eq!( + rebuilt_view.position(&network_path, local_id), + original_view.position(&network_path, local_id), + "position mismatch for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.is_layer(&network_path, local_id), + original_view.is_layer(&network_path, local_id), + "is_layer mismatch for node {local_id:?} in network {network_path:?}" + ); + // Original display names are returned by the source as-is (including `""`). After + // round-trip the rebuilt interface also stores `""` for nodes that had no name set, + // so this comparison is exact. + assert_eq!( + rebuilt_view.display_name(&network_path, local_id), + original_view.display_name(&network_path, local_id), + "display_name mismatch for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.locked(&network_path, local_id), + original_view.locked(&network_path, local_id), + "locked mismatch for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.pinned(&network_path, local_id), + original_view.pinned(&network_path, local_id), + "pinned mismatch for node {local_id:?} in network {network_path:?}" + ); + + // Per-input metadata: walk both interfaces' input slots in lockstep. + let original_inputs = original.document_node(&local_id, &network_path).map(|n| n.inputs.len()).unwrap_or(0); + let rebuilt_inputs = rebuilt.document_node(&local_id, &network_path).map(|n| n.inputs.len()).unwrap_or(0); + assert_eq!( + rebuilt_inputs, original_inputs, + "input count mismatch for node {local_id:?} in network {network_path:?}: rebuilt={rebuilt_inputs} original={original_inputs}" + ); + + for index in 0..rebuilt_inputs { + assert_eq!( + rebuilt_view.input_name(&network_path, local_id, index), + original_view.input_name(&network_path, local_id, index), + "input_name mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.input_description(&network_path, local_id, index), + original_view.input_description(&network_path, local_id, index), + "input_description mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.widget_override(&network_path, local_id, index), + original_view.widget_override(&network_path, local_id, index), + "widget_override mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + assert_eq!( + rebuilt_view.input_data(&network_path, local_id, index), + original_view.input_data(&network_path, local_id, index), + "input_data mismatch at slot {index} for node {local_id:?} in network {network_path:?}" + ); + } + + assert_eq!( + rebuilt_view.output_names(&network_path, local_id), + original_view.output_names(&network_path, local_id), + "output_names mismatch for node {local_id:?} in network {network_path:?}" + ); + } + + // Symmetric: every node in the original must also exist in the rebuilt interface. + for (network_path, local_id) in collect_all_node_paths(original) { + assert!( + rebuilt.nested_network(&network_path).and_then(|n| n.nodes.get(&local_id)).is_some(), + "original node {local_id:?} in network {network_path:?} missing after rebuild" + ); + } + + // Per-network metadata: every nested network path (including the root) must resolve to the same + // `reference` in the rebuilt interface. Node-graph nav + previewing are per-peer view state that + // lives in `session.json`, not the registry, so they're not round-tripped here. + let mut network_paths_to_check: Vec> = vec![Vec::new()]; + for (network_path, local_id) in collect_all_node_paths(original) { + let mut child = network_path.clone(); + child.push(local_id); + if original.nested_network(&child).is_some() { + network_paths_to_check.push(child); + } + } + + let mut checked_any_reference = false; + for path in &network_paths_to_check { + assert_eq!(rebuilt_view.reference(path), original_view.reference(path), "reference mismatch for network {path:?}"); + if original_view.reference(path).is_some() { + checked_any_reference = true; + } + } + + assert!(checked_any_reference, "demo artwork produced no reference metadata — fixture is wrong or extraction is broken"); +} + +/// Per-peer view settings (`ui::doc::*`) survive the `session.json` round-trip: serialize them into +/// the view map, then apply it onto a fresh handler and confirm each field matches. +#[test] +fn document_settings_round_trip() { + let mut document = load_demo("changing-seasons.graphite"); + + // Set distinctive, non-default values so the round-trip proves real data moved, not defaults. + document.render_mode = RenderMode::Outline; + document.rulers_visible = false; + document.collapsed = CollapsedLayers(vec![vec![NodeId(7)], vec![NodeId(7), NodeId(42)]]); + + let view_settings = DocumentSettings { + document_ptz: &document.document_ptz, + render_mode: &document.render_mode, + overlays_visibility: &document.overlays_visibility_settings, + rulers_visible: document.rulers_visible, + snapping_state: &document.snapping_state, + collapsed: &document.collapsed, + } + .to_view_map(); + + // Apply the serialized view settings onto a fresh handler and compare each field's serialized + // form (avoids requiring `PartialEq` on every setting type). + let mut restored = DocumentMessageHandler::default(); + restored.apply_stored_document_settings(&view_settings); + + assert_eq!(serde_json::to_value(restored.render_mode).unwrap(), serde_json::to_value(document.render_mode).unwrap(), "render_mode"); + assert_eq!( + serde_json::to_value(restored.rulers_visible).unwrap(), + serde_json::to_value(document.rulers_visible).unwrap(), + "rulers_visible" + ); + assert_eq!( + serde_json::to_value(restored.document_ptz).unwrap(), + serde_json::to_value(document.document_ptz).unwrap(), + "document_ptz" + ); + assert_eq!( + serde_json::to_value(restored.overlays_visibility_settings).unwrap(), + serde_json::to_value(document.overlays_visibility_settings).unwrap(), + "overlays" + ); + assert_eq!( + serde_json::to_value(restored.snapping_state).unwrap(), + serde_json::to_value(document.snapping_state).unwrap(), + "snapping_state" + ); + assert_eq!(serde_json::to_value(restored.collapsed).unwrap(), serde_json::to_value(document.collapsed).unwrap(), "collapsed"); +} diff --git a/editor/src/messages/portfolio/document/storage_round_trip_tests.rs b/editor/src/messages/portfolio/document/storage_round_trip_tests.rs index b2f9f8fee3..a3c0d0d37c 100644 --- a/editor/src/messages/portfolio/document/storage_round_trip_tests.rs +++ b/editor/src/messages/portfolio/document/storage_round_trip_tests.rs @@ -239,7 +239,7 @@ async fn edit_after_open_commits_cleanly() { { let document = editor.active_document_mut(); document.network_interface = rebuilt; - document.storage = Some(reopened); + document.set_storage(Some(reopened)); document.finalize_storage_load(); } @@ -257,11 +257,13 @@ async fn edit_after_open_commits_cleanly() { // `commit_storage_snapshot` only logs commit failures, so assert on the underlying `commit_from_runtime` // result directly to make the deletion commit a hard test failure rather than a silent log line. let document = editor.active_document_mut(); - let network = document.network_interface.document_network().clone(); - let view = StorageMetadataView::new(&document.network_interface); + // Clone the interface for the metadata view so it doesn't borrow `document` across the mutable + // `storage_mut()` borrow below. + let interface = document.network_interface.clone(); + let network = interface.document_network().clone(); + let view = StorageMetadataView::new(&interface); document - .storage - .as_mut() + .storage_mut() .expect("storage mounted") .commit_from_runtime(&network, &view, &Default::default(), &byte_store) .expect("commit after deleting a layer post-open must not fail"); @@ -616,7 +618,7 @@ async fn undo_image_paste_resources_subset_of_runtime() { editor.handle_message(DocumentMessage::Undo).await; let document = editor.active_document(); - let storage = document.storage.as_ref().expect("storage mounted"); + let storage = document.storage().expect("storage mounted"); let stored: std::collections::BTreeSet<_> = storage.registry().resources.keys().copied().collect(); // Every resource the restored network still references must be in the cursor. @@ -689,7 +691,7 @@ async fn undo_twice_steps_cursor_two_interactions() { // Back at the loaded base, neither path should be able to undo further: the load is not an undo step. // If the `Gdd` retired the mount base as its own interaction, the cursor can still undo here while legacy // cannot, which is the off-by-one that makes the cursor lag the legacy path by a interaction. - let storage = editor.active_document().storage.as_ref().expect("storage mounted"); + let storage = editor.active_document().storage().expect("storage mounted"); assert!(!storage.can_undo(), "cursor must not undo past the loaded base (the load is not an undo step)"); } @@ -699,7 +701,7 @@ async fn undo_twice_steps_cursor_two_interactions() { /// redo) while the cursor reverts the interaction's `AddResource`, so the runtime's resource set is allowed /// to be a superset of the cursor's, but a resource the current network references must be present. fn assert_cursor_matches_runtime(document: &DocumentMessageHandler, at: &str) { - let storage = document.storage.as_ref().expect("storage mounted"); + let storage = document.storage().expect("storage mounted"); let peer = storage.session().peer(); let network = document.network_interface.document_network().clone(); @@ -731,7 +733,7 @@ async fn mount_in_memory_storage(editor: &mut EditorTestUtils) -> HashMapResourc let gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0x5EED, "test".into(), "test".into()) .await .expect("create_in"); - editor.active_document_mut().storage = Some(gdd); + editor.active_document_mut().set_storage(Some(gdd)); HashMapResourceStorage::new() } @@ -757,7 +759,7 @@ async fn demo_artwork_edit_autosaves_and_round_trips() { // First autosave: captures the loaded document. `verify_storage_round_trip` panics on drift. editor.active_document_mut().commit_storage_snapshot(&byte_store); - let history_after_open = editor.active_document().storage.as_ref().unwrap().session().history().count(); + let history_after_open = editor.active_document().storage().unwrap().session().history().count(); // Snapshot the network, then make a real modification. let before_edit = editor.active_document().network_interface.document_network().clone(); @@ -767,7 +769,7 @@ async fn demo_artwork_edit_autosaves_and_round_trips() { // Second autosave: again verifies the round-trip, and the edit must produce new retired history. editor.active_document_mut().commit_storage_snapshot(&byte_store); - let history_after_edit = editor.active_document().storage.as_ref().unwrap().session().history().count(); + let history_after_edit = editor.active_document().storage().unwrap().session().history().count(); assert!( history_after_edit > history_after_open, "committing an edit should append retired deltas: {history_after_open} -> {history_after_edit}" diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs index f4d0e3b3f1..3002715baa 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/storage_metadata.rs @@ -1,5 +1,5 @@ -//! Bridge between `NodeNetworkInterface` and `graph-storage`'s `NodeMetadataSource` trait, plus -//! an integration round-trip test against a demo `.graphite` document. +//! Bridge between `NodeNetworkInterface` and `graph-storage`'s `NodeMetadataSource` trait. +//! Conversion round-trip tests live in `storage_metadata_tests`. //! //! The trait impl lives on the [`StorageMetadataView`] wrapper (not on `NodeNetworkInterface` //! directly) because several trait method names collide with inherent methods, and Rust silently @@ -382,342 +382,3 @@ fn input_metadata_entry_to_runtime(entry: InputMetadataEntry) -> InputMetadata { ..Default::default() } } - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use graph_storage::{NodeMetadataSource, PeerId, Registry}; - - use super::*; - use crate::messages::portfolio::document::document_message_handler::DocumentMessageHandler; - - /// Load a demo `.graphite` straight into a `DocumentMessageHandler` for inspection. - fn load_demo(file_name: &str) -> DocumentMessageHandler { - let path = format!("../demo-artwork/{file_name}"); - let content = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("Failed to read {path}: {e}")); - DocumentMessageHandler::deserialize_document(&content).unwrap_or_else(|e| panic!("Failed to deserialize {path}: {e:?}")) - } - - /// Walk every node in every nested network and collect `(network_path, local_id)` pairs so the - /// test can iterate every node addressable from the metadata side. - fn collect_all_node_paths(interface: &NodeNetworkInterface) -> Vec<(Vec, NodeId)> { - fn walk(interface: &NodeNetworkInterface, path: Vec, out: &mut Vec<(Vec, NodeId)>) { - let Some(network) = interface.nested_network(&path) else { return }; - for (&local_id, node) in &network.nodes { - out.push((path.clone(), local_id)); - - if matches!(&node.implementation, graph_craft::document::DocumentNodeImplementation::Network(_)) { - let mut child = path.clone(); - child.push(local_id); - walk(interface, child, out); - } - } - } - - let mut out = Vec::new(); - walk(interface, Vec::new(), &mut out); - out - } - - /// Loads a demo artwork, round-trips its `NodeNetwork + NodeNetworkInterface metadata` through - /// `Registry`, and asserts every node's `ui::*` attributes survive unchanged. - /// - /// Uses one demo (rather than the full set) because this is an exhaustive per-node check. - #[test] - fn editor_metadata_round_trip_against_demo() { - let document = load_demo("changing-seasons.graphite"); - let interface = &document.network_interface; - let source = StorageMetadataView::new(interface); - - let network = interface.document_network().clone(); - - let conversion = Registry::convert_from_runtime(&network, &source, &Default::default(), PeerId(0)).expect("convert_from_runtime failed"); - let declarations = conversion.declarations().expect("rebuild declarations"); - let registry = conversion.registry; - - let (_converted_network, entries) = registry.to_runtime_with_metadata(&declarations).expect("to_runtime_with_metadata failed"); - - // Index emitted entries by their (network_path, local_id) address. - let entries_by_address: HashMap<(Vec, NodeId), &graph_storage::NodeMetadataEntry> = entries.iter().map(|e| ((e.network_path.clone(), e.local_id), e)).collect(); - - let mut checked_any_position = false; - let mut checked_any_layer = false; - let mut checked_any_input_metadata = false; - - for (network_path, local_id) in collect_all_node_paths(interface) { - let expected_position = source.position(&network_path, local_id); - let expected_is_layer = source.is_layer(&network_path, local_id); - let expected_display = source.display_name(&network_path, local_id).map(str::to_owned); - let expected_locked = source.locked(&network_path, local_id); - let expected_pinned = source.pinned(&network_path, local_id); - let expected_output_names = source.output_names(&network_path, local_id); - - // Pull per-input metadata directly off the runtime side. We use the runtime invariant - // (`input_metadata.len() == inputs.len()`) as the iteration bound rather than calling the - // trait per index, since the trait would return `None` past the end and we want a strict - // slot-by-slot comparison. - let input_count = interface.document_node(&local_id, &network_path).map(|node| node.inputs.len()).unwrap_or(0); - let any_input_metadata_present = (0..input_count).any(|i| { - source.input_name(&network_path, local_id, i).is_some_and(|s| !s.is_empty()) - || source.input_description(&network_path, local_id, i).is_some_and(|s| !s.is_empty()) - || source.widget_override(&network_path, local_id, i).is_some() - || !source.input_data(&network_path, local_id, i).is_empty() - }); - - let any_metadata = expected_position.is_some() - || expected_is_layer - || expected_display.as_deref().is_some_and(|s| !s.is_empty()) - || expected_locked - || expected_pinned - || any_input_metadata_present - || !expected_output_names.is_empty(); - - if !any_metadata { - assert!( - entries_by_address.get(&(network_path.clone(), local_id)).is_none(), - "node {local_id:?} in network {network_path:?} has no editor metadata but produced an entry" - ); - continue; - } - - let entry = entries_by_address - .get(&(network_path.clone(), local_id)) - .unwrap_or_else(|| panic!("missing entry for node {local_id:?} in network {network_path:?}")); - - assert_eq!(entry.position, expected_position, "position mismatch for node {local_id:?} in network {network_path:?}"); - assert_eq!(entry.is_layer, expected_is_layer, "is_layer mismatch for node {local_id:?} in network {network_path:?}"); - // The trait round-trip drops empty display names (treats them as "unset") so the entry's - // `display_name` is `None` when the runtime carries `""`. Normalize before comparing. - let normalized_expected_display = expected_display.as_deref().filter(|s| !s.is_empty()).map(str::to_owned); - assert_eq!( - entry.display_name, normalized_expected_display, - "display_name mismatch for node {local_id:?} in network {network_path:?}" - ); - assert_eq!(entry.locked, expected_locked, "locked mismatch for node {local_id:?} in network {network_path:?}"); - assert_eq!(entry.pinned, expected_pinned, "pinned mismatch for node {local_id:?} in network {network_path:?}"); - assert_eq!(entry.output_names, expected_output_names, "output_names mismatch for node {local_id:?} in network {network_path:?}"); - - // Per-input metadata: the entry's `input_metadata` vec has the same length as the node's - // input slot count, and each slot matches the trait's per-field view (with the same - // "empty string ↔ None" normalization as `display_name`). - assert_eq!( - entry.input_metadata.len(), - input_count, - "input_metadata length mismatch for node {local_id:?} in network {network_path:?}: entry has {}, node has {input_count}", - entry.input_metadata.len(), - ); - for (index, input_entry) in entry.input_metadata.iter().enumerate() { - let expected_name = source.input_name(&network_path, local_id, index).filter(|s| !s.is_empty()).map(str::to_owned); - let expected_description = source.input_description(&network_path, local_id, index).filter(|s| !s.is_empty()).map(str::to_owned); - let expected_widget = source.widget_override(&network_path, local_id, index).map(str::to_owned); - let expected_data = source.input_data(&network_path, local_id, index); - - assert_eq!( - input_entry.input_name, expected_name, - "input_name mismatch at slot {index} for node {local_id:?} in network {network_path:?}" - ); - assert_eq!( - input_entry.input_description, expected_description, - "input_description mismatch at slot {index} for node {local_id:?} in network {network_path:?}" - ); - assert_eq!( - input_entry.widget_override, expected_widget, - "widget_override mismatch at slot {index} for node {local_id:?} in network {network_path:?}" - ); - assert_eq!( - input_entry.input_data, expected_data, - "input_data mismatch at slot {index} for node {local_id:?} in network {network_path:?}" - ); - } - - if entry.position.is_some() { - checked_any_position = true; - } - if entry.is_layer { - checked_any_layer = true; - } - if any_input_metadata_present { - checked_any_input_metadata = true; - } - } - - // Sanity: a real artwork should exercise at least these two shapes; otherwise the test is - // just iterating empty metadata and proving nothing. - assert!(checked_any_position, "demo artwork produced no positioned nodes — fixture is wrong or extraction is broken"); - assert!(checked_any_layer, "demo artwork produced no layer nodes — fixture is wrong or extraction is broken"); - // Demo artworks always have some custom widget overrides / input names; if not, the - // per-input round-trip isn't actually being exercised here. - assert!(checked_any_input_metadata, "demo artwork produced no per-input metadata — fixture is wrong or extraction is broken"); - } - - /// Full editor-side round-trip: original interface → Registry → (NodeNetwork, Vec) → - /// freshly-built interface. Asserts the rebuilt interface presents the same `ui::*` state as - /// the original when read through `StorageMetadataView`. - #[test] - fn editor_interface_rebuild_round_trip() { - let document = load_demo("changing-seasons.graphite"); - let original = &document.network_interface; - let original_view = StorageMetadataView::new(original); - - let network = original.document_network().clone(); - let conversion = Registry::convert_from_runtime(&network, &original_view, &Default::default(), PeerId(0)).expect("convert_from_runtime failed"); - let declarations = conversion.declarations().expect("rebuild declarations"); - let registry = conversion.registry; - let (rebuilt_network, node_entries, network_entries) = registry.to_runtime_with_full_metadata(&declarations).expect("to_runtime_with_full_metadata failed"); - - let rebuilt = build_interface_from_storage(rebuilt_network, node_entries, network_entries).expect("build_interface_from_storage failed"); - let rebuilt_view = StorageMetadataView::new(&rebuilt); - - // Every node the original carried must also resolve identically through the rebuilt view. - // Iterating over the *rebuilt* interface verifies that the rebuild covered every node, not - // just the ones the entries vec mentioned. - for (network_path, local_id) in collect_all_node_paths(&rebuilt) { - assert_eq!( - rebuilt_view.position(&network_path, local_id), - original_view.position(&network_path, local_id), - "position mismatch for node {local_id:?} in network {network_path:?}" - ); - assert_eq!( - rebuilt_view.is_layer(&network_path, local_id), - original_view.is_layer(&network_path, local_id), - "is_layer mismatch for node {local_id:?} in network {network_path:?}" - ); - // Original display names are returned by the source as-is (including `""`). After - // round-trip the rebuilt interface also stores `""` for nodes that had no name set, - // so this comparison is exact. - assert_eq!( - rebuilt_view.display_name(&network_path, local_id), - original_view.display_name(&network_path, local_id), - "display_name mismatch for node {local_id:?} in network {network_path:?}" - ); - assert_eq!( - rebuilt_view.locked(&network_path, local_id), - original_view.locked(&network_path, local_id), - "locked mismatch for node {local_id:?} in network {network_path:?}" - ); - assert_eq!( - rebuilt_view.pinned(&network_path, local_id), - original_view.pinned(&network_path, local_id), - "pinned mismatch for node {local_id:?} in network {network_path:?}" - ); - - // Per-input metadata: walk both interfaces' input slots in lockstep. - let original_inputs = original.document_node(&local_id, &network_path).map(|n| n.inputs.len()).unwrap_or(0); - let rebuilt_inputs = rebuilt.document_node(&local_id, &network_path).map(|n| n.inputs.len()).unwrap_or(0); - assert_eq!( - rebuilt_inputs, original_inputs, - "input count mismatch for node {local_id:?} in network {network_path:?}: rebuilt={rebuilt_inputs} original={original_inputs}" - ); - - for index in 0..rebuilt_inputs { - assert_eq!( - rebuilt_view.input_name(&network_path, local_id, index), - original_view.input_name(&network_path, local_id, index), - "input_name mismatch at slot {index} for node {local_id:?} in network {network_path:?}" - ); - assert_eq!( - rebuilt_view.input_description(&network_path, local_id, index), - original_view.input_description(&network_path, local_id, index), - "input_description mismatch at slot {index} for node {local_id:?} in network {network_path:?}" - ); - assert_eq!( - rebuilt_view.widget_override(&network_path, local_id, index), - original_view.widget_override(&network_path, local_id, index), - "widget_override mismatch at slot {index} for node {local_id:?} in network {network_path:?}" - ); - assert_eq!( - rebuilt_view.input_data(&network_path, local_id, index), - original_view.input_data(&network_path, local_id, index), - "input_data mismatch at slot {index} for node {local_id:?} in network {network_path:?}" - ); - } - - assert_eq!( - rebuilt_view.output_names(&network_path, local_id), - original_view.output_names(&network_path, local_id), - "output_names mismatch for node {local_id:?} in network {network_path:?}" - ); - } - - // Symmetric: every node in the original must also exist in the rebuilt interface. - for (network_path, local_id) in collect_all_node_paths(original) { - assert!( - rebuilt.nested_network(&network_path).and_then(|n| n.nodes.get(&local_id)).is_some(), - "original node {local_id:?} in network {network_path:?} missing after rebuild" - ); - } - - // Per-network metadata: every nested network path (including the root) must resolve to the same - // `reference` in the rebuilt interface. Node-graph nav + previewing are per-peer view state that - // lives in `session.json`, not the registry, so they're not round-tripped here. - let mut network_paths_to_check: Vec> = vec![Vec::new()]; - for (network_path, local_id) in collect_all_node_paths(original) { - let mut child = network_path.clone(); - child.push(local_id); - if original.nested_network(&child).is_some() { - network_paths_to_check.push(child); - } - } - - let mut checked_any_reference = false; - for path in &network_paths_to_check { - assert_eq!(rebuilt_view.reference(path), original_view.reference(path), "reference mismatch for network {path:?}"); - if original_view.reference(path).is_some() { - checked_any_reference = true; - } - } - - assert!(checked_any_reference, "demo artwork produced no reference metadata — fixture is wrong or extraction is broken"); - } - - /// Per-peer view settings (`ui::doc::*`) survive the `session.json` round-trip: serialize them into - /// the view map, then apply it onto a fresh handler and confirm each field matches. - #[test] - fn document_settings_round_trip() { - let mut document = load_demo("changing-seasons.graphite"); - - // Set distinctive, non-default values so the round-trip proves real data moved, not defaults. - document.render_mode = RenderMode::Outline; - document.rulers_visible = false; - document.collapsed = CollapsedLayers(vec![vec![NodeId(7)], vec![NodeId(7), NodeId(42)]]); - - let view_settings = DocumentSettings { - document_ptz: &document.document_ptz, - render_mode: &document.render_mode, - overlays_visibility: &document.overlays_visibility_settings, - rulers_visible: document.rulers_visible, - snapping_state: &document.snapping_state, - collapsed: &document.collapsed, - } - .to_view_map(); - - // Apply the serialized view settings onto a fresh handler and compare each field's serialized - // form (avoids requiring `PartialEq` on every setting type). - let mut restored = DocumentMessageHandler::default(); - restored.apply_stored_document_settings(&view_settings); - - assert_eq!(serde_json::to_value(restored.render_mode).unwrap(), serde_json::to_value(document.render_mode).unwrap(), "render_mode"); - assert_eq!( - serde_json::to_value(restored.rulers_visible).unwrap(), - serde_json::to_value(document.rulers_visible).unwrap(), - "rulers_visible" - ); - assert_eq!( - serde_json::to_value(restored.document_ptz).unwrap(), - serde_json::to_value(document.document_ptz).unwrap(), - "document_ptz" - ); - assert_eq!( - serde_json::to_value(restored.overlays_visibility_settings).unwrap(), - serde_json::to_value(document.overlays_visibility_settings).unwrap(), - "overlays" - ); - assert_eq!( - serde_json::to_value(restored.snapping_state).unwrap(), - serde_json::to_value(document.snapping_state).unwrap(), - "snapping_state" - ); - assert_eq!(serde_json::to_value(restored.collapsed).unwrap(), serde_json::to_value(document.collapsed).unwrap(), "collapsed"); - } -} diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index ecee7384e9..afbc8b8b39 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -398,7 +398,7 @@ impl MessageHandler> for Portfolio // Document was closed before its working copy finished mounting; drop the `Gdd`. return; }; - document.storage = gdd; + document.set_storage(gdd); // On a fresh mount, capture the current runtime state into the working copy: edits made // during the mount window were skipped by `commit_storage_snapshot` (no-op while unmounted), // so this initial commit brings the working copy up to date. On a reopen the working copy @@ -449,7 +449,7 @@ impl MessageHandler> for Portfolio // declaration bytes), which the runtime resource registry doesn't track but the working copy // and `.gdd` export need in the global cache. History-wide, not just the current registry: // an undo drops an interaction's resources from the working registry, but redo still needs them. - if let Some(storage) = &document.storage { + if let Some(storage) = document.storage() { used_resources.extend(storage.all_referenced_resource_hashes()); } }