Integrate document-format storage into the editor (#4265)

* Integrate document-format storage into the editor

* Address review feedback: drop redundant clones and dedupe metadata traversal

* Extract DocumentHistory and move storage-metadata tests to their own file

* Extract storage diffing/IO, gate validation and .gdd save behind preferences, restructure tests

* Address PR review: save-format/path consistency, autosave persistence, and view-settings/diff fixes

* Skip storage remount on .gdd open and split value/timestamp drift in attribute diff

* Rename storage tests module and surface .gdd open failures with per-resource diff detail

* Add debug-menu toggle to hide the storage preferences section

* Hopefully fix async tests

* Shorten comments

* Fix tests

* Review

* Review

* Review

* Block on future mesages in tests

* Review

* Shorten comments

---------

Co-authored-by: Timon <me@timon.zip>
This commit is contained in:
Dennis Kobert
2026-07-04 21:27:00 +02:00
committed by GitHub
parent 294952e152
commit 540a4ff273
32 changed files with 2979 additions and 72 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 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
}
@@ -271,6 +272,7 @@ impl Dispatcher {
menu_bar_message_handler.properties_panel_open = layout.is_panel_present(PanelType::Properties);
menu_bar_message_handler.message_logging_verbosity = self.message_handlers.debug_message_handler.message_logging_verbosity;
menu_bar_message_handler.reset_node_definitions_on_open = self.message_handlers.portfolio_message_handler.reset_node_definitions_on_open;
menu_bar_message_handler.show_storage_preferences = self.message_handlers.preferences_message_handler.show_storage_preferences;
if let Some(document) = self
.message_handlers

View File

@@ -303,6 +303,62 @@ impl PreferencesDialogMessageHandler {
rows.extend_from_slice(&[header, node_graph_wires_label, graph_wire_style, brush_tool]);
}
// =========
// DOCUMENTS
// =========
// Soak-only `.gdd` storage options, hidden unless enabled from the developer debug menu.
if preferences.show_storage_preferences {
let header = vec![TextLabel::new("Documents").italic(true).widget_instance()];
let save_as_gdd_description = "
Save documents in the new .gdd container format (with the legacy .graphite file embedded as a recovery fallback) instead of a plain .graphite file. The .gdd format is still being validated, so this is opt-in for now.\n\
\n\
*Default: Off.*
"
.trim();
let save_as_gdd_checkbox_id = CheckboxId::new();
let save_as_gdd = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(preferences.save_as_gdd)
.tooltip_label("Save as .gdd")
.tooltip_description(save_as_gdd_description)
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::SaveAsGdd { enabled: checkbox_input.checked }.into())
.for_label(save_as_gdd_checkbox_id)
.widget_instance(),
TextLabel::new("Save as .gdd")
.tooltip_label("Save as .gdd")
.tooltip_description(save_as_gdd_description)
.for_checkbox(save_as_gdd_checkbox_id)
.widget_instance(),
];
let validate_description = "
Validate every document save, open, and undo by round-tripping it through the .gdd storage format and comparing against the legacy path, logging any mismatch. Useful for debugging the .gdd format during its soak, but the per-edit round-trip has a performance cost.\n\
\n\
*Default: Off.*
"
.trim();
let validate_checkbox_id = CheckboxId::new();
let validate_storage_round_trip = vec![
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
CheckboxInput::new(preferences.validate_storage_round_trip)
.tooltip_label("Validate Storage Round-Trip")
.tooltip_description(validate_description)
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::ValidateStorageRoundTrip { enabled: checkbox_input.checked }.into())
.for_label(validate_checkbox_id)
.widget_instance(),
TextLabel::new("Validate Storage Round-Trip")
.tooltip_label("Validate Storage Round-Trip")
.tooltip_description(validate_description)
.for_checkbox(validate_checkbox_id)
.widget_instance(),
];
rows.extend_from_slice(&[header, save_as_gdd, validate_storage_round_trip]);
}
// =============
// COMPATIBILITY
// =============

View File

