Integrate document-format storage into the editor

This commit is contained in:
Dennis Kobert
2026-06-21 15:20:16 +02:00
parent 96d5a442b0
commit a43c661f14
20 changed files with 2787 additions and 66 deletions

View File

@@ -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

View File

@@ -10,11 +10,18 @@ pub struct Editor {
}
impl Editor {
pub fn new(environment: Environment, uuid_random_seed: u64, resource_storage: Arc<dyn ResourceStorage>, mut application_io: PlatformApplicationIo, wake: Wake) -> Self {
pub fn new(
environment: Environment,
uuid_random_seed: u64,
resource_storage: Arc<dyn ResourceStorage>,
working_copy_root: Option<std::path::PathBuf>,
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);

View File

@@ -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;

View File

@@ -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<dyn ResourceStorage>) -> Self {
pub fn new(resource_storage: Arc<dyn ResourceStorage>, working_copy_root: Option<std::path::PathBuf>) -> 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
}

View File

@@ -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<Box<dyn Future<Output = Message> + Send + 'static>>;
#[cfg(target_family = "wasm")]
@@ -132,7 +137,7 @@ impl MessageHandler<FutureMessage, FutureMessageContext> for FutureMessageHandle
#[cfg(not(target_family = "wasm"))]
fn default_spawner() -> Arc<dyn MessageSpawner> {
Arc::new(TokioSpawner::default())
Arc::new(TokioSpawner)
}
#[cfg(target_family = "wasm")]
@@ -140,37 +145,27 @@ fn default_spawner() -> Arc<dyn MessageSpawner> {
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<tokio::runtime::Runtime>,
}
static EDITOR_ASYNC_RUNTIME: std::sync::LazyLock<tokio::runtime::Runtime> = 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 Default for TokioSpawner {
fn default() -> Self {
Self { runtime: std::sync::OnceLock::new() }
}
}
#[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<Message>, wake: Wake) {
self.runtime().spawn(async move {
EDITOR_ASYNC_RUNTIME.spawn(async move {
let message = future.await;
let _ = results.unbounded_send(message);
wake();

View File

@@ -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<AnyContainer>`), so a cloned document still reads the live working copy.
#[serde(skip, default)]
#[derivative(Debug = "ignore")]
pub storage: Option<document_format::GddV1>,
/// 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<DocumentMessage, DocumentMessageContext<'_>> 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<DocumentMessage, DocumentMessageContext<'_>> 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<DocumentMessage, DocumentMessageContext<'_>> 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<DocumentMessage, DocumentMessageContext<'_>> 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<DocumentMessage, DocumentMessageContext<'_>> 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<DocumentMessage, DocumentMessageContext<'_>> 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<DocumentMessage, DocumentMessageContext<'_>> 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<DocumentMessage, DocumentMessageContext<'_>> 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() {
@@ -1739,6 +1795,40 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> 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<std::path::PathBuf>) -> 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());
@@ -1914,6 +2004,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<String, serde_json::Value>) {
use graph_storage::attr::session::doc;
fn decode<T: serde::de::DeserializeOwned>(view_settings: &std::collections::BTreeMap<String, serde_json::Value>, key: &str) -> Option<T> {
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<Message>) -> 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<Message>) {
// 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
@@ -2191,13 +2549,23 @@ impl DocumentMessageHandler {
paths
}
pub fn undo_with_history(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque<Message>) {
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<Message>) {
// 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<Message>) -> Option<NodeNetworkInterface> {
@@ -2227,14 +2595,20 @@ impl DocumentMessageHandler {
Some(previous_network)
}
pub fn redo_with_history(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque<Message>) {
// 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<Message>) {
// 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<Message>) -> Option<NodeNetworkInterface> {
@@ -3581,6 +3955,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();

View File

@@ -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};

View File

@@ -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<String, serde_json::Value>,
}
/// 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>, NodeId)> {
fn walk(interface: &NodeNetworkInterface, path: Vec<NodeId>, out: &mut Vec<(Vec<NodeId>, 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::<std::collections::BTreeSet<_>>(), 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::<std::collections::BTreeSet<_>>(),
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::<std::collections::BTreeSet<_>>(),
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::<std::collections::BTreeSet<_>>(),
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);
}

View File

@@ -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<NodeId> = 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

View File

@@ -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<NodeId>, 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<Position> {
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<String, serde_json::Value> {
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<String> {
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<String, serde_json::Value> {
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<NodeMetadataEntry>, network_entries: Vec<NetworkMetadataEntry>) -> Result<NodeNetworkInterface, InterfaceRebuildError> {
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<Vec<NodeId>, 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<Vec<NodeId>, graph_storage::NetworkId>,
) -> BTreeMap<graph_storage::NetworkId, BTreeMap<String, serde_json::Value>> {
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<Vec<NodeId>, graph_storage::NetworkId>,
network_view_settings: &BTreeMap<graph_storage::NetworkId, BTreeMap<String, serde_json::Value>>,
) {
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::<PTZ>(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::<Previewing>(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<NetworkMetadataEntry>) {
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<NodeMetadataEntry>) -> Result<(), InterfaceRebuildError> {
// Group entries by network_path so we do one nested_metadata_mut lookup per network.
let mut by_path: HashMap<Vec<NodeId>, Vec<NodeMetadataEntry>> = 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>, NodeId)> {
fn walk(interface: &NodeNetworkInterface, path: Vec<NodeId>, out: &mut Vec<(Vec<NodeId>, 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>, 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<entry>) →
/// 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<NodeId>> = 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");
}
}

View File

@@ -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<document_format::GddV1>,
},
DestroyAllDocuments,
EditorPreferences,
GarbageCollectResources,
@@ -97,6 +112,34 @@ pub enum PortfolioMessage {
document_path: Option<PathBuf>,
document_serialized_content: String,
},
/// Open a `.gdd` document container (archive bytes), building the runtime from its stored registry.
OpenGddDocument {
document_name: Option<String>,
document_path: Option<PathBuf>,
content: Vec<u8>,
},
/// 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<String>,
document_path: Option<PathBuf>,
#[serde(skip, default)]
#[derivative(Debug = "ignore", PartialEq = "ignore", Clone(clone_with = "clone_to_none"))]
document: Option<Box<DocumentMessageHandler>>,
},
/// 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<Box<crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface>>,
},
LoadDocument {
document_id: DocumentId,
document_name: Option<String>,
@@ -199,3 +242,8 @@ pub enum PortfolioMessage {
sizes: Vec<f64>,
},
}
/// Clone helper for the non-serializable `gdd` payload: a cloned mount message carries no `Gdd`.
fn clone_to_none<T>(_: &Option<T>) -> Option<T> {
None
}

View File

@@ -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<PathBuf>,
/// 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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<PortfolioMessage, PortfolioMessageContext<'_>> 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);
@@ -1837,6 +1956,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<PathBuf>) {
self.working_copy_root = root;
}
pub fn document(&self, document_id: DocumentId) -> Option<&DocumentMessageHandler> {
self.documents.get(&document_id)
}
@@ -1934,6 +2059,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,
@@ -1953,7 +2079,7 @@ impl PortfolioMessageHandler {
}
}
fn load_document(&mut self, mut new_document: DocumentMessageHandler, document_id: DocumentId, responses: &mut VecDeque<Message>) {
fn load_document(&mut self, mut new_document: DocumentMessageHandler, document_id: DocumentId, resource_storage: &ResourceStorageMessageHandler, responses: &mut VecDeque<Message>) {
let is_new_document = !self.document_ids.contains(&document_id);
if is_new_document {
self.document_ids.push_back(document_id);
@@ -1980,6 +2106,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 `<root>/<id_hex>`; 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<std::path::PathBuf>,
document_id: DocumentId,
legacy_network: graph_craft::document::NodeNetwork,
byte_store: Box<dyn graph_craft::application_io::resource::LoadResource>,
) -> 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.
@@ -2146,6 +2325,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<document_container::AnyContainer, document_format::Error> {
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<std::path::PathBuf>,
document_id: DocumentId,
document_name: Option<String>,
document_path: Option<std::path::PathBuf>,
content: Vec<u8>,
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<DocumentMessageHandler> {
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<String> {

View File

@@ -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<u8>),
/// A bitmap image.
Image(Image<Color>),
/// An SVG file string.

View File

@@ -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<Arc<dyn ResourceStorage>>,
@@ -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<ResourcesHandle> {
self.storage.clone().map(|inner| ResourcesHandle { inner })
}
}
impl std::fmt::Debug for ResourceStorageMessageHandler {