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