@@ -73,8 +73,10 @@ pub struct FutureMessageContext {}
#[derive(ExtractField)]
pub struct FutureMessageHandler {
#[cfg_attr(test, expect(dead_code))]
spawner: Arc<dyn MessageSpawner>,
wake: Wake,
#[cfg_attr(test, expect(dead_code))]
results_sender: UnboundedSender<Message>,
results_receiver: UnboundedReceiver<Message>,
}
@@ -119,7 +121,15 @@ impl MessageHandler<FutureMessage, FutureMessageContext> for FutureMessageHandle
fn process_message(&mut self, message: FutureMessage, _responses: &mut VecDeque<Message>, _context: FutureMessageContext) {
match message {
FutureMessage::Await { future } => {
#[cfg(not(test))]
self.spawner.spawn(future.into_future(), self.results_sender.clone(), self.wake.clone());
// For tests, block on the future to ensure the result is available when validating editor state afterwards.
#[cfg(test)]
{
let message = futures::executor::block_on(future.into_future());
_responses.push_back(message);
}
}
FutureMessage::Wake => {
// Tick-only message: the dispatcher's top-of-tick drain handles the real work.

View File

@@ -17,6 +17,7 @@ pub struct MenuBarMessageHandler {
pub has_selection_history: (bool, bool),
pub message_logging_verbosity: MessageLoggingVerbosity,
pub reset_node_definitions_on_open: bool,
pub show_storage_preferences: bool,
pub make_path_editable_is_allowed: bool,
pub data_panel_open: bool,
pub layers_panel_open: bool,
@@ -50,6 +51,7 @@ impl LayoutHolder for MenuBarMessageHandler {
let message_logging_verbosity_names = self.message_logging_verbosity == MessageLoggingVerbosity::Names;
let message_logging_verbosity_contents = self.message_logging_verbosity == MessageLoggingVerbosity::Contents;
let reset_node_definitions_on_open = self.reset_node_definitions_on_open;
let show_storage_preferences = self.show_storage_preferences;
let make_path_editable_is_allowed = self.make_path_editable_is_allowed;
let about = MenuListEntry::new("About Graphite…")
@@ -718,6 +720,10 @@ impl LayoutHolder for MenuBarMessageHandler {
.label("Reset Nodes to Definitions on Open")
.icon(if reset_node_definitions_on_open { "CheckboxChecked" } else { "CheckboxUnchecked" })
.on_commit(|_| PortfolioMessage::ToggleResetNodesToDefinitionsOnOpen.into()),
MenuListEntry::new("Show Storage Preferences")
.label("Show Storage Preferences")
.icon(if show_storage_preferences { "CheckboxChecked" } else { "CheckboxUnchecked" })
.on_commit(|_| PreferencesMessage::ToggleShowStoragePreferences.into()),
],
vec![
MenuListEntry::new("Print Trace Logs")

View File

@@ -0,0 +1,276 @@
//! Human-readable pretty-printers for the dual-write soak's validation checks: registry-vs-registry and
//! runtime-network-vs-runtime-network diffs. The callers are gated behind the `validate_storage_round_trip`
//! preference, so these ship in release but only run when the soak is turned on.
use std::fmt::Write;
pub(crate) fn diff_registries(stored: &graph_storage::Registry, target: &graph_storage::Registry) -> String {
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();
let shared_resource_diffs: Vec<_> = stored_resources
.intersection(&target_resources)
.filter(|id| !resource_value_equal(&stored.resources[id], &target.resources[id]))
.collect();
if !missing_resources.is_empty() || !extra_resources.is_empty() || !shared_resource_diffs.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 !shared_resource_diffs.is_empty() {
let _ = writeln!(out, " differing payloads: {shared_resource_diffs:?}");
for id in &shared_resource_diffs {
let _ = writeln!(out, " resource {id}:");
diff_resource(&mut out, &stored.resources[id], &target.resources[id]);
}
}
}
if stored.attributes != target.attributes {
diff_attributes(&mut out, " document attributes", &stored.attributes, &target.attributes);
}
out
}
fn diff_node(out: &mut String, stored: &graph_storage::Node, target: &graph_storage::Node) {
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());
}
}
fn diff_network(out: &mut String, stored: &graph_storage::Network, target: &graph_storage::Network) {
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);
}
}
/// Value-level resource comparison (same resolved hash, same source bodies keyed by `SourceKey`),
/// ignoring LWW timestamps. Mirrors `graph_storage`'s internal `resources_value_equal` for a single
/// entry, since that helper is crate-private and only operates over a whole store.
fn resource_value_equal(stored: &graph_storage::ResourceEntry, target: &graph_storage::ResourceEntry) -> bool {
stored.hash == target.hash && stored.sources.len() == target.sources.len() && stored.sources.iter().all(|(key, value)| target.source(key).is_some_and(|other| value.source == other.source))
}
fn diff_resource(out: &mut String, stored: &graph_storage::ResourceEntry, target: &graph_storage::ResourceEntry) {
if stored.hash != target.hash {
let _ = writeln!(out, " hash: stored={:?} target={:?}", stored.hash, target.hash);
}
if stored.sources.len() != target.sources.len() {
let _ = writeln!(out, " sources.len: stored={} target={}", stored.sources.len(), target.sources.len());
}
// Report source bodies that drift for a shared key, plus keys present on only one side.
for (key, value) in &stored.sources {
match target.source(key) {
Some(other) if other.source != value.source => {
let _ = writeln!(out, " source[{key:?}]: stored={:?} target={:?}", value.source, other.source);
}
None => {
let _ = writeln!(out, " source[{key:?}]: only in stored");
}
Some(_) => {}
}
}
for (key, _) in &target.sources {
if stored.source(key).is_none() {
let _ = writeln!(out, " source[{key:?}]: only in target");
}
}
}
fn diff_attributes(out: &mut String, label: &str, stored: &graph_storage::Attributes, target: &graph_storage::Attributes) {
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();
// Split value drift from timestamp-only drift: each attribute carries an LWW timestamp, so comparing
// whole records would flag equal-value entries that merely differ in when they were last set.
let (differing_values, differing_timestamps): (Vec<_>, Vec<_>) = stored_keys
.intersection(&target_keys)
.copied()
.filter(|k| stored.get(*k) != target.get(*k))
.partition(|k| stored.get(*k).map(|v| &v.value) != target.get(*k).map(|v| &v.value));
let _ = writeln!(
out,
"{label}: missing_from_stored={missing:?} extra_in_stored={extra:?} differing_values={differing_values:?} differing_timestamps={differing_timestamps:?}"
);
}
/// Human-readable summary of how two networks differ (exports, node set, per-node payloads, scope injections).
pub(crate) fn diff_networks(expected: &graph_craft::document::NodeNetwork, actual: &graph_craft::document::NodeNetwork) -> String {
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) {
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);
}
}

View File

@@ -0,0 +1,267 @@
use std::collections::VecDeque;
use std::collections::{BTreeMap, HashSet};
use graph_craft::application_io::resource::{ResourceId, ResourceRegistry, ResourceStorage};
use graph_storage::Registry;
use super::utility_types::network_interface::NodeNetworkInterface;
use super::utility_types::network_interface::storage_metadata::{StorageMetadataView, collect_network_view_settings};
/// Per-document undo/redo state: the legacy snapshot stacks plus the `Gdd` working-copy cursor that is
/// becoming the authoritative history. Owns the dual-stack bookkeeping push/pop/clear and the cursor's stage/retire/move/verify
/// lifecycle, so the handler drives history through one surface rather than three loose fields.
///
/// Not serialized: the legacy stacks are runtime-only, and a clone shares the working-copy container by
/// `Arc`, so it keeps reading the live working copy.
#[derive(derivative::Derivative)]
#[derivative(Clone, Debug, Default)]
pub struct DocumentHistory {
/// Stack of document network snapshots for previous history states.
lagacy_undo_stack: VecDeque<NodeNetworkInterface>,
/// Stack of document network snapshots for future history states.
lagacy_redo_stack: VecDeque<NodeNetworkInterface>,
/// The `Gdd` working copy: owns the CRDT `Session` and mirrors edits to disk. `None` until the mount
/// future built by `load_document` resolves.
#[derivative(Debug = "ignore")]
storage: Option<document_format::GddV1>,
}
impl DocumentHistory {
// ===== Legacy snapshot stacks =====
/// Push a snapshot onto the undo stack, evicting the oldest entry past the history cap.
pub fn push_undo(&mut self, snapshot: NodeNetworkInterface) {
Self::push_capped(&mut self.lagacy_undo_stack, snapshot);
}
/// Push a snapshot onto the redo stack, evicting the oldest entry past the history cap.
pub fn push_redo(&mut self, snapshot: NodeNetworkInterface) {
Self::push_capped(&mut self.lagacy_redo_stack, snapshot);
}
/// Pop the most recent undo snapshot, or `None` when the stack is empty.
pub fn pop_undo(&mut self) -> Option<NodeNetworkInterface> {
self.lagacy_undo_stack.pop_back()
}
/// Pop the most recent redo snapshot, or `None` when the stack is empty.
pub fn pop_redo(&mut self) -> Option<NodeNetworkInterface> {
self.lagacy_redo_stack.pop_back()
}
/// Drop the most recently pushed undo snapshot (used to cancel a transaction that ended up unmodified).
pub fn discard_last_undo(&mut self) {
self.lagacy_undo_stack.pop_back();
}
/// Clear the redo stack, called when a fresh edit invalidates the redo future.
pub fn clear_redo(&mut self) {
self.lagacy_redo_stack.clear();
}
/// Add the resources referenced by every snapshot in both history stacks into `resources`, so
/// history-only resources stay alive for legacy undo/redo.
pub fn collect_used_resources(&self, resources: &mut HashSet<ResourceId>) {
for interface in self.lagacy_undo_stack.iter().chain(&self.lagacy_redo_stack) {
interface.collect_used_resources(resources);
}
}
// ===== Gdd working-copy cursor =====
/// The `Gdd` working copy, `None` until the mount future resolves.
pub fn storage(&self) -> Option<&document_format::GddV1> {
self.storage.as_ref()
}
/// Mutable access to the `Gdd` working copy.
pub fn storage_mut(&mut self) -> Option<&mut document_format::GddV1> {
self.storage.as_mut()
}
/// Attach (or clear) the `Gdd` working copy once the mount future resolves.
pub fn set_storage(&mut self, storage: Option<document_format::GddV1>) {
self.storage = storage;
}
/// Retire the pending staged hot ops 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 interaction aligned with the legacy step. No-op while unmounted.
pub fn retire_storage_interaction(&mut self) {
let Some(storage) = self.storage.as_mut() else { return };
if let Err(error) = storage.retire_pending_interaction() {
log::error!("Storage interaction retirement failed: {error}");
}
}
/// Stage the runtime snapshot into the `Gdd` working copy at each `CommitTransaction`. No-op while
/// unmounted. Proto-node declaration bytes go into `byte_store` (the app-global resource cache). The
/// staged hot ops are retired by [`retire_storage_interaction`](Self::retire_storage_interaction) at
/// undo-step boundaries. `validate` (the `validate_storage_round_trip` preference) gates the per-commit
/// round-trip check, off by default for its perf cost.
pub fn stage_snapshot(
&mut self,
interface: &NodeNetworkInterface,
registry: &ResourceRegistry,
view_settings: BTreeMap<String, serde_json::Value>,
legacy_document: &str,
byte_store: &dyn ResourceStorage,
) {
let Some(storage) = self.storage.as_mut() else { return };
let network = interface.document_network();
let metadata_view = StorageMetadataView::new(interface);
let network_view_settings = storage
.network_ids(network, &metadata_view)
.ok()
.map(|network_ids| collect_network_view_settings(interface, &network_ids));
// Stage without retiring: a tool drag fires several `CommitTransaction`s but is one legacy undo
// step, so the deltas accumulate as hot ops and coalesce at the next undo-step boundary.
if let Err(error) = storage.stage_runtime_snapshot(network, &metadata_view, 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 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}");
}
}
/// Move the `Gdd` undo/redo cursor along the retired interaction chain, flushing any open interaction
/// first. Returns a clone of the post-move `Gdd` (`Arc`-shared) so a `'static` rebuild future can read
/// the rewound state while the live document keeps its cursor. `None` when there is nothing to move to,
/// unmounted, or the move failed.
pub fn move_cursor(&mut self, undo: bool) -> Option<document_format::GddV1> {
self.retire_storage_interaction();
let storage = self.storage.as_mut()?;
let moved = if undo {
if !storage.can_undo() {
return None;
}
storage.undo().map(|_| ())
} else {
if !storage.can_redo() {
return None;
}
storage.redo().map(|_| ())
};
if let Err(error) = moved {
log::error!("Storage undo/redo cursor move failed: {error}");
return None;
}
Some(storage.clone())
}
// Soak round-trip verification (runtime-gated by `validate_storage_round_trip`)
// These log drift rather than panicking, since the soak can run in release where a crash is
// unacceptable; tests still fail loud via the `#[cfg(test)]` panics.
/// Soak check: the stored registry should equal a fresh `from_runtime`, and a `to_runtime` of it should
/// equal the original network.
pub fn verify_round_trip(&self, interface: &NodeNetworkInterface, registry: &ResourceRegistry) {
use super::diff_networks;
use super::document_diff::diff_registries;
let Some(storage) = &self.storage else { return };
let peer = storage.session().peer();
let network = interface.document_network();
let metadata_view = StorageMetadataView::new(interface);
let conversion = match Registry::convert_from_runtime(network, &metadata_view, registry, peer) {
Ok(conversion) => conversion,
Err(error) => {
log::error!("storage round-trip: from_runtime failed: {error}");
return;
}
};
let target = &conversion.registry;
let declarations = match conversion.declarations() {
Ok(declarations) => declarations,
Err(error) => {
log::error!("storage round-trip: declaration rebuild failed: {error}");
return;
}
};
let stored = storage.registry();
if !stored.value_equal(target) {
log::error!("storage round-trip: registry value drift after commit\n{}", diff_registries(stored, target));
#[cfg(test)]
panic!("storage round-trip: registry value drift after commit");
}
if !stored.order_consistent(target) {
log::error!("storage round-trip: timestamp order inconsistent between stored and target");
#[cfg(test)]
panic!("storage round-trip: timestamp order inconsistent between stored and target");
}
let (round_tripped, _entries) = match stored.to_runtime_with_metadata(&declarations) {
Ok(result) => result,
Err(error) => {
log::error!("storage round-trip: to_runtime failed: {error}");
return;
}
};
if &round_tripped != network {
log::error!("storage round-trip: network drift after to_runtime\n{}", diff_networks(network, &round_tripped));
#[cfg(test)]
panic!("storage round-trip: network drift after to_runtime");
}
}
/// Soak check: after a cursor move, the cursor's registry should equal a fresh `from_runtime` of the
/// current (legacy-restored) interface. `current_resources` are the resources the live network
/// references; history-only resources the cursor dropped are expected, not drift.
pub fn verify_cursor_matches_runtime(&self, interface: &NodeNetworkInterface, registry: &ResourceRegistry, current_resources: &HashSet<ResourceId>) {
use super::document_diff::diff_registries;
let Some(storage) = &self.storage else { return };
let peer = storage.session().peer();
let network = interface.document_network();
let metadata_view = StorageMetadataView::new(interface);
let Ok(mut conversion) = Registry::convert_from_runtime(network, &metadata_view, registry, peer) else {
log::error!("undo/redo shadow: from_runtime failed");
return;
};
let stored = storage.registry();
// The cursor reverts the interaction's `AddResource` while the runtime keeps the resource alive for
// legacy redo, so a history-only resource the cursor dropped is expected. Drop those before comparing.
conversion.registry.resources.retain(|id, _| stored.resources.contains_key(id) || current_resources.contains(id));
if !stored.value_equal(&conversion.registry) {
log::error!(
"undo/redo shadow: cursor registry diverged from the restored interface\n{}",
diff_registries(stored, &conversion.registry)
);
}
}
fn push_capped(stack: &mut VecDeque<NodeNetworkInterface>, snapshot: NodeNetworkInterface) {
stack.push_back(snapshot);
if stack.len() > crate::consts::MAX_UNDO_HISTORY_LEN {
stack.pop_front();
}
}
}

View File

@@ -1,3 +1,5 @@
use super::DocumentHistory;
use super::document_diff::diff_networks;
use super::node_graph::document_node_definitions;
use super::utility_types::error::EditorError;
use super::utility_types::misc::{GroupFolderType, SNAP_FUNCTIONS_FOR_BOUNDING_BOXES, SNAP_FUNCTIONS_FOR_PATHS, SnappingOptions, SnappingState};
@@ -5,7 +7,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 +66,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 {
// ======================
@@ -138,12 +141,9 @@ pub struct DocumentMessageHandler {
/// Path to network that is currently selected. Updated based on the most recently clicked panel.
#[serde(skip)]
selection_network_path: Vec<NodeId>,
/// Stack of document network snapshots for previous history states.
/// Undo/redo state: the legacy snapshot stacks plus the `Gdd` working-copy cursor.
#[serde(skip)]
document_undo_history: VecDeque<NodeNetworkInterface>,
/// Stack of document network snapshots for future history states.
#[serde(skip)]
document_redo_history: VecDeque<NodeNetworkInterface>,
history: DocumentHistory,
/// Hash of the document snapshot that was most recently saved to disk by the user.
#[serde(skip)]
saved_hash: Option<u64>,
@@ -194,8 +194,7 @@ impl Default for DocumentMessageHandler {
pending_gradient_migration: false,
breadcrumb_network_path: Vec::new(),
selection_network_path: Vec::new(),
document_undo_history: VecDeque::new(),
document_redo_history: VecDeque::new(),
history: DocumentHistory::default(),
saved_hash: None,
auto_saved_hash: None,
layer_range_selection_reference: None,
@@ -402,8 +401,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();
@@ -823,7 +822,7 @@ 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);
responses.add(DocumentMessage::StartTransaction);
let layer = graph_modification_utils::new_image_layer(image, layer_node_id, layer_parent, responses);
@@ -832,7 +831,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 {
@@ -853,6 +853,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 });
}
@@ -890,7 +892,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 {
@@ -1024,28 +1027,63 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
DocumentMessage::SaveDocument | DocumentMessage::SaveDocumentAs => {
responses.add(PortfolioMessage::AutoSaveActiveDocument);
let name = format!("{}.{}", self.name.clone(), FILE_EXTENSION);
let path = if let DocumentMessage::SaveDocumentAs = message { None } else { self.path.clone() };
// Save as a `.gdd` container or a plain legacy `.graphite`, per the editor preference.
let save_as_gdd = preferences.save_as_gdd;
let extension = if save_as_gdd { GDD_FILE_EXTENSION } else { FILE_EXTENSION };
let name = format!("{}.{}", self.name.clone(), extension);
// Keep the path's extension in sync with the chosen format so bytes and filename agree.
let path = match message {
DocumentMessage::SaveDocumentAs => None,
_ => self.path.clone().map(|path| path.with_extension(extension)),
};
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 working-copy container (Arc), so it 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();
// Legacy .graphite blob with resources embedded inline, so it is self-contained and also
// serves as the .gdd recovery fallback.
let legacy_document = document.serialize_document().into_bytes();
// A .gdd exports the working copy with the legacy blob embedded, falling back to the bare
// blob when there is no working copy or the export fails.
let content = match (save_as_gdd, document.history.storage()) {
(false, _) => legacy_document,
(true, 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
}),
(true, 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(),
})
});
}
@@ -1057,8 +1095,8 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
// Update the name to match the file stem
let document_name_from_path = self.path.as_ref().and_then(|path| {
if path.extension().is_some_and(|e| e == FILE_EXTENSION) {
path.file_stem().map(|n| n.to_string_lossy().to_string())
if path.extension().is_some_and(|extension| extension == FILE_EXTENSION || extension == GDD_FILE_EXTENSION) {
path.file_stem().map(|stem| stem.to_string_lossy().to_string())
} else {
None
}
@@ -1291,12 +1329,10 @@ 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 => {
self.retire_storage_interaction();
self.network_interface.start_transaction();
let network_interface_clone = self.network_interface.clone();
self.document_undo_history.push_back(network_interface_clone);
if self.document_undo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN {
self.document_undo_history.pop_front();
}
self.history.push_undo(self.network_interface.clone());
// Push the UpdateOpenDocumentsList message to the bus in order to update the save status of the open documents
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
}
@@ -1312,14 +1348,17 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
},
DocumentMessage::CancelTransaction => {
self.network_interface.finish_transaction();
self.document_undo_history.pop_back();
self.history.discard_last_undo();
}
DocumentMessage::CommitTransaction => {
if self.network_interface.transaction_status() == TransactionStatus::Finished {
return;
}
self.network_interface.finish_transaction();
self.document_redo_history.clear();
self.history.clear_redo();
self.commit_storage_snapshot(&resource_storage.resources_mut(), preferences.validate_storage_round_trip);
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
}
DocumentMessage::AbortTransaction => match self.network_interface.transaction_status() {
@@ -1754,6 +1793,35 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
}
impl DocumentMessageHandler {
/// Build a document handler from a `.gdd` working copy.
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());
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.history.set_storage(Some(storage));
document
}
/// Post-load fixups for a document built from storage.
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());
@@ -1929,6 +1997,133 @@ impl DocumentMessageHandler {
&self.selection_network_path
}
/// The `Gdd` working copy, `None` until the mount future resolves.
pub fn storage(&self) -> Option<&document_format::GddV1> {
self.history.storage()
}
/// Mutable access to the `Gdd` working copy.
pub fn storage_mut(&mut self) -> Option<&mut document_format::GddV1> {
self.history.storage_mut()
}
/// Attach (or clear) the `Gdd` working copy once the mount future resolves.
pub fn set_storage(&mut self, storage: Option<document_format::GddV1>) {
self.history.set_storage(storage);
}
/// Retire the pending staged hot ops into durable Gdd history as one undo unit.
pub(crate) fn retire_storage_interaction(&mut self) {
self.history.retire_storage_interaction();
}
/// Stages the runtime network into the `Gdd` working copy.
pub fn commit_storage_snapshot(&mut self, byte_store: &dyn graph_craft::application_io::resource::ResourceStorage, validate: bool) {
use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::DocumentSettings;
if self.history.storage().is_none() {
return;
}
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();
let legacy_document = self.serialize_document();
self.history
.stage_snapshot(&self.network_interface, &self.resources.registry, view_settings, legacy_document.as_str(), byte_store);
if validate {
self.history.verify_round_trip(&self.network_interface, &self.resources.registry);
}
}
/// Restore `view_settings` map into the document.
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;
}
}
/// Move the `Gdd` undo/redo cursor and spawn the async future that rebuilds the
/// interface from the cursor and swaps it in. `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 {
let Some(gdd) = self.history.move_cursor(undo) else { return false };
responses.add(crate::messages::portfolio::document_storage_io::rebuild_gdd_cursor(
gdd,
resource_storage.resources_mut(),
document_id,
had_oracle,
));
true
}
/// Swap in the interface rebuilt from the `Gdd` cursor. Always overwrites the interface.
pub(crate) fn apply_gdd_cursor_rebuild(&mut self, mut rebuilt: NodeNetworkInterface, had_oracle: bool, validate: bool, responses: &mut VecDeque<Message>) {
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();
if validate && had_oracle {
self.compare_rebuild_against_legacy(&rebuilt);
}
self.network_interface = rebuilt;
if validate {
let current_resources: std::collections::HashSet<_> = self.used_resources(false).iter().copied().collect();
self.history.verify_cursor_matches_runtime(&self.network_interface, &self.resources.registry, &current_resources);
}
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
responses.add(NodeGraphMessage::SelectedNodesUpdated);
responses.add(NodeGraphMessage::ForceRunDocumentGraph);
responses.add(NodeGraphMessage::UnloadWires);
responses.add(NodeGraphMessage::SendWires);
}
/// Check if the rebuilt network equals the legacy-restored one. Logs drift (panics in tests).
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!("undo/redo rebuild diverged from the legacy snapshot")
}
}
pub fn serialize_document(&self) -> String {
let val = serde_json::to_string(self);
// We fully expect the serialization to succeed
@@ -2206,18 +2401,20 @@ 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>) {
let legacy_applied = if let Some(previous_network) = self.undo(viewport, responses) {
self.history.push_redo(previous_network);
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> {
// If there is no history return and don't broadcast SelectionChanged
let mut network_interface = self.document_undo_history.pop_back()?;
let mut network_interface = self.history.pop_undo()?;
// Set the previous network navigation metadata to the current navigation metadata
network_interface.copy_all_navigation_metadata(&self.network_interface);
@@ -2242,19 +2439,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>) {
let legacy_applied = if let Some(previous_network) = self.redo(viewport, responses) {
self.history.push_undo(previous_network);
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> {
// If there is no history return and don't broadcast SelectionChanged
let mut network_interface = self.document_redo_history.pop_back()?;
let mut network_interface = self.history.pop_redo()?;
// Set the previous network navigation metadata to the current navigation metadata
network_interface.copy_all_navigation_metadata(&self.network_interface);
@@ -3589,8 +3787,7 @@ impl DocumentMessageHandler {
let mut resources = HashSet::new();
self.network_interface.collect_used_resources(&mut resources);
if include_history {
self.document_undo_history.iter().for_each(|interface| interface.collect_used_resources(&mut resources));
self.document_redo_history.iter().for_each(|interface| interface.collect_used_resources(&mut resources));
self.history.collect_used_resources(&mut resources);
}
resources.into_iter().collect::<Vec<_>>().into_boxed_slice()
}

View File

@@ -1,5 +1,9 @@
pub(crate) mod document_diff;
mod document_history;
mod document_message;
mod document_message_handler;
#[cfg(test)]
mod storage_tests;
pub mod data_panel;
pub mod graph_operation;
@@ -10,6 +14,8 @@ pub mod properties_panel;
pub mod resource;
pub mod utility_types;
pub(crate) use document_diff::diff_networks;
pub(crate) use document_history::DocumentHistory;
#[doc(inline)]
pub use document_message::{DocumentMessage, DocumentMessageDiscriminant};
#[doc(inline)]

View File

@@ -0,0 +1,313 @@
//! Conversion-level round-trip tests for the [`storage_metadata`](crate::messages::portfolio::document::utility_types::network_interface::storage_metadata)
//! bridge: drive a demo `.graphite` document through `Registry` (and back through
//! `build_interface_from_storage`) without an actual save/reopen, asserting the editor's `ui::*`
//! metadata survives the conversion. The end-to-end save/reopen pipeline is covered separately in
//! [`round_trip_tests`](super::round_trip_tests).
use std::collections::HashMap;
use graph_storage::{NodeMetadataSource, PeerId, Registry};
use super::test_support::{load_demo, node_paths};
use crate::messages::portfolio::document::document_message_handler::DocumentMessageHandler;
use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{DocumentSettings, StorageMetadataView, build_interface_from_storage};
use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers;
use graph_craft::document::NodeId;
use graphene_std::vector::style::RenderMode;
/// 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 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);
// Use the node's input count as the slot-by-slot iteration bound for the per-input metadata checks.
let input_count = interface.document_node(&local_id, &network_path).map(|node| node.inputs.len()).unwrap_or(0);
let any_input_metadata_present = (0..input_count).any(|i| {
source.input_name(&network_path, local_id, i).is_some_and(|s| !s.is_empty())
|| source.input_description(&network_path, local_id, i).is_some_and(|s| !s.is_empty())
|| source.widget_override(&network_path, local_id, i).is_some()
|| !source.input_data(&network_path, local_id, i).is_empty()
});
let any_metadata = expected_position.is_some()
|| expected_is_layer
|| expected_display.as_deref().is_some_and(|s| !s.is_empty())
|| expected_locked
|| expected_pinned
|| any_input_metadata_present
|| !expected_output_names.is_empty();
if !any_metadata {
assert!(
!entries_by_address.contains_key(&(network_path.clone(), local_id)),
"node {local_id:?} in network {network_path:?} has no editor metadata but produced an entry"
);
continue;
}
let entry = entries_by_address
.get(&(network_path.clone(), local_id))
.unwrap_or_else(|| panic!("missing entry for node {local_id:?} in network {network_path:?}"));
assert_eq!(entry.position, expected_position, "position mismatch for node {local_id:?} in network {network_path:?}");
assert_eq!(entry.is_layer, expected_is_layer, "is_layer mismatch for node {local_id:?} in network {network_path:?}");
// The trait round-trip drops empty display names (treats them as "unset") so the entry's
// `display_name` is `None` when the runtime carries `""`. Normalize before comparing.
let normalized_expected_display = expected_display.as_deref().filter(|s| !s.is_empty()).map(str::to_owned);
assert_eq!(
entry.display_name, normalized_expected_display,
"display_name mismatch for node {local_id:?} in network {network_path:?}"
);
assert_eq!(entry.locked, expected_locked, "locked mismatch for node {local_id:?} in network {network_path:?}");
assert_eq!(entry.pinned, expected_pinned, "pinned mismatch for node {local_id:?} in network {network_path:?}");
assert_eq!(entry.output_names, expected_output_names, "output_names mismatch for node {local_id:?} in network {network_path:?}");
// Per-input metadata: the entry's `input_metadata` has one slot per input, each matching the trait's
// per-field view (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 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 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 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

@@ -0,0 +1,9 @@
//! Integration tests for the `.gdd` document-storage layer, split by altitude:
//! - [`metadata_tests`] drives a demo document through the conversion bridge in-process (no save/reopen).
//! - [`round_trip_tests`] drives real editor edits through a full `Gdd` save/reopen pipeline.
//!
//! Shared fixtures live in [`test_support`].
mod metadata_tests;
mod round_trip_tests;
mod test_support;

View File

@@ -0,0 +1,710 @@
//! 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_storage::{NodeMetadataSource, PeerId};
use super::test_support::{RoundTrip, node_paths, round_trip_through_gdd};
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::{StorageMetadataView, build_interface_from_storage};
use crate::test_utils::test_prelude::*;
use graphene_std::vector::style::RenderMode;
/// 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}");
}
}
#[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.
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_diff::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.set_storage(Some(reopened));
document.finalize_storage_load();
}
// Mirror the live sequence via the real `commit_storage_snapshot`: initial commit after open, then a
// deletion, then commit again. Deletion (not addition) is the trigger, since 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, true);
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();
// Clone the interface for the metadata view so it doesn't borrow `document` across the mutable
// `storage_mut()` borrow below.
let interface = document.network_interface.clone();
let network = interface.document_network().clone();
let view = StorageMetadataView::new(&interface);
document
.storage_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: the in-memory registry is back at the two-interaction state, 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, true);
let before_edit = editor.active_document().network_interface.document_network().clone();
editor.draw_ellipse(50., 50., 150., 150.).await;
let after_edit = editor.active_document().network_interface.document_network().clone();
assert_ne!(&before_edit, &after_edit, "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: the shadow drives gdd.redo() and must reproduce the post-edit network.
editor.handle_message(DocumentMessage::Redo).await;
assert_eq!(editor.active_document().network_interface.document_network(), &after_edit, "redo should restore the post-edit network");
}
#[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.contains_key(network::NAV_PTZ), "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, true);
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 brackets the layer add,
/// name set, reparent, and transform in a single transaction. Were the name set to open its own nested
/// transaction (a historical wart), the first undo would revert only the name and leave the layer behind, so
/// this asserts the layer count returns to its pre-paste value after exactly one undo.
#[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::InsertImage {
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"
);
}
/// Undoing an image 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`. Drives the relaxed comparison: every resource the current network references must be in
/// the cursor, and any extra the runtime carries must be history-only.
#[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, true);
let image = Image::new(2, 2, Color::WHITE);
editor
.handle_message(PortfolioMessage::InsertImage {
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().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 edits (draw, then paste) on an opened document are two undo steps; undoing twice must step the `Gdd`
/// cursor back two interactions in lockstep with the legacy path. The loaded base is not itself an undo
/// step, so the mount-time base must not become an undoable `Gdd` interaction, or the cursor lags the legacy
/// path by one once undo reaches 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, true);
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::InsertImage {
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 (the live editor re-evaluates the graph). The pasted image's resource is still
// cached for redo though its node is gone, so a naive snapshot would re-detect it as a new `AddResource`
// and retire a phantom interaction, knocking the cursor out of lockstep. Scoping the snapshot to
// network-referenced resources must keep this a no-op.
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
// Second undo: removes the rectangle, back to the loaded base. The cursor must step a second interaction
// and land on the base, not one short.
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 can undo further: the load is not an undo step. Had the `Gdd`
// retired the mount base as its own interaction, the cursor could still undo here, the off-by-one.
let storage = editor.active_document().storage().expect("storage mounted");
assert!(!storage.can_undo(), "cursor must not undo past the loaded base (the load is not an undo step)");
}
/// Assert the `Gdd` cursor's stored registry matches a fresh `from_runtime` of the current interface (node
/// set, network set, per-network export wiring), mirroring the live `verify_cursor_matches_runtime` check.
/// The runtime's resource set may be a superset (it keeps undone resources alive for redo), but every
/// resource the current network references must be present.
fn assert_cursor_matches_runtime(document: &DocumentMessageHandler, at: &str) {
let storage = document.storage().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().set_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 tests on any conversion or round-trip drift, so a clean run
/// is itself the assertion that the whole pipeline holds on real document data. Also checks that committing
/// edits grows the retired-delta history and that 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, true);
let history_after_open = editor.active_document().storage().unwrap().session().history().count();
// Snapshot the network, then make a real modification.
let before_edit = editor.active_document().network_interface.document_network().clone();
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, true);
let history_after_edit = editor.active_document().storage().unwrap().session().history().count();
assert!(
history_after_edit > history_after_open,
"committing an edit should append retired deltas: {history_after_open} -> {history_after_edit}"
);
// 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, true);
}

View File

@@ -0,0 +1,88 @@
//! Shared helpers for the storage integration tests ([`metadata_tests`](super::metadata_tests) and
//! [`round_trip_tests`](super::round_trip_tests)): loading demo artwork, walking every addressable
//! node, and pushing a document through a real `Gdd` 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::PeerId;
use crate::messages::portfolio::document::document_message_handler::DocumentMessageHandler;
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{DocumentSettings, StorageMetadataView, build_interface_from_storage};
/// Load a demo `.graphite` straight into a `DocumentMessageHandler` for inspection.
pub 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 a test can
/// iterate every node addressable from the metadata side.
pub 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
}
/// 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::*`).
pub struct RoundTrip {
pub rebuilt: NodeNetworkInterface,
pub registry: graph_storage::Registry,
pub 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, exercising the whole codec / file / replay
/// pipeline rather than just the in-memory conversion.
pub 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 }
}

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;
@@ -3452,22 +3453,67 @@ impl NodeNetworkInterface {
// Public mutable methods
impl NodeNetworkInterface {
pub fn copy_all_navigation_metadata(&mut self, other_interface: &NodeNetworkInterface) {
/// Walk every network at every nesting level, invoking `visit` with each network's path and a mutable
/// reference to its metadata. Only nodes that contain a nested network are recursed into.
fn for_each_network_metadata_mut(&mut self, mut visit: impl FnMut(&[NodeId], &mut NodeNetworkMetadata)) {
let mut stack = vec![vec![]];
while let Some(path) = stack.pop() {
let Some(self_network_metadata) = self.network_metadata_mut(&path) else {
continue;
};
if let Some(other_network_metadata) = other_interface.network_metadata(&path) {
visit(&path, self_network_metadata);
// Only nodes that contain a nested network have metadata to recurse into, so skip leaf nodes.
stack.extend(
self_network_metadata
.persistent_metadata
.node_metadata
.iter()
.filter(|(_, node_metadata)| node_metadata.persistent_metadata.network_metadata.is_some())
.map(|(node_id, _)| {
let mut current_path: Vec<NodeId> = path.clone();
current_path.push(*node_id);
current_path
}),
);
}
}
pub fn copy_all_navigation_metadata(&mut self, other_interface: &NodeNetworkInterface) {
self.for_each_network_metadata_mut(|path, self_network_metadata| {
if let Some(other_network_metadata) = other_interface.network_metadata(path) {
self_network_metadata.persistent_metadata.navigation_metadata = other_network_metadata.persistent_metadata.navigation_metadata.clone();
}
});
}
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
}));
}
/// Copy all transient view and selection state from `other_interface` onto `self` at every nesting level:
/// navigation metadata (pan/zoom), selection undo/redo history, and the document-to-viewport camera. Lets
/// an interface rebuilt from storage keep the user's current view rather than reset it. `resolved_types`
/// is a separate runtime cache the caller handles.
pub fn copy_all_transient_view_state(&mut self, other_interface: &NodeNetworkInterface) {
self.for_each_network_metadata_mut(|path, self_network_metadata| {
if let Some(other_network_metadata) = other_interface.network_metadata(path) {
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();
// The live preview root may name a node that the rebuilt-from-storage network no longer contains
// (e.g. after a storage undo), so only carry it over when the root still exists; otherwise drop it.
self_persistent.previewing = match other_persistent.previewing {
Previewing::Yes {
root_node_to_restore: Some(root_node),
} if !self_persistent.node_metadata.contains_key(&root_node.node_id) => Previewing::No,
previewing => previewing,
};
self_persistent.selection_undo_history = other_persistent.selection_undo_history.clone();
self_persistent.selection_redo_history = other_persistent.selection_redo_history.clone();
}
});
self.document_metadata.document_to_viewport = other_interface.document_metadata.document_to_viewport;
}
pub fn set_transform(&mut self, transform: DAffine2, network_path: &[NodeId]) {
@@ -6858,6 +6904,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,392 @@
//! Bridge between `NodeNetworkInterface` and `graph-storage`'s `NodeMetadataSource` trait.
//! Conversion round-trip tests live in `storage_metadata_tests`.
//!
//! The trait impl lives on the [`StorageMetadataView`] wrapper (not on `NodeNetworkInterface`
//! directly) because several trait method names collide with inherent methods, and Rust silently
//! 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, NavigationMetadata, 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 interface = NodeNetworkInterface {
network: MemoNetwork::new(network),
network_metadata,
..Default::default()
};
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
/// `session.json` map keyed by the stable storage [`NetworkId`](graph_storage::NetworkId), which
/// `network_ids` resolves from each runtime `network_path`. 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 default_navigation = NavigationMetadata::default();
// Only persist nav fields that diverge from the default, so a network at its default view stays out
// of `session.json` (the `if !settings.is_empty()` guard below skips it entirely).
let mut settings = BTreeMap::new();
if navigation.node_graph_ptz != default_navigation.node_graph_ptz
&& let Ok(value) = serde_json::to_value(navigation.node_graph_ptz)
{
settings.insert(session::network::NAV_PTZ.to_string(), value);
}
if navigation.node_graph_to_viewport != default_navigation.node_graph_to_viewport
&& let Ok(value) = serde_json::to_value(navigation.node_graph_to_viewport)
{
settings.insert(session::network::NAV_TRANSFORM.to_string(), value);
}
if navigation.node_graph_width != default_navigation.node_graph_width
&& 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()
}
}

View File

@@ -0,0 +1,231 @@
//! Asynchronous `.gdd` working-copy IO, spawned by `PortfolioMessageHandler` as `FutureMessage`s:
//! building/opening containers, opening `.gdd` archives into documents, and rebuilding the interface
//! from the undo/redo cursor. The `validate` flag is the `validate_storage_round_trip` preference; when
//! set, the registry build is compared against the legacy oracle (logged, not fatal) for the soak.
use document_container::AnyContainer;
use document_format::{Error as DocumentFormatError, GddV1, GddV1Layout};
use graph_craft::application_io::resource::{LoadResource, ResourceStorage};
use graph_craft::document::NodeNetwork;
use super::document::DocumentMessageHandler;
use super::document::diff_networks;
use super::document::utility_types::network_interface::storage_metadata::{apply_network_view_settings, build_interface_from_storage, network_ids_from_entries};
use super::document_migration::document_migration_string_preprocessing;
use super::portfolio_message::PortfolioMessage;
use crate::messages::message::Message;
use crate::messages::portfolio::document::utility_types::misc::DocumentId;
use crate::messages::resource_storage::ResourcesHandle;
/// Build the container backend at `path` (folder on native, OPFS directory name on web) or in-memory
/// when `None`. Also returns whether an existing working copy was found (its `manifest.json` is present).
async fn build_per_document_container(path: Option<&std::path::Path>) -> Result<(AnyContainer, bool), DocumentFormatError> {
let Some(path) = path else {
return Ok((AnyContainer::Memory(document_container::backends::memory::MemoryBackend::new()), false));
};
#[cfg(not(target_family = "wasm"))]
{
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)? };
Ok((AnyContainer::Folder(backend), exists))
}
#[cfg(target_family = "wasm")]
{
use document_container::AsyncContainer;
use document_container::backends::opfs::OpfsBackend;
let directory_name = path
.to_str()
.ok_or(DocumentFormatError::Container(document_container::ContainerError::InvalidPath(path.display().to_string())))?;
let backend = OpfsBackend::open(directory_name).await?;
let exists = backend.exists("manifest.json").await;
Ok((AnyContainer::Opfs(backend), exists))
}
}
/// Open the existing working copy at `path`, or create a fresh one bound to `peer` (in-memory when
/// `path` is `None`). Returns the working copy plus whether it was reopened (vs freshly created); only a
/// reopen has independently-stored state worth comparing against the legacy load.
pub(super) async fn build_or_open_working_copy(path: Option<&std::path::Path>, peer: graph_storage::PeerId, document_uuid: u64, version: String) -> Result<(GddV1, bool), DocumentFormatError> {
let (container, exists) = build_per_document_container(path).await?;
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))
}
/// `FutureMessage` that opens a `.gdd` archive into a document, delivered via
/// [`PortfolioMessage::GddDocumentLoaded`]. See [`build_document_from_gdd`] for the build itself.
pub(super) async fn open_gdd_document(
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: ResourcesHandle,
validate: bool,
) -> Message {
let path = working_copy_root.map(|root| root.join(format!("{:x}", document_id.0)));
let document = build_document_from_gdd(path.as_deref(), &content, &store_handle, document_id, validate).await;
Message::Portfolio(PortfolioMessage::GddDocumentLoaded {
document_id,
document_name,
document_path,
document: document.map(Box::new),
})
}
/// `FutureMessage` that rebuilds a document's interface from a post-move `Gdd` cursor snapshot and
/// delivers it via [`PortfolioMessage::GddUndoRedoRebuilt`] (`None` interface on failure, logged here).
pub(crate) async fn rebuild_gdd_cursor(gdd: GddV1, store_handle: ResourcesHandle, document_id: DocumentId, had_oracle: bool) -> Message {
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 })
}
/// Core of the `.gdd` open: archive -> working copy -> `Gdd` -> runtime interface. The registry build is
/// authoritative; the embedded legacy blob is the soak oracle and the fallback if the build fails.
/// Returns `None` only if neither the build nor the legacy fallback worked.
async fn build_document_from_gdd(path: Option<&std::path::Path>, content: &[u8], store_handle: &impl ResourceStorage, document_id: DocumentId, validate: bool) -> Option<DocumentMessageHandler> {
let (container, _exists) = match build_per_document_container(path).await {
Ok(result) => result,
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 archived resource bytes 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}"),
}
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());
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) => {
// Per-network view state lives in `session.json`, not the registry, so restore it here.
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
}
};
// Soak compare against the legacy oracle, normalizing the benign generic-vs-resolved `call_argument`.
if let Some(interface) = interface {
if validate && 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{}",
diff_networks(&oracle, &built)
);
}
}
return Some(DocumentMessageHandler::from_storage(interface, gdd, String::new(), None));
}
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
}
/// Soak check that the reopened `.gdd`'s stored registry, converted back to a runtime network, matches
/// the legacy load. Logs divergence only (legacy stays authoritative); runs once per open.
pub(super) async fn compare_storage_against_runtime(gdd: &GddV1, legacy_network: &NodeNetwork, byte_store: &dyn 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;
}
};
// Normalize the benign generic-vs-resolved `call_argument` on both sides before comparing.
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{}",
diff_networks(&legacy, &candidate)
);
}
}
/// Recursively rewrite a concrete `Context` `call_argument` back to the generic `T` storage authored, so
/// the generic-vs-resolved difference (recomputed during compilation) compares equal. Other types untouched.
fn normalize_call_arguments_for_compare(network: &mut 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);
}
}
}

View File

@@ -3,6 +3,7 @@ mod portfolio_message_handler;
pub mod document;
pub mod document_migration;
pub mod document_storage_io;
pub mod fonts;
pub mod persistent_state;
pub mod utility_types;

View File

@@ -8,7 +8,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]
@@ -41,6 +42,18 @@ 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`. The `gdd` payload is non-serializable and a clone carries none
/// (`clone_to_none`), so it travels exactly once. `reopened` is true when an existing working copy was
/// opened: the persisted cursor is trusted as-is and the mount-time re-commit is skipped, since
/// re-committing 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,
@@ -90,6 +103,33 @@ 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. Travels once like [`DocumentStorageMounted`](Self::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 so the async 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 against it. Travels
/// once like [`DocumentStorageMounted`](Self::DocumentStorageMounted).
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>,
@@ -180,3 +220,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

@@ -2,7 +2,7 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier;
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, FILE_EXTENSION};
use crate::consts::{DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, GDD_FILE_EXTENSION};
use crate::messages::animation::TimingInformation;
use crate::messages::dialog::simple_dialogs;
use crate::messages::frontend::utility_types::{DocumentInfo, PersistedState};
@@ -14,6 +14,7 @@ use crate::messages::portfolio::document::graph_operation::utility_types::Transf
use crate::messages::portfolio::document::node_graph::document_node_definitions;
use crate::messages::portfolio::document::utility_types::network_interface::OutputConnector;
use crate::messages::portfolio::document_migration::*;
use crate::messages::portfolio::document_storage_io::{build_or_open_working_copy, compare_storage_against_runtime, open_gdd_document};
use crate::messages::portfolio::utility_types::FileContent;
use crate::messages::preferences::SelectionMode;
use crate::messages::prelude::*;
@@ -64,6 +65,9 @@ pub struct PortfolioMessageHandler {
pub selection_mode: SelectionMode,
pub reset_node_definitions_on_open: bool,
pub workspace_panel_layout: WorkspacePanelLayout,
working_copy_root: Option<PathBuf>,
/// Number of document not fully loaded. While non-zero, resource GC is skipped.
pending_opens: usize,
}
#[message_handler_data]
@@ -197,7 +201,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
responses.add(PortfolioMessage::GarbageCollectResources);
}
PortfolioMessage::AutoSaveDocument { document_id } => {
let Some(document) = self.document(document_id) else { return };
let validate = preferences.validate_storage_round_trip;
let Some(document) = self.documents.get_mut(&document_id) else { return };
document.commit_storage_snapshot(&resource_storage.resources_mut(), validate);
responses.add(PersistentStateMessage::WriteDocument {
document_id,
document: document.serialize_document(),
@@ -292,6 +300,17 @@ 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.
return;
};
document.set_storage(gdd);
if !reopened {
document.commit_storage_snapshot(&resource_storage.resources_mut(), preferences.validate_storage_round_trip);
document.retire_storage_interaction();
}
}
PortfolioMessage::DestroyAllDocuments => {
// Empty the list of internal document data
self.documents.clear();
@@ -307,6 +326,10 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
// We don't know what can be safely garbage collected
return;
}
if self.pending_opens > 0 {
// A document not fully loaded, skip garbage collection.
return;
}
let mut used_resources = HashSet::new();
for (id, info) in self.unloaded_documents.iter() {
@@ -320,6 +343,10 @@ 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()));
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 {
@@ -505,7 +532,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, preferences.validate_storage_round_trip, responses);
responses.add(PortfolioMessage::SelectDocument { document_id });
}
PortfolioMessage::MoveAllPanelTabs {
@@ -642,6 +669,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 });
}
@@ -669,6 +704,13 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
document_serialized_content: content,
});
}
FileContent::GddDocument(content) => {
responses.add(PortfolioMessage::OpenGddDocument {
document_name: name,
document_path: Some(path),
content,
});
}
FileContent::Svg(svg) => {
responses.add(PortfolioMessage::InsertSvg {
name,
@@ -710,6 +752,68 @@ 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());
// Suppress resource GC until the open completes
self.pending_opens += 1;
responses.add(open_gdd_document(
self.working_copy_root.clone(),
document_id,
document_name,
document_path,
content,
resource_storage.resources_mut(),
preferences.validate_storage_round_trip,
));
}
PortfolioMessage::GddDocumentLoaded {
document_id,
document_name,
document_path,
document,
} => {
self.pending_opens = self.pending_opens.saturating_sub(1);
let Some(mut document) = document.map(|boxed| *boxed) else {
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_default();
simple_dialogs::FailedToOpenDocumentDialog { document_name: name }.send_dialog_to_frontend(responses);
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, preferences.validate_storage_round_trip, 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, preferences.validate_storage_round_trip, responses);
}
PortfolioMessage::ToggleResetNodesToDefinitionsOnOpen => {
self.reset_node_definitions_on_open = !self.reset_node_definitions_on_open;
responses.add(MenuBarMessage::SendLayout);
@@ -836,8 +940,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
document.set_save_state(document_is_saved);
let document_name_from_path = document_path.as_ref().and_then(|path| {
if path.extension().is_some_and(|e| e == FILE_EXTENSION) {
path.file_stem().map(|n| n.to_string_lossy().to_string())
if path.extension().is_some_and(|extension| extension == FILE_EXTENSION || extension == GDD_FILE_EXTENSION) {
path.file_stem().map(|stem| stem.to_string_lossy().to_string())
} else {
None
}
@@ -855,7 +959,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, preferences.validate_storage_round_trip, responses);
responses.add(AppWindowMessage::Focus);
@@ -1601,6 +1705,10 @@ impl PortfolioMessageHandler {
Self { executor, ..Default::default() }
}
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)
}
@@ -1698,6 +1806,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,
@@ -1717,7 +1826,14 @@ 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,
validate: bool,
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);
@@ -1744,6 +1860,64 @@ impl PortfolioMessageHandler {
if is_new_document {
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
}
// Mount the per-document `Gdd` working copy asynchronously.
//
// Only mount for legacy loads: a `.gdd` open already mounted its working copy
// and remounting would overwrite it and drop that history state.
if self.documents.get(&document_id).and_then(|document| document.storage()).is_none() {
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(),
validate,
));
}
}
/// 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` before
/// the working copy attaches. It validates the `.gdd` *read* path.
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>,
validate: bool,
) -> Message {
let path = working_copy_root.map(|root| root.join(format!("{:016x}", 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 validate && 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.

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

@@ -36,6 +36,13 @@ pub enum PreferencesMessage {
DisableUIAcceleration {
disable_ui_acceleration: bool,
},
ValidateStorageRoundTrip {
enabled: bool,
},
SaveAsGdd {
enabled: bool,
},
ToggleShowStoragePreferences,
#[cfg(target_os = "macos")]
VSync {
vsync: bool,

View File

@@ -23,6 +23,9 @@ pub struct PreferencesMessageHandler {
pub ui_scale: f64,
pub max_render_region_size: u32,
pub disable_ui_acceleration: bool,
pub validate_storage_round_trip: bool,
pub save_as_gdd: bool,
pub show_storage_preferences: bool,
#[cfg(target_os = "macos")]
pub vsync: bool,
}
@@ -66,6 +69,9 @@ impl Default for PreferencesMessageHandler {
ui_scale: UI_SCALE_DEFAULT,
max_render_region_size: EditorPreferences::default().max_render_region_size,
disable_ui_acceleration: cfg!(target_os = "linux"), // TODO: Set this back to false once we have ui acceleration working more reliably on linux
validate_storage_round_trip: false,
save_as_gdd: false,
show_storage_preferences: false,
#[cfg(target_os = "macos")]
vsync: false,
}
@@ -132,6 +138,16 @@ impl MessageHandler<PreferencesMessage, PreferencesMessageContext<'_>> for Prefe
PreferencesMessage::DisableUIAcceleration { disable_ui_acceleration } => {
self.disable_ui_acceleration = disable_ui_acceleration;
}
PreferencesMessage::ValidateStorageRoundTrip { enabled } => {
self.validate_storage_round_trip = enabled;
}
PreferencesMessage::SaveAsGdd { enabled } => {
self.save_as_gdd = enabled;
}
PreferencesMessage::ToggleShowStoragePreferences => {
self.show_storage_preferences = !self.show_storage_preferences;
responses.add(MenuBarMessage::SendLayout);
}
#[cfg(target_os = "macos")]
PreferencesMessage::VSync { vsync } => {
self.vsync = vsync;

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,12 @@ impl ResourceStorageMessageHandler {
inner: self.storage.clone().expect("Resource storage not initialized"),
})
}
pub fn resources_mut(&self) -> ResourcesHandle {
ResourcesHandle {
inner: self.storage.clone().expect("Resource storage not initialized"),
}
}
}
impl std::fmt::Debug for ResourceStorageMessageHandler {

View File

@@ -28,6 +28,10 @@ impl EditorTestUtils {
editor.handle_message(PortfolioMessage::Init);
// Enable the dual-write soak validation in tests so storage round-trip drift fails loud, matching
// the previous always-on-in-debug behavior now that it is gated by a preference.
editor.handle_message(PreferencesMessage::ValidateStorageRoundTrip { enabled: true });
Self { editor, runtime }
}