mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Integrate document-format storage into the editor (#4265)
* Integrate document-format storage into the editor * Address review feedback: drop redundant clones and dedupe metadata traversal * Extract DocumentHistory and move storage-metadata tests to their own file * Extract storage diffing/IO, gate validation and .gdd save behind preferences, restructure tests * Address PR review: save-format/path consistency, autosave persistence, and view-settings/diff fixes * Skip storage remount on .gdd open and split value/timestamp drift in attribute diff * Rename storage tests module and surface .gdd open failures with per-resource diff detail * Add debug-menu toggle to hide the storage preferences section * Hopefully fix async tests * Shorten comments * Fix tests * Review * Review * Review * Block on future mesages in tests * Review * Shorten comments --------- Co-authored-by: Timon <me@timon.zip>
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -2282,11 +2282,14 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"color",
|
||||
"derivative",
|
||||
"document-container",
|
||||
"document-format",
|
||||
"dyn-any",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"glam",
|
||||
"graph-craft",
|
||||
"graph-storage",
|
||||
"graphene-hash",
|
||||
"graphene-std",
|
||||
"graphite-proc-macros",
|
||||
|
||||
@@ -100,7 +100,7 @@ impl App {
|
||||
let wake = Arc::new(move || {
|
||||
wake_scheduler.schedule(AppEvent::DesktopWrapperMessage(DesktopWrapperMessage::Wake));
|
||||
});
|
||||
let desktop_wrapper = DesktopWrapper::new(rand::rng().random(), Arc::new(resource_storage), wgpu_context.clone(), wake);
|
||||
let desktop_wrapper = DesktopWrapper::new(rand::rng().random(), Arc::new(resource_storage), dirs::app_autosave_documents_dir(), wgpu_context.clone(), wake);
|
||||
|
||||
Self {
|
||||
render_state: None,
|
||||
|
||||
@@ -37,13 +37,14 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
|
||||
if let Some(path) = path {
|
||||
dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content });
|
||||
} else {
|
||||
let extension = std::path::Path::new(&name).extension().and_then(|extension| extension.to_str()).unwrap_or("*").to_string();
|
||||
dispatcher.respond(DesktopFrontendMessage::SaveFileDialog {
|
||||
title: "Save Document".to_string(),
|
||||
default_filename: name,
|
||||
default_folder: folder,
|
||||
filters: vec![FileFilter {
|
||||
name: "Graphite".to_string(),
|
||||
extensions: vec!["graphite".to_string()],
|
||||
name: "Graphite Document".to_string(),
|
||||
extensions: vec![extension],
|
||||
}],
|
||||
context: SaveFileDialogContext::Document { document_id, content },
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ pub struct 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")]
|
||||
let host = Host::Windows;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -37,7 +37,7 @@ impl DesktopWrapper {
|
||||
let application_io = PlatformApplicationIo::new_with_context(wgpu_context);
|
||||
|
||||
Self {
|
||||
editor: Editor::new(env, uuid_random_seed, resource_storage, application_io, schedule_wake),
|
||||
editor: Editor::new(env, uuid_random_seed, resource_storage, Some(working_copy_root), application_io, schedule_wake),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ gpu = ["interpreted-executor/gpu", "dep:wgpu-executor"]
|
||||
# Local dependencies
|
||||
graphite-proc-macros = { workspace = true }
|
||||
graph-craft = { workspace = true }
|
||||
graph-storage = { workspace = true, features = ["conversion"] }
|
||||
document-format = { workspace = true }
|
||||
document-container = { workspace = true }
|
||||
graphene-hash = { workspace = true }
|
||||
interpreted-executor = { workspace = true }
|
||||
graphene-std = { workspace = true } # NOTE: `core-types` should not be added here because `graphene-std` re-exports its contents
|
||||
|
||||
@@ -10,11 +10,18 @@ pub struct Editor {
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn new(environment: Environment, uuid_random_seed: u64, resource_storage: Arc<dyn ResourceStorage>, mut application_io: PlatformApplicationIo, wake: Wake) -> Self {
|
||||
pub fn new(
|
||||
environment: Environment,
|
||||
uuid_random_seed: u64,
|
||||
resource_storage: Arc<dyn ResourceStorage>,
|
||||
working_copy_root: Option<std::path::PathBuf>,
|
||||
mut application_io: PlatformApplicationIo,
|
||||
wake: Wake,
|
||||
) -> Self {
|
||||
ENVIRONMENT.set(environment).expect("Editor shoud only be initialized once");
|
||||
graphene_std::uuid::set_uuid_seed(uuid_random_seed);
|
||||
|
||||
let mut dispatcher = Dispatcher::new(resource_storage);
|
||||
let mut dispatcher = Dispatcher::new(resource_storage, working_copy_root);
|
||||
dispatcher.message_handlers.future_message_handler.set_wake(wake);
|
||||
application_io.inject_resource_proxy(dispatcher.message_handlers.resource_storage_message_handler.resources());
|
||||
crate::node_graph_executor::replace_application_io(application_io);
|
||||
|
||||
@@ -176,6 +176,9 @@ pub const COLOR_OVERLAY_BLACK_75: &str = "#000000bf";
|
||||
|
||||
// DOCUMENT
|
||||
pub const FILE_EXTENSION: &str = "graphite";
|
||||
/// New document container format. Save writes a `.gdd` with the legacy `.graphite` embedded as a fallback
|
||||
/// during the dual-write soak; `.graphite` stays a supported open/import input.
|
||||
pub const GDD_FILE_EXTENSION: &str = "gdd";
|
||||
pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
|
||||
pub const MAX_UNDO_HISTORY_LEN: usize = 100; // TODO: Add this to user preferences
|
||||
pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 1;
|
||||
|
||||
@@ -89,9 +89,10 @@ const DEBUG_MESSAGE_BLOCK_LIST: &[MessageDiscriminant] = &[
|
||||
const DEBUG_MESSAGE_ENDING_BLOCK_LIST: &[&str] = &["PointerMove", "PointerOutsideViewport", "Overlays", "Draw", "CurrentTime", "Time"];
|
||||
|
||||
impl Dispatcher {
|
||||
pub fn new(resource_storage: Arc<dyn ResourceStorage>) -> Self {
|
||||
pub fn new(resource_storage: Arc<dyn ResourceStorage>, working_copy_root: Option<std::path::PathBuf>) -> Self {
|
||||
let mut s = Self::default();
|
||||
s.message_handlers.resource_storage_message_handler = ResourceStorageMessageHandler::new(resource_storage);
|
||||
s.message_handlers.portfolio_message_handler.set_working_copy_root(working_copy_root);
|
||||
s
|
||||
}
|
||||
|
||||
@@ -271,6 +272,7 @@ impl Dispatcher {
|
||||
menu_bar_message_handler.properties_panel_open = layout.is_panel_present(PanelType::Properties);
|
||||
menu_bar_message_handler.message_logging_verbosity = self.message_handlers.debug_message_handler.message_logging_verbosity;
|
||||
menu_bar_message_handler.reset_node_definitions_on_open = self.message_handlers.portfolio_message_handler.reset_node_definitions_on_open;
|
||||
menu_bar_message_handler.show_storage_preferences = self.message_handlers.preferences_message_handler.show_storage_preferences;
|
||||
|
||||
if let Some(document) = self
|
||||
.message_handlers
|
||||
|
||||
@@ -303,6 +303,62 @@ impl PreferencesDialogMessageHandler {
|
||||
rows.extend_from_slice(&[header, node_graph_wires_label, graph_wire_style, brush_tool]);
|
||||
}
|
||||
|
||||
// =========
|
||||
// DOCUMENTS
|
||||
// =========
|
||||
// Soak-only `.gdd` storage options, hidden unless enabled from the developer debug menu.
|
||||
if preferences.show_storage_preferences {
|
||||
let header = vec![TextLabel::new("Documents").italic(true).widget_instance()];
|
||||
|
||||
let save_as_gdd_description = "
|
||||
Save documents in the new .gdd container format (with the legacy .graphite file embedded as a recovery fallback) instead of a plain .graphite file. The .gdd format is still being validated, so this is opt-in for now.\n\
|
||||
\n\
|
||||
*Default: Off.*
|
||||
"
|
||||
.trim();
|
||||
let save_as_gdd_checkbox_id = CheckboxId::new();
|
||||
let save_as_gdd = vec![
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
CheckboxInput::new(preferences.save_as_gdd)
|
||||
.tooltip_label("Save as .gdd")
|
||||
.tooltip_description(save_as_gdd_description)
|
||||
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::SaveAsGdd { enabled: checkbox_input.checked }.into())
|
||||
.for_label(save_as_gdd_checkbox_id)
|
||||
.widget_instance(),
|
||||
TextLabel::new("Save as .gdd")
|
||||
.tooltip_label("Save as .gdd")
|
||||
.tooltip_description(save_as_gdd_description)
|
||||
.for_checkbox(save_as_gdd_checkbox_id)
|
||||
.widget_instance(),
|
||||
];
|
||||
|
||||
let validate_description = "
|
||||
Validate every document save, open, and undo by round-tripping it through the .gdd storage format and comparing against the legacy path, logging any mismatch. Useful for debugging the .gdd format during its soak, but the per-edit round-trip has a performance cost.\n\
|
||||
\n\
|
||||
*Default: Off.*
|
||||
"
|
||||
.trim();
|
||||
let validate_checkbox_id = CheckboxId::new();
|
||||
let validate_storage_round_trip = vec![
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
CheckboxInput::new(preferences.validate_storage_round_trip)
|
||||
.tooltip_label("Validate Storage Round-Trip")
|
||||
.tooltip_description(validate_description)
|
||||
.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::ValidateStorageRoundTrip { enabled: checkbox_input.checked }.into())
|
||||
.for_label(validate_checkbox_id)
|
||||
.widget_instance(),
|
||||
TextLabel::new("Validate Storage Round-Trip")
|
||||
.tooltip_label("Validate Storage Round-Trip")
|
||||
.tooltip_description(validate_description)
|
||||
.for_checkbox(validate_checkbox_id)
|
||||
.widget_instance(),
|
||||
];
|
||||
|
||||
rows.extend_from_slice(&[header, save_as_gdd, validate_storage_round_trip]);
|
||||
}
|
||||
|
||||
// =============
|
||||
// COMPATIBILITY
|
||||
// =============
|
||||
|
||||
@@ -73,8 +73,10 @@ pub struct FutureMessageContext {}
|
||||
|
||||
#[derive(ExtractField)]
|
||||
pub struct FutureMessageHandler {
|
||||
#[cfg_attr(test, expect(dead_code))]
|
||||
spawner: Arc<dyn MessageSpawner>,
|
||||
wake: Wake,
|
||||
#[cfg_attr(test, expect(dead_code))]
|
||||
results_sender: UnboundedSender<Message>,
|
||||
results_receiver: UnboundedReceiver<Message>,
|
||||
}
|
||||
@@ -119,7 +121,15 @@ impl MessageHandler<FutureMessage, FutureMessageContext> for FutureMessageHandle
|
||||
fn process_message(&mut self, message: FutureMessage, _responses: &mut VecDeque<Message>, _context: FutureMessageContext) {
|
||||
match message {
|
||||
FutureMessage::Await { future } => {
|
||||
#[cfg(not(test))]
|
||||
self.spawner.spawn(future.into_future(), self.results_sender.clone(), self.wake.clone());
|
||||
|
||||
// For tests, block on the future to ensure the result is available when validating editor state afterwards.
|
||||
#[cfg(test)]
|
||||
{
|
||||
let message = futures::executor::block_on(future.into_future());
|
||||
_responses.push_back(message);
|
||||
}
|
||||
}
|
||||
FutureMessage::Wake => {
|
||||
// Tick-only message: the dispatcher's top-of-tick drain handles the real work.
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct MenuBarMessageHandler {
|
||||
pub has_selection_history: (bool, bool),
|
||||
pub message_logging_verbosity: MessageLoggingVerbosity,
|
||||
pub reset_node_definitions_on_open: bool,
|
||||
pub show_storage_preferences: bool,
|
||||
pub make_path_editable_is_allowed: bool,
|
||||
pub data_panel_open: bool,
|
||||
pub layers_panel_open: bool,
|
||||
@@ -50,6 +51,7 @@ impl LayoutHolder for MenuBarMessageHandler {
|
||||
let message_logging_verbosity_names = self.message_logging_verbosity == MessageLoggingVerbosity::Names;
|
||||
let message_logging_verbosity_contents = self.message_logging_verbosity == MessageLoggingVerbosity::Contents;
|
||||
let reset_node_definitions_on_open = self.reset_node_definitions_on_open;
|
||||
let show_storage_preferences = self.show_storage_preferences;
|
||||
let make_path_editable_is_allowed = self.make_path_editable_is_allowed;
|
||||
|
||||
let about = MenuListEntry::new("About Graphite…")
|
||||
@@ -718,6 +720,10 @@ impl LayoutHolder for MenuBarMessageHandler {
|
||||
.label("Reset Nodes to Definitions on Open")
|
||||
.icon(if reset_node_definitions_on_open { "CheckboxChecked" } else { "CheckboxUnchecked" })
|
||||
.on_commit(|_| PortfolioMessage::ToggleResetNodesToDefinitionsOnOpen.into()),
|
||||
MenuListEntry::new("Show Storage Preferences")
|
||||
.label("Show Storage Preferences")
|
||||
.icon(if show_storage_preferences { "CheckboxChecked" } else { "CheckboxUnchecked" })
|
||||
.on_commit(|_| PreferencesMessage::ToggleShowStoragePreferences.into()),
|
||||
],
|
||||
vec![
|
||||
MenuListEntry::new("Print Trace Logs")
|
||||
|
||||
276
editor/src/messages/portfolio/document/document_diff.rs
Normal file
276
editor/src/messages/portfolio/document/document_diff.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
//! Human-readable pretty-printers for the dual-write soak's validation checks: registry-vs-registry and
|
||||
//! runtime-network-vs-runtime-network diffs. The callers are gated behind the `validate_storage_round_trip`
|
||||
//! preference, so these ship in release but only run when the soak is turned on.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
pub(crate) fn diff_registries(stored: &graph_storage::Registry, target: &graph_storage::Registry) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
let stored_node_ids: std::collections::BTreeSet<_> = stored.node_instances.keys().copied().collect();
|
||||
let target_node_ids: std::collections::BTreeSet<_> = target.node_instances.keys().copied().collect();
|
||||
let missing_nodes: Vec<_> = target_node_ids.difference(&stored_node_ids).collect();
|
||||
let extra_nodes: Vec<_> = stored_node_ids.difference(&target_node_ids).collect();
|
||||
let shared_node_diffs: Vec<_> = stored_node_ids
|
||||
.intersection(&target_node_ids)
|
||||
.filter(|id| !stored.node_instances[id].value_equal(&target.node_instances[id]))
|
||||
.collect();
|
||||
|
||||
let stored_network_ids: std::collections::BTreeSet<_> = stored.networks.keys().copied().collect();
|
||||
let target_network_ids: std::collections::BTreeSet<_> = target.networks.keys().copied().collect();
|
||||
let missing_networks: Vec<_> = target_network_ids.difference(&stored_network_ids).collect();
|
||||
let extra_networks: Vec<_> = stored_network_ids.difference(&target_network_ids).collect();
|
||||
let shared_network_diffs: Vec<_> = stored_network_ids
|
||||
.intersection(&target_network_ids)
|
||||
.filter(|id| !stored.networks[id].value_equal(&target.networks[id]))
|
||||
.collect();
|
||||
|
||||
let _ = writeln!(out, " nodes: stored={} target={}", stored_node_ids.len(), target_node_ids.len());
|
||||
if !missing_nodes.is_empty() {
|
||||
let _ = writeln!(out, " missing from stored: {missing_nodes:?}");
|
||||
}
|
||||
if !extra_nodes.is_empty() {
|
||||
let _ = writeln!(out, " extra in stored: {extra_nodes:?}");
|
||||
}
|
||||
if !shared_node_diffs.is_empty() {
|
||||
let _ = writeln!(out, " differing payloads: {shared_node_diffs:?}");
|
||||
for id in &shared_node_diffs {
|
||||
let stored_node = &stored.node_instances[id];
|
||||
let target_node = &target.node_instances[id];
|
||||
let _ = writeln!(out, " node {id}:");
|
||||
diff_node(&mut out, stored_node, target_node);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writeln!(out, " networks: stored={} target={}", stored_network_ids.len(), target_network_ids.len());
|
||||
if !missing_networks.is_empty() {
|
||||
let _ = writeln!(out, " missing from stored: {missing_networks:?}");
|
||||
}
|
||||
if !extra_networks.is_empty() {
|
||||
let _ = writeln!(out, " extra in stored: {extra_networks:?}");
|
||||
}
|
||||
if !shared_network_diffs.is_empty() {
|
||||
let _ = writeln!(out, " differing payloads: {shared_network_diffs:?}");
|
||||
for id in &shared_network_diffs {
|
||||
let stored_network = &stored.networks[id];
|
||||
let target_network = &target.networks[id];
|
||||
let _ = writeln!(out, " network {id}:");
|
||||
diff_network(&mut out, stored_network, target_network);
|
||||
}
|
||||
}
|
||||
|
||||
let stored_resources: std::collections::BTreeSet<_> = stored.resources.keys().copied().collect();
|
||||
let target_resources: std::collections::BTreeSet<_> = target.resources.keys().copied().collect();
|
||||
let missing_resources: Vec<_> = target_resources.difference(&stored_resources).collect();
|
||||
let extra_resources: Vec<_> = stored_resources.difference(&target_resources).collect();
|
||||
let shared_resource_diffs: Vec<_> = stored_resources
|
||||
.intersection(&target_resources)
|
||||
.filter(|id| !resource_value_equal(&stored.resources[id], &target.resources[id]))
|
||||
.collect();
|
||||
if !missing_resources.is_empty() || !extra_resources.is_empty() || !shared_resource_diffs.is_empty() {
|
||||
let _ = writeln!(out, " resources: stored={} target={}", stored_resources.len(), target_resources.len());
|
||||
if !missing_resources.is_empty() {
|
||||
let _ = writeln!(out, " missing from stored: {missing_resources:?}");
|
||||
}
|
||||
if !extra_resources.is_empty() {
|
||||
let _ = writeln!(out, " extra in stored: {extra_resources:?}");
|
||||
}
|
||||
if !shared_resource_diffs.is_empty() {
|
||||
let _ = writeln!(out, " differing payloads: {shared_resource_diffs:?}");
|
||||
for id in &shared_resource_diffs {
|
||||
let _ = writeln!(out, " resource {id}:");
|
||||
diff_resource(&mut out, &stored.resources[id], &target.resources[id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stored.attributes != target.attributes {
|
||||
diff_attributes(&mut out, " document attributes", &stored.attributes, &target.attributes);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn diff_node(out: &mut String, stored: &graph_storage::Node, target: &graph_storage::Node) {
|
||||
if stored.implementation() != target.implementation() {
|
||||
let _ = writeln!(out, " implementation: stored={:?} target={:?}", stored.implementation(), target.implementation());
|
||||
}
|
||||
if stored.network() != target.network() {
|
||||
let _ = writeln!(out, " network back-pointer: stored={} target={}", stored.network(), target.network());
|
||||
}
|
||||
|
||||
let stored_inputs = stored.inputs();
|
||||
let target_inputs = target.inputs();
|
||||
if stored_inputs.len() != target_inputs.len() {
|
||||
let _ = writeln!(out, " inputs.len: stored={} target={}", stored_inputs.len(), target_inputs.len());
|
||||
}
|
||||
for (i, (s, t)) in stored_inputs.iter().zip(target_inputs.iter()).enumerate() {
|
||||
if s != t {
|
||||
let value_differs = s.input != t.input;
|
||||
let timestamp_differs = s.timestamp != t.timestamp;
|
||||
let _ = writeln!(out, " input[{i}]: value_differs={value_differs} timestamp_differs={timestamp_differs}");
|
||||
if value_differs {
|
||||
let _ = writeln!(out, " stored.value={:?}\n target.value={:?}", s.input, t.input);
|
||||
}
|
||||
if s.attributes != t.attributes {
|
||||
diff_attributes(out, &format!(" input[{i}].attributes"), &s.attributes, &t.attributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stored.attributes() != target.attributes() {
|
||||
diff_attributes(out, " attributes", stored.attributes(), target.attributes());
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_network(out: &mut String, stored: &graph_storage::Network, target: &graph_storage::Network) {
|
||||
if stored.exports.len() != target.exports.len() {
|
||||
let _ = writeln!(out, " exports.len: stored={} target={}", stored.exports.len(), target.exports.len());
|
||||
}
|
||||
for (i, (s, t)) in stored.exports.iter().zip(target.exports.iter()).enumerate() {
|
||||
if s != t {
|
||||
let target_differs = s.target != t.target;
|
||||
let timestamp_differs = s.timestamp != t.timestamp;
|
||||
let _ = writeln!(out, " export[{i}]: target_differs={target_differs} timestamp_differs={timestamp_differs}");
|
||||
if target_differs {
|
||||
let _ = writeln!(out, " stored.target={:?}\n target.target={:?}", s.target, t.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stored.attributes != target.attributes {
|
||||
diff_attributes(out, " attributes", &stored.attributes, &target.attributes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Value-level resource comparison (same resolved hash, same source bodies keyed by `SourceKey`),
|
||||
/// ignoring LWW timestamps. Mirrors `graph_storage`'s internal `resources_value_equal` for a single
|
||||
/// entry, since that helper is crate-private and only operates over a whole store.
|
||||
fn resource_value_equal(stored: &graph_storage::ResourceEntry, target: &graph_storage::ResourceEntry) -> bool {
|
||||
stored.hash == target.hash && stored.sources.len() == target.sources.len() && stored.sources.iter().all(|(key, value)| target.source(key).is_some_and(|other| value.source == other.source))
|
||||
}
|
||||
|
||||
fn diff_resource(out: &mut String, stored: &graph_storage::ResourceEntry, target: &graph_storage::ResourceEntry) {
|
||||
if stored.hash != target.hash {
|
||||
let _ = writeln!(out, " hash: stored={:?} target={:?}", stored.hash, target.hash);
|
||||
}
|
||||
if stored.sources.len() != target.sources.len() {
|
||||
let _ = writeln!(out, " sources.len: stored={} target={}", stored.sources.len(), target.sources.len());
|
||||
}
|
||||
|
||||
// Report source bodies that drift for a shared key, plus keys present on only one side.
|
||||
for (key, value) in &stored.sources {
|
||||
match target.source(key) {
|
||||
Some(other) if other.source != value.source => {
|
||||
let _ = writeln!(out, " source[{key:?}]: stored={:?} target={:?}", value.source, other.source);
|
||||
}
|
||||
None => {
|
||||
let _ = writeln!(out, " source[{key:?}]: only in stored");
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for (key, _) in &target.sources {
|
||||
if stored.source(key).is_none() {
|
||||
let _ = writeln!(out, " source[{key:?}]: only in target");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_attributes(out: &mut String, label: &str, stored: &graph_storage::Attributes, target: &graph_storage::Attributes) {
|
||||
let stored_keys: std::collections::BTreeSet<_> = stored.keys().collect();
|
||||
let target_keys: std::collections::BTreeSet<_> = target.keys().collect();
|
||||
let missing: Vec<_> = target_keys.difference(&stored_keys).collect();
|
||||
let extra: Vec<_> = stored_keys.difference(&target_keys).collect();
|
||||
|
||||
// Split value drift from timestamp-only drift: each attribute carries an LWW timestamp, so comparing
|
||||
// whole records would flag equal-value entries that merely differ in when they were last set.
|
||||
let (differing_values, differing_timestamps): (Vec<_>, Vec<_>) = stored_keys
|
||||
.intersection(&target_keys)
|
||||
.copied()
|
||||
.filter(|k| stored.get(*k) != target.get(*k))
|
||||
.partition(|k| stored.get(*k).map(|v| &v.value) != target.get(*k).map(|v| &v.value));
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{label}: missing_from_stored={missing:?} extra_in_stored={extra:?} differing_values={differing_values:?} differing_timestamps={differing_timestamps:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Human-readable summary of how two networks differ (exports, node set, per-node payloads, scope injections).
|
||||
pub(crate) fn diff_networks(expected: &graph_craft::document::NodeNetwork, actual: &graph_craft::document::NodeNetwork) -> String {
|
||||
let mut out = String::new();
|
||||
|
||||
if expected.exports != actual.exports {
|
||||
let _ = writeln!(out, " exports differ: expected={} actual={}", expected.exports.len(), actual.exports.len());
|
||||
for (i, (exp, act)) in expected.exports.iter().zip(actual.exports.iter()).enumerate() {
|
||||
if exp != act {
|
||||
let _ = writeln!(out, " [{i}] expected={exp:?}\n actual= {act:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expected_ids: std::collections::BTreeSet<_> = expected.nodes.keys().copied().collect();
|
||||
let actual_ids: std::collections::BTreeSet<_> = actual.nodes.keys().copied().collect();
|
||||
let missing: Vec<_> = expected_ids.difference(&actual_ids).collect();
|
||||
let extra: Vec<_> = actual_ids.difference(&expected_ids).collect();
|
||||
let differing: Vec<_> = expected_ids.intersection(&actual_ids).filter(|id| expected.nodes.get(id) != actual.nodes.get(id)).collect();
|
||||
|
||||
if !missing.is_empty() || !extra.is_empty() || !differing.is_empty() {
|
||||
let _ = writeln!(out, " nodes: expected={} actual={}", expected_ids.len(), actual_ids.len());
|
||||
if !missing.is_empty() {
|
||||
let _ = writeln!(out, " missing from actual: {missing:?}");
|
||||
}
|
||||
if !extra.is_empty() {
|
||||
let _ = writeln!(out, " extra in actual: {extra:?}");
|
||||
}
|
||||
if !differing.is_empty() {
|
||||
let _ = writeln!(out, " differing payloads: {differing:?}");
|
||||
for id in &differing {
|
||||
if let (Some(exp), Some(act)) = (expected.nodes.get(id), actual.nodes.get(id)) {
|
||||
let _ = writeln!(out, " node {id}:");
|
||||
diff_document_node(&mut out, exp, act);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if expected.scope_injections != actual.scope_injections {
|
||||
let _ = writeln!(out, " scope_injections differ");
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Field-level diff between two runtime `DocumentNode`s with the same ID, so the compare-on-open log
|
||||
/// names *which* field diverged rather than just the node ID. `original_location` is `#[serde(skip)]`
|
||||
/// and recomputed at load, so it's a likely culprit for a payload mismatch that doesn't affect behavior.
|
||||
fn diff_document_node(out: &mut String, expected: &graph_craft::document::DocumentNode, actual: &graph_craft::document::DocumentNode) {
|
||||
if expected.inputs != actual.inputs {
|
||||
let _ = writeln!(out, " inputs differ (len expected={} actual={})", expected.inputs.len(), actual.inputs.len());
|
||||
for (i, (e, a)) in expected.inputs.iter().zip(actual.inputs.iter()).enumerate() {
|
||||
if e != a {
|
||||
let _ = writeln!(out, " input[{i}]: expected={e:?}\n actual= {a:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if expected.call_argument != actual.call_argument {
|
||||
let _ = writeln!(out, " call_argument: expected={:?} actual={:?}", expected.call_argument, actual.call_argument);
|
||||
}
|
||||
if expected.implementation != actual.implementation {
|
||||
let _ = writeln!(out, " implementation: expected={:?} actual={:?}", expected.implementation, actual.implementation);
|
||||
}
|
||||
if expected.visible != actual.visible {
|
||||
let _ = writeln!(out, " visible: expected={} actual={}", expected.visible, actual.visible);
|
||||
}
|
||||
if expected.skip_deduplication != actual.skip_deduplication {
|
||||
let _ = writeln!(out, " skip_deduplication: expected={} actual={}", expected.skip_deduplication, actual.skip_deduplication);
|
||||
}
|
||||
if expected.context_features != actual.context_features {
|
||||
let _ = writeln!(out, " context_features: expected={:?} actual={:?}", expected.context_features, actual.context_features);
|
||||
}
|
||||
if expected.original_location != actual.original_location {
|
||||
let _ = writeln!(out, " original_location differs (recomputed at load, not stored):");
|
||||
let _ = writeln!(out, " expected={:?}\n actual= {:?}", expected.original_location, actual.original_location);
|
||||
}
|
||||
}
|
||||
267
editor/src/messages/portfolio/document/document_history.rs
Normal file
267
editor/src/messages/portfolio/document/document_history.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use graph_craft::application_io::resource::{ResourceId, ResourceRegistry, ResourceStorage};
|
||||
use graph_storage::Registry;
|
||||
|
||||
use super::utility_types::network_interface::NodeNetworkInterface;
|
||||
use super::utility_types::network_interface::storage_metadata::{StorageMetadataView, collect_network_view_settings};
|
||||
|
||||
/// Per-document undo/redo state: the legacy snapshot stacks plus the `Gdd` working-copy cursor that is
|
||||
/// becoming the authoritative history. Owns the dual-stack bookkeeping push/pop/clear and the cursor's stage/retire/move/verify
|
||||
/// lifecycle, so the handler drives history through one surface rather than three loose fields.
|
||||
///
|
||||
/// Not serialized: the legacy stacks are runtime-only, and a clone shares the working-copy container by
|
||||
/// `Arc`, so it keeps reading the live working copy.
|
||||
#[derive(derivative::Derivative)]
|
||||
#[derivative(Clone, Debug, Default)]
|
||||
pub struct DocumentHistory {
|
||||
/// Stack of document network snapshots for previous history states.
|
||||
lagacy_undo_stack: VecDeque<NodeNetworkInterface>,
|
||||
/// Stack of document network snapshots for future history states.
|
||||
lagacy_redo_stack: VecDeque<NodeNetworkInterface>,
|
||||
/// The `Gdd` working copy: owns the CRDT `Session` and mirrors edits to disk. `None` until the mount
|
||||
/// future built by `load_document` resolves.
|
||||
#[derivative(Debug = "ignore")]
|
||||
storage: Option<document_format::GddV1>,
|
||||
}
|
||||
|
||||
impl DocumentHistory {
|
||||
// ===== Legacy snapshot stacks =====
|
||||
|
||||
/// Push a snapshot onto the undo stack, evicting the oldest entry past the history cap.
|
||||
pub fn push_undo(&mut self, snapshot: NodeNetworkInterface) {
|
||||
Self::push_capped(&mut self.lagacy_undo_stack, snapshot);
|
||||
}
|
||||
|
||||
/// Push a snapshot onto the redo stack, evicting the oldest entry past the history cap.
|
||||
pub fn push_redo(&mut self, snapshot: NodeNetworkInterface) {
|
||||
Self::push_capped(&mut self.lagacy_redo_stack, snapshot);
|
||||
}
|
||||
|
||||
/// Pop the most recent undo snapshot, or `None` when the stack is empty.
|
||||
pub fn pop_undo(&mut self) -> Option<NodeNetworkInterface> {
|
||||
self.lagacy_undo_stack.pop_back()
|
||||
}
|
||||
|
||||
/// Pop the most recent redo snapshot, or `None` when the stack is empty.
|
||||
pub fn pop_redo(&mut self) -> Option<NodeNetworkInterface> {
|
||||
self.lagacy_redo_stack.pop_back()
|
||||
}
|
||||
|
||||
/// Drop the most recently pushed undo snapshot (used to cancel a transaction that ended up unmodified).
|
||||
pub fn discard_last_undo(&mut self) {
|
||||
self.lagacy_undo_stack.pop_back();
|
||||
}
|
||||
|
||||
/// Clear the redo stack, called when a fresh edit invalidates the redo future.
|
||||
pub fn clear_redo(&mut self) {
|
||||
self.lagacy_redo_stack.clear();
|
||||
}
|
||||
|
||||
/// Add the resources referenced by every snapshot in both history stacks into `resources`, so
|
||||
/// history-only resources stay alive for legacy undo/redo.
|
||||
pub fn collect_used_resources(&self, resources: &mut HashSet<ResourceId>) {
|
||||
for interface in self.lagacy_undo_stack.iter().chain(&self.lagacy_redo_stack) {
|
||||
interface.collect_used_resources(resources);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Gdd working-copy cursor =====
|
||||
|
||||
/// The `Gdd` working copy, `None` until the mount future resolves.
|
||||
pub fn storage(&self) -> Option<&document_format::GddV1> {
|
||||
self.storage.as_ref()
|
||||
}
|
||||
|
||||
/// Mutable access to the `Gdd` working copy.
|
||||
pub fn storage_mut(&mut self) -> Option<&mut document_format::GddV1> {
|
||||
self.storage.as_mut()
|
||||
}
|
||||
|
||||
/// Attach (or clear) the `Gdd` working copy once the mount future resolves.
|
||||
pub fn set_storage(&mut self, storage: Option<document_format::GddV1>) {
|
||||
self.storage = storage;
|
||||
}
|
||||
|
||||
/// Retire the pending staged hot ops into durable Gdd history as one undo unit. Called at each undo-step
|
||||
/// boundary (a new `StartTransaction`) and before undo/redo, so the per-`CommitTransaction` staging
|
||||
/// coalesces into one interaction aligned with the legacy step. No-op while unmounted.
|
||||
pub fn retire_storage_interaction(&mut self) {
|
||||
let Some(storage) = self.storage.as_mut() else { return };
|
||||
if let Err(error) = storage.retire_pending_interaction() {
|
||||
log::error!("Storage interaction retirement failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage the runtime snapshot into the `Gdd` working copy at each `CommitTransaction`. No-op while
|
||||
/// unmounted. Proto-node declaration bytes go into `byte_store` (the app-global resource cache). The
|
||||
/// staged hot ops are retired by [`retire_storage_interaction`](Self::retire_storage_interaction) at
|
||||
/// undo-step boundaries. `validate` (the `validate_storage_round_trip` preference) gates the per-commit
|
||||
/// round-trip check, off by default for its perf cost.
|
||||
pub fn stage_snapshot(
|
||||
&mut self,
|
||||
interface: &NodeNetworkInterface,
|
||||
registry: &ResourceRegistry,
|
||||
view_settings: BTreeMap<String, serde_json::Value>,
|
||||
legacy_document: &str,
|
||||
byte_store: &dyn ResourceStorage,
|
||||
) {
|
||||
let Some(storage) = self.storage.as_mut() else { return };
|
||||
|
||||
let network = interface.document_network();
|
||||
let metadata_view = StorageMetadataView::new(interface);
|
||||
|
||||
let network_view_settings = storage
|
||||
.network_ids(network, &metadata_view)
|
||||
.ok()
|
||||
.map(|network_ids| collect_network_view_settings(interface, &network_ids));
|
||||
|
||||
// Stage without retiring: a tool drag fires several `CommitTransaction`s but is one legacy undo
|
||||
// step, so the deltas accumulate as hot ops and coalesce at the next undo-step boundary.
|
||||
if let Err(error) = storage.stage_runtime_snapshot(network, &metadata_view, registry, byte_store) {
|
||||
log::error!("Storage snapshot staging failed: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = storage.set_view_settings(view_settings) {
|
||||
log::error!("Persisting view settings failed: {error}");
|
||||
}
|
||||
|
||||
if let Some(network_view_settings) = network_view_settings
|
||||
&& let Err(error) = storage.set_network_view_settings(network_view_settings)
|
||||
{
|
||||
log::error!("Persisting per-network view settings failed: {error}");
|
||||
}
|
||||
|
||||
// Dual-write soak: embed the legacy `.graphite` bytes so the new format
|
||||
// can be validated against (and recovered from) the old one on open.
|
||||
if let Err(error) = storage.store_legacy_document(legacy_document.as_bytes()) {
|
||||
log::error!("Embedding legacy document into working copy failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the `Gdd` undo/redo cursor along the retired interaction chain, flushing any open interaction
|
||||
/// first. Returns a clone of the post-move `Gdd` (`Arc`-shared) so a `'static` rebuild future can read
|
||||
/// the rewound state while the live document keeps its cursor. `None` when there is nothing to move to,
|
||||
/// unmounted, or the move failed.
|
||||
pub fn move_cursor(&mut self, undo: bool) -> Option<document_format::GddV1> {
|
||||
self.retire_storage_interaction();
|
||||
|
||||
let storage = self.storage.as_mut()?;
|
||||
|
||||
let moved = if undo {
|
||||
if !storage.can_undo() {
|
||||
return None;
|
||||
}
|
||||
storage.undo().map(|_| ())
|
||||
} else {
|
||||
if !storage.can_redo() {
|
||||
return None;
|
||||
}
|
||||
storage.redo().map(|_| ())
|
||||
};
|
||||
if let Err(error) = moved {
|
||||
log::error!("Storage undo/redo cursor move failed: {error}");
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(storage.clone())
|
||||
}
|
||||
|
||||
// Soak round-trip verification (runtime-gated by `validate_storage_round_trip`)
|
||||
// These log drift rather than panicking, since the soak can run in release where a crash is
|
||||
// unacceptable; tests still fail loud via the `#[cfg(test)]` panics.
|
||||
|
||||
/// Soak check: the stored registry should equal a fresh `from_runtime`, and a `to_runtime` of it should
|
||||
/// equal the original network.
|
||||
pub fn verify_round_trip(&self, interface: &NodeNetworkInterface, registry: &ResourceRegistry) {
|
||||
use super::diff_networks;
|
||||
use super::document_diff::diff_registries;
|
||||
|
||||
let Some(storage) = &self.storage else { return };
|
||||
let peer = storage.session().peer();
|
||||
|
||||
let network = interface.document_network();
|
||||
let metadata_view = StorageMetadataView::new(interface);
|
||||
|
||||
let conversion = match Registry::convert_from_runtime(network, &metadata_view, registry, peer) {
|
||||
Ok(conversion) => conversion,
|
||||
Err(error) => {
|
||||
log::error!("storage round-trip: from_runtime failed: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let target = &conversion.registry;
|
||||
let declarations = match conversion.declarations() {
|
||||
Ok(declarations) => declarations,
|
||||
Err(error) => {
|
||||
log::error!("storage round-trip: declaration rebuild failed: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let stored = storage.registry();
|
||||
if !stored.value_equal(target) {
|
||||
log::error!("storage round-trip: registry value drift after commit\n{}", diff_registries(stored, target));
|
||||
#[cfg(test)]
|
||||
panic!("storage round-trip: registry value drift after commit");
|
||||
}
|
||||
if !stored.order_consistent(target) {
|
||||
log::error!("storage round-trip: timestamp order inconsistent between stored and target");
|
||||
#[cfg(test)]
|
||||
panic!("storage round-trip: timestamp order inconsistent between stored and target");
|
||||
}
|
||||
|
||||
let (round_tripped, _entries) = match stored.to_runtime_with_metadata(&declarations) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
log::error!("storage round-trip: to_runtime failed: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if &round_tripped != network {
|
||||
log::error!("storage round-trip: network drift after to_runtime\n{}", diff_networks(network, &round_tripped));
|
||||
#[cfg(test)]
|
||||
panic!("storage round-trip: network drift after to_runtime");
|
||||
}
|
||||
}
|
||||
|
||||
/// Soak check: after a cursor move, the cursor's registry should equal a fresh `from_runtime` of the
|
||||
/// current (legacy-restored) interface. `current_resources` are the resources the live network
|
||||
/// references; history-only resources the cursor dropped are expected, not drift.
|
||||
pub fn verify_cursor_matches_runtime(&self, interface: &NodeNetworkInterface, registry: &ResourceRegistry, current_resources: &HashSet<ResourceId>) {
|
||||
use super::document_diff::diff_registries;
|
||||
|
||||
let Some(storage) = &self.storage else { return };
|
||||
let peer = storage.session().peer();
|
||||
|
||||
let network = interface.document_network();
|
||||
let metadata_view = StorageMetadataView::new(interface);
|
||||
|
||||
let Ok(mut conversion) = Registry::convert_from_runtime(network, &metadata_view, registry, peer) else {
|
||||
log::error!("undo/redo shadow: from_runtime failed");
|
||||
return;
|
||||
};
|
||||
|
||||
let stored = storage.registry();
|
||||
|
||||
// The cursor reverts the interaction's `AddResource` while the runtime keeps the resource alive for
|
||||
// legacy redo, so a history-only resource the cursor dropped is expected. Drop those before comparing.
|
||||
conversion.registry.resources.retain(|id, _| stored.resources.contains_key(id) || current_resources.contains(id));
|
||||
|
||||
if !stored.value_equal(&conversion.registry) {
|
||||
log::error!(
|
||||
"undo/redo shadow: cursor registry diverged from the restored interface\n{}",
|
||||
diff_registries(stored, &conversion.registry)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_capped(stack: &mut VecDeque<NodeNetworkInterface>, snapshot: NodeNetworkInterface) {
|
||||
stack.push_back(snapshot);
|
||||
if stack.len() > crate::consts::MAX_UNDO_HISTORY_LEN {
|
||||
stack.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use super::DocumentHistory;
|
||||
use super::document_diff::diff_networks;
|
||||
use super::node_graph::document_node_definitions;
|
||||
use super::utility_types::error::EditorError;
|
||||
use super::utility_types::misc::{GroupFolderType, SNAP_FUNCTIONS_FOR_BOUNDING_BOXES, SNAP_FUNCTIONS_FOR_PATHS, SnappingOptions, SnappingState};
|
||||
@@ -5,7 +7,7 @@ use super::utility_types::network_interface::{self, NodeNetworkInterface, Transa
|
||||
use super::utility_types::nodes::{CollapsedLayers, LayerStructureEntry, SelectedNodes};
|
||||
use crate::application::{GRAPHITE_GIT_COMMIT_HASH, generate_uuid};
|
||||
use crate::consts::{
|
||||
ASYMPTOTIC_EFFECT, BLEND_COUNT_PER_LAYER, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING,
|
||||
ASYMPTOTIC_EFFECT, BLEND_COUNT_PER_LAYER, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, GDD_FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING,
|
||||
VIEWPORT_ROTATE_SNAP_INTERVAL,
|
||||
};
|
||||
use crate::messages::input_mapper::utility_types::macros::action_shortcut;
|
||||
@@ -64,7 +66,8 @@ pub struct DocumentMessageContext<'a> {
|
||||
pub fonts: &'a FontsMessageHandler,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, ExtractField)]
|
||||
#[derive(derivative::Derivative, serde::Serialize, serde::Deserialize, ExtractField)]
|
||||
#[derivative(Clone, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct DocumentMessageHandler {
|
||||
// ======================
|
||||
@@ -138,12 +141,9 @@ pub struct DocumentMessageHandler {
|
||||
/// Path to network that is currently selected. Updated based on the most recently clicked panel.
|
||||
#[serde(skip)]
|
||||
selection_network_path: Vec<NodeId>,
|
||||
/// Stack of document network snapshots for previous history states.
|
||||
/// Undo/redo state: the legacy snapshot stacks plus the `Gdd` working-copy cursor.
|
||||
#[serde(skip)]
|
||||
document_undo_history: VecDeque<NodeNetworkInterface>,
|
||||
/// Stack of document network snapshots for future history states.
|
||||
#[serde(skip)]
|
||||
document_redo_history: VecDeque<NodeNetworkInterface>,
|
||||
history: DocumentHistory,
|
||||
/// Hash of the document snapshot that was most recently saved to disk by the user.
|
||||
#[serde(skip)]
|
||||
saved_hash: Option<u64>,
|
||||
@@ -194,8 +194,7 @@ impl Default for DocumentMessageHandler {
|
||||
pending_gradient_migration: false,
|
||||
breadcrumb_network_path: Vec::new(),
|
||||
selection_network_path: Vec::new(),
|
||||
document_undo_history: VecDeque::new(),
|
||||
document_redo_history: VecDeque::new(),
|
||||
history: DocumentHistory::default(),
|
||||
saved_hash: None,
|
||||
auto_saved_hash: None,
|
||||
layer_range_selection_reference: None,
|
||||
@@ -402,8 +401,8 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] });
|
||||
self.layer_range_selection_reference = None;
|
||||
}
|
||||
DocumentMessage::DocumentHistoryBackward => self.undo_with_history(viewport, responses),
|
||||
DocumentMessage::DocumentHistoryForward => self.redo_with_history(viewport, responses),
|
||||
DocumentMessage::DocumentHistoryBackward => self.undo_with_history(document_id, viewport, resource_storage, responses),
|
||||
DocumentMessage::DocumentHistoryForward => self.redo_with_history(document_id, viewport, resource_storage, responses),
|
||||
DocumentMessage::DocumentStructureChanged => {
|
||||
if layers_panel_open {
|
||||
self.network_interface.load_structure();
|
||||
@@ -823,7 +822,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
let layer_node_id = NodeId::new();
|
||||
let layer_id = LayerNodeIdentifier::new_unchecked(layer_node_id);
|
||||
|
||||
responses.add(DocumentMessage::AddTransaction);
|
||||
responses.add(DocumentMessage::StartTransaction);
|
||||
|
||||
let layer = graph_modification_utils::new_image_layer(image, layer_node_id, layer_parent, responses);
|
||||
|
||||
@@ -832,7 +831,8 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
node_id: layer.to_node(),
|
||||
network_path: Vec::new(),
|
||||
alias: name,
|
||||
skip_adding_history_step: false,
|
||||
// Fold the name into the paste's single transaction so the whole paste is one undo step.
|
||||
skip_adding_history_step: true,
|
||||
});
|
||||
}
|
||||
if let Some((parent, insert_index)) = parent_and_insert_index {
|
||||
@@ -853,6 +853,8 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
skip_rerender: false,
|
||||
});
|
||||
|
||||
responses.add(DocumentMessage::CommitTransaction);
|
||||
|
||||
// Force chosen tool to be Select Tool after importing image.
|
||||
responses.add(ToolMessage::ActivateTool { tool_type: ToolType::Select });
|
||||
}
|
||||
@@ -890,7 +892,8 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
node_id: layer.to_node(),
|
||||
network_path: Vec::new(),
|
||||
alias: name,
|
||||
skip_adding_history_step: false,
|
||||
// Fold the name into the paste's single transaction so the whole paste is one undo step.
|
||||
skip_adding_history_step: true,
|
||||
});
|
||||
}
|
||||
if let Some((parent, insert_index)) = parent_and_insert_index {
|
||||
@@ -1024,28 +1027,63 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
DocumentMessage::SaveDocument | DocumentMessage::SaveDocumentAs => {
|
||||
responses.add(PortfolioMessage::AutoSaveActiveDocument);
|
||||
|
||||
let name = format!("{}.{}", self.name.clone(), FILE_EXTENSION);
|
||||
let path = if let DocumentMessage::SaveDocumentAs = message { None } else { self.path.clone() };
|
||||
// Save as a `.gdd` container or a plain legacy `.graphite`, per the editor preference.
|
||||
let save_as_gdd = preferences.save_as_gdd;
|
||||
let extension = if save_as_gdd { GDD_FILE_EXTENSION } else { FILE_EXTENSION };
|
||||
let name = format!("{}.{}", self.name.clone(), extension);
|
||||
|
||||
// Keep the path's extension in sync with the chosen format so bytes and filename agree.
|
||||
let path = match message {
|
||||
DocumentMessage::SaveDocumentAs => None,
|
||||
_ => self.path.clone().map(|path| path.with_extension(extension)),
|
||||
};
|
||||
if path.is_some() {
|
||||
responses.add(DocumentMessage::MarkAsSaved);
|
||||
}
|
||||
let folder = self.path.as_ref().and_then(|path| path.parent()).map(|parent| parent.to_path_buf());
|
||||
|
||||
// The clone shares the working-copy container (Arc), so it reads the state the queued
|
||||
// AutoSaveActiveDocument just committed.
|
||||
let mut document = self.clone();
|
||||
let resources_load_handle = resource_storage.resources();
|
||||
let export_load_handle = resource_storage.resources();
|
||||
|
||||
responses.add(async move {
|
||||
document.resources.collect_garbage(document.used_resources(false).as_ref());
|
||||
document.resources.embed_resources(resources_load_handle).await;
|
||||
|
||||
let content = document.serialize_document().into_bytes().into();
|
||||
// Legacy .graphite blob with resources embedded inline, so it is self-contained and also
|
||||
// serves as the .gdd recovery fallback.
|
||||
let legacy_document = document.serialize_document().into_bytes();
|
||||
|
||||
// A .gdd exports the working copy with the legacy blob embedded, falling back to the bare
|
||||
// blob when there is no working copy or the export fails.
|
||||
let content = match (save_as_gdd, document.history.storage()) {
|
||||
(false, _) => legacy_document,
|
||||
(true, Some(storage)) => storage
|
||||
.export_to_bytes(
|
||||
document_format::ExportFormat::Xz,
|
||||
document_format::ExportOptions::default(),
|
||||
export_load_handle.as_ref(),
|
||||
Some(&legacy_document),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
log::error!("Save: building .gdd export failed, falling back to legacy .graphite: {error}");
|
||||
legacy_document
|
||||
}),
|
||||
(true, None) => {
|
||||
log::warn!("Save: working copy not mounted yet, saving legacy .graphite only");
|
||||
legacy_document
|
||||
}
|
||||
};
|
||||
|
||||
Message::Frontend(FrontendMessage::TriggerSaveDocument {
|
||||
document_id,
|
||||
name,
|
||||
path,
|
||||
folder,
|
||||
content,
|
||||
content: content.into(),
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -1057,8 +1095,8 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
|
||||
// Update the name to match the file stem
|
||||
let document_name_from_path = self.path.as_ref().and_then(|path| {
|
||||
if path.extension().is_some_and(|e| e == FILE_EXTENSION) {
|
||||
path.file_stem().map(|n| n.to_string_lossy().to_string())
|
||||
if path.extension().is_some_and(|extension| extension == FILE_EXTENSION || extension == GDD_FILE_EXTENSION) {
|
||||
path.file_stem().map(|stem| stem.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -1291,12 +1329,10 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
}
|
||||
// Note: A transaction should never be started in a scope that mutates the network interface, since it will only be run after that scope ends.
|
||||
DocumentMessage::StartTransaction => {
|
||||
self.retire_storage_interaction();
|
||||
|
||||
self.network_interface.start_transaction();
|
||||
let network_interface_clone = self.network_interface.clone();
|
||||
self.document_undo_history.push_back(network_interface_clone);
|
||||
if self.document_undo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN {
|
||||
self.document_undo_history.pop_front();
|
||||
}
|
||||
self.history.push_undo(self.network_interface.clone());
|
||||
// Push the UpdateOpenDocumentsList message to the bus in order to update the save status of the open documents
|
||||
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
|
||||
}
|
||||
@@ -1312,14 +1348,17 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
},
|
||||
DocumentMessage::CancelTransaction => {
|
||||
self.network_interface.finish_transaction();
|
||||
self.document_undo_history.pop_back();
|
||||
self.history.discard_last_undo();
|
||||
}
|
||||
DocumentMessage::CommitTransaction => {
|
||||
if self.network_interface.transaction_status() == TransactionStatus::Finished {
|
||||
return;
|
||||
}
|
||||
self.network_interface.finish_transaction();
|
||||
self.document_redo_history.clear();
|
||||
self.history.clear_redo();
|
||||
|
||||
self.commit_storage_snapshot(&resource_storage.resources_mut(), preferences.validate_storage_round_trip);
|
||||
|
||||
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
|
||||
}
|
||||
DocumentMessage::AbortTransaction => match self.network_interface.transaction_status() {
|
||||
@@ -1754,6 +1793,35 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
|
||||
}
|
||||
|
||||
impl DocumentMessageHandler {
|
||||
/// Build a document handler from a `.gdd` working copy.
|
||||
pub fn from_storage(interface: NodeNetworkInterface, storage: document_format::GddV1, name: String, path: Option<std::path::PathBuf>) -> Self {
|
||||
let mut document = Self {
|
||||
network_interface: interface,
|
||||
name,
|
||||
path,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
document.apply_stored_document_settings(storage.view_settings());
|
||||
match storage.registry().to_resource_registry() {
|
||||
Ok(resource_registry) => document.resources.registry = resource_registry,
|
||||
Err(error) => log::error!("Opening .gdd: failed to rebuild resource registry: {error}"),
|
||||
}
|
||||
document.history.set_storage(Some(storage));
|
||||
|
||||
document
|
||||
}
|
||||
|
||||
/// Post-load fixups for a document built from storage.
|
||||
pub fn finalize_storage_load(&mut self) {
|
||||
for (node_id, node, path) in self.network_interface.document_network().clone().recursive_nodes() {
|
||||
self.network_interface.validate_input_metadata(node_id, node, &path);
|
||||
self.network_interface.validate_output_names(node_id, node, &path);
|
||||
}
|
||||
|
||||
self.network_interface.load_structure();
|
||||
}
|
||||
|
||||
/// Translates a viewport mouse position to a document-space transform, or uses the viewport center if no mouse position is given.
|
||||
fn document_transform_from_mouse(&self, mouse: Option<(f64, f64)>, viewport: &ViewportMessageHandler) -> DAffine2 {
|
||||
let viewport_pos: DVec2 = mouse.map_or_else(|| viewport.center_in_viewport_space().into_dvec2() + viewport.offset().into_dvec2(), |pos| pos.into());
|
||||
@@ -1929,6 +1997,133 @@ impl DocumentMessageHandler {
|
||||
&self.selection_network_path
|
||||
}
|
||||
|
||||
/// The `Gdd` working copy, `None` until the mount future resolves.
|
||||
pub fn storage(&self) -> Option<&document_format::GddV1> {
|
||||
self.history.storage()
|
||||
}
|
||||
|
||||
/// Mutable access to the `Gdd` working copy.
|
||||
pub fn storage_mut(&mut self) -> Option<&mut document_format::GddV1> {
|
||||
self.history.storage_mut()
|
||||
}
|
||||
|
||||
/// Attach (or clear) the `Gdd` working copy once the mount future resolves.
|
||||
pub fn set_storage(&mut self, storage: Option<document_format::GddV1>) {
|
||||
self.history.set_storage(storage);
|
||||
}
|
||||
|
||||
/// Retire the pending staged hot ops into durable Gdd history as one undo unit.
|
||||
pub(crate) fn retire_storage_interaction(&mut self) {
|
||||
self.history.retire_storage_interaction();
|
||||
}
|
||||
|
||||
/// Stages the runtime network into the `Gdd` working copy.
|
||||
pub fn commit_storage_snapshot(&mut self, byte_store: &dyn graph_craft::application_io::resource::ResourceStorage, validate: bool) {
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::DocumentSettings;
|
||||
|
||||
if self.history.storage().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let view_settings = DocumentSettings {
|
||||
document_ptz: &self.document_ptz,
|
||||
render_mode: &self.render_mode,
|
||||
overlays_visibility: &self.overlays_visibility_settings,
|
||||
rulers_visible: self.rulers_visible,
|
||||
snapping_state: &self.snapping_state,
|
||||
collapsed: &self.collapsed,
|
||||
}
|
||||
.to_view_map();
|
||||
|
||||
let legacy_document = self.serialize_document();
|
||||
|
||||
self.history
|
||||
.stage_snapshot(&self.network_interface, &self.resources.registry, view_settings, legacy_document.as_str(), byte_store);
|
||||
|
||||
if validate {
|
||||
self.history.verify_round_trip(&self.network_interface, &self.resources.registry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore `view_settings` map into the document.
|
||||
pub fn apply_stored_document_settings(&mut self, view_settings: &std::collections::BTreeMap<String, serde_json::Value>) {
|
||||
use graph_storage::attr::session::doc;
|
||||
|
||||
fn decode<T: serde::de::DeserializeOwned>(view_settings: &std::collections::BTreeMap<String, serde_json::Value>, key: &str) -> Option<T> {
|
||||
view_settings.get(key).and_then(|value| serde_json::from_value(value.clone()).ok())
|
||||
}
|
||||
|
||||
if let Some(value) = decode(view_settings, doc::PTZ) {
|
||||
self.document_ptz = value;
|
||||
}
|
||||
if let Some(value) = decode(view_settings, doc::RENDER_MODE) {
|
||||
self.render_mode = value;
|
||||
}
|
||||
if let Some(value) = decode(view_settings, doc::OVERLAYS) {
|
||||
self.overlays_visibility_settings = value;
|
||||
}
|
||||
if let Some(value) = decode(view_settings, doc::RULERS_VISIBLE) {
|
||||
self.rulers_visible = value;
|
||||
}
|
||||
if let Some(value) = decode(view_settings, doc::SNAPPING) {
|
||||
self.snapping_state = value;
|
||||
}
|
||||
if let Some(value) = decode(view_settings, doc::COLLAPSED) {
|
||||
self.collapsed = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the `Gdd` undo/redo cursor and spawn the async future that rebuilds the
|
||||
/// interface from the cursor and swaps it in. `had_oracle` records whether the legacy snapshot already
|
||||
/// applied, so the completion can compare; it travels with the spawned message. Returns whether the
|
||||
/// cursor moved, so callers know a rebuild is pending.
|
||||
fn drive_storage_undo_redo(&mut self, document_id: DocumentId, resource_storage: &ResourceStorageMessageHandler, had_oracle: bool, undo: bool, responses: &mut VecDeque<Message>) -> bool {
|
||||
let Some(gdd) = self.history.move_cursor(undo) else { return false };
|
||||
|
||||
responses.add(crate::messages::portfolio::document_storage_io::rebuild_gdd_cursor(
|
||||
gdd,
|
||||
resource_storage.resources_mut(),
|
||||
document_id,
|
||||
had_oracle,
|
||||
));
|
||||
true
|
||||
}
|
||||
|
||||
/// Swap in the interface rebuilt from the `Gdd` cursor. Always overwrites the interface.
|
||||
pub(crate) fn apply_gdd_cursor_rebuild(&mut self, mut rebuilt: NodeNetworkInterface, had_oracle: bool, validate: bool, responses: &mut VecDeque<Message>) {
|
||||
rebuilt.copy_all_transient_view_state(&self.network_interface);
|
||||
std::mem::swap(&mut rebuilt.resolved_types, &mut self.network_interface.resolved_types);
|
||||
rebuilt.load_structure();
|
||||
|
||||
if validate && had_oracle {
|
||||
self.compare_rebuild_against_legacy(&rebuilt);
|
||||
}
|
||||
|
||||
self.network_interface = rebuilt;
|
||||
|
||||
if validate {
|
||||
let current_resources: std::collections::HashSet<_> = self.used_resources(false).iter().copied().collect();
|
||||
self.history.verify_cursor_matches_runtime(&self.network_interface, &self.resources.registry, ¤t_resources);
|
||||
}
|
||||
|
||||
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
|
||||
responses.add(NodeGraphMessage::SelectedNodesUpdated);
|
||||
responses.add(NodeGraphMessage::ForceRunDocumentGraph);
|
||||
responses.add(NodeGraphMessage::UnloadWires);
|
||||
responses.add(NodeGraphMessage::SendWires);
|
||||
}
|
||||
|
||||
/// Check if the rebuilt network equals the legacy-restored one. Logs drift (panics in tests).
|
||||
fn compare_rebuild_against_legacy(&self, rebuilt: &NodeNetworkInterface) {
|
||||
let legacy = self.network_interface.document_network();
|
||||
let candidate = rebuilt.document_network();
|
||||
if candidate != legacy {
|
||||
log::error!("undo/redo rebuild diverged from the legacy snapshot\n{}", diff_networks(legacy, candidate));
|
||||
#[cfg(test)]
|
||||
panic!("undo/redo rebuild diverged from the legacy snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize_document(&self) -> String {
|
||||
let val = serde_json::to_string(self);
|
||||
// We fully expect the serialization to succeed
|
||||
@@ -2206,18 +2401,20 @@ impl DocumentMessageHandler {
|
||||
paths
|
||||
}
|
||||
|
||||
pub fn undo_with_history(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque<Message>) {
|
||||
let Some(previous_network) = self.undo(viewport, responses) else { return };
|
||||
pub fn undo_with_history(&mut self, document_id: DocumentId, viewport: &ViewportMessageHandler, resource_storage: &ResourceStorageMessageHandler, responses: &mut VecDeque<Message>) {
|
||||
let legacy_applied = if let Some(previous_network) = self.undo(viewport, responses) {
|
||||
self.history.push_redo(previous_network);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
self.document_redo_history.push_back(previous_network);
|
||||
if self.document_redo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN {
|
||||
self.document_redo_history.pop_front();
|
||||
}
|
||||
self.drive_storage_undo_redo(document_id, resource_storage, legacy_applied, true, responses);
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque<Message>) -> Option<NodeNetworkInterface> {
|
||||
// If there is no history return and don't broadcast SelectionChanged
|
||||
let mut network_interface = self.document_undo_history.pop_back()?;
|
||||
let mut network_interface = self.history.pop_undo()?;
|
||||
|
||||
// Set the previous network navigation metadata to the current navigation metadata
|
||||
network_interface.copy_all_navigation_metadata(&self.network_interface);
|
||||
@@ -2242,19 +2439,20 @@ impl DocumentMessageHandler {
|
||||
|
||||
Some(previous_network)
|
||||
}
|
||||
pub fn redo_with_history(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque<Message>) {
|
||||
// Push the UpdateOpenDocumentsList message to the queue in order to update the save status of the open documents
|
||||
let Some(previous_network) = self.redo(viewport, responses) else { return };
|
||||
pub fn redo_with_history(&mut self, document_id: DocumentId, viewport: &ViewportMessageHandler, resource_storage: &ResourceStorageMessageHandler, responses: &mut VecDeque<Message>) {
|
||||
let legacy_applied = if let Some(previous_network) = self.redo(viewport, responses) {
|
||||
self.history.push_undo(previous_network);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
self.document_undo_history.push_back(previous_network);
|
||||
if self.document_undo_history.len() > crate::consts::MAX_UNDO_HISTORY_LEN {
|
||||
self.document_undo_history.pop_front();
|
||||
}
|
||||
self.drive_storage_undo_redo(document_id, resource_storage, legacy_applied, false, responses);
|
||||
}
|
||||
|
||||
pub fn redo(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque<Message>) -> Option<NodeNetworkInterface> {
|
||||
// If there is no history return and don't broadcast SelectionChanged
|
||||
let mut network_interface = self.document_redo_history.pop_back()?;
|
||||
let mut network_interface = self.history.pop_redo()?;
|
||||
|
||||
// Set the previous network navigation metadata to the current navigation metadata
|
||||
network_interface.copy_all_navigation_metadata(&self.network_interface);
|
||||
@@ -3589,8 +3787,7 @@ impl DocumentMessageHandler {
|
||||
let mut resources = HashSet::new();
|
||||
self.network_interface.collect_used_resources(&mut resources);
|
||||
if include_history {
|
||||
self.document_undo_history.iter().for_each(|interface| interface.collect_used_resources(&mut resources));
|
||||
self.document_redo_history.iter().for_each(|interface| interface.collect_used_resources(&mut resources));
|
||||
self.history.collect_used_resources(&mut resources);
|
||||
}
|
||||
resources.into_iter().collect::<Vec<_>>().into_boxed_slice()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
pub(crate) mod document_diff;
|
||||
mod document_history;
|
||||
mod document_message;
|
||||
mod document_message_handler;
|
||||
#[cfg(test)]
|
||||
mod storage_tests;
|
||||
|
||||
pub mod data_panel;
|
||||
pub mod graph_operation;
|
||||
@@ -10,6 +14,8 @@ pub mod properties_panel;
|
||||
pub mod resource;
|
||||
pub mod utility_types;
|
||||
|
||||
pub(crate) use document_diff::diff_networks;
|
||||
pub(crate) use document_history::DocumentHistory;
|
||||
#[doc(inline)]
|
||||
pub use document_message::{DocumentMessage, DocumentMessageDiscriminant};
|
||||
#[doc(inline)]
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Conversion-level round-trip tests for the [`storage_metadata`](crate::messages::portfolio::document::utility_types::network_interface::storage_metadata)
|
||||
//! bridge: drive a demo `.graphite` document through `Registry` (and back through
|
||||
//! `build_interface_from_storage`) without an actual save/reopen, asserting the editor's `ui::*`
|
||||
//! metadata survives the conversion. The end-to-end save/reopen pipeline is covered separately in
|
||||
//! [`round_trip_tests`](super::round_trip_tests).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use graph_storage::{NodeMetadataSource, PeerId, Registry};
|
||||
|
||||
use super::test_support::{load_demo, node_paths};
|
||||
use crate::messages::portfolio::document::document_message_handler::DocumentMessageHandler;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{DocumentSettings, StorageMetadataView, build_interface_from_storage};
|
||||
use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers;
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::vector::style::RenderMode;
|
||||
|
||||
/// Loads a demo artwork, round-trips its `NodeNetwork + NodeNetworkInterface metadata` through
|
||||
/// `Registry`, and asserts every node's `ui::*` attributes survive unchanged.
|
||||
///
|
||||
/// Uses one demo (rather than the full set) because this is an exhaustive per-node check.
|
||||
#[test]
|
||||
fn editor_metadata_round_trip_against_demo() {
|
||||
let document = load_demo("changing-seasons.graphite");
|
||||
let interface = &document.network_interface;
|
||||
let source = StorageMetadataView::new(interface);
|
||||
|
||||
let network = interface.document_network().clone();
|
||||
|
||||
let conversion = Registry::convert_from_runtime(&network, &source, &Default::default(), PeerId(0)).expect("convert_from_runtime failed");
|
||||
let declarations = conversion.declarations().expect("rebuild declarations");
|
||||
let registry = conversion.registry;
|
||||
|
||||
let (_converted_network, entries) = registry.to_runtime_with_metadata(&declarations).expect("to_runtime_with_metadata failed");
|
||||
|
||||
// Index emitted entries by their (network_path, local_id) address.
|
||||
let entries_by_address: HashMap<(Vec<NodeId>, NodeId), &graph_storage::NodeMetadataEntry> = entries.iter().map(|e| ((e.network_path.clone(), e.local_id), e)).collect();
|
||||
|
||||
let mut checked_any_position = false;
|
||||
let mut checked_any_layer = false;
|
||||
let mut checked_any_input_metadata = false;
|
||||
|
||||
for (network_path, local_id) in node_paths(interface) {
|
||||
let expected_position = source.position(&network_path, local_id);
|
||||
let expected_is_layer = source.is_layer(&network_path, local_id);
|
||||
let expected_display = source.display_name(&network_path, local_id).map(str::to_owned);
|
||||
let expected_locked = source.locked(&network_path, local_id);
|
||||
let expected_pinned = source.pinned(&network_path, local_id);
|
||||
let expected_output_names = source.output_names(&network_path, local_id);
|
||||
|
||||
// Use the node's input count as the slot-by-slot iteration bound for the per-input metadata checks.
|
||||
let input_count = interface.document_node(&local_id, &network_path).map(|node| node.inputs.len()).unwrap_or(0);
|
||||
let any_input_metadata_present = (0..input_count).any(|i| {
|
||||
source.input_name(&network_path, local_id, i).is_some_and(|s| !s.is_empty())
|
||||
|| source.input_description(&network_path, local_id, i).is_some_and(|s| !s.is_empty())
|
||||
|| source.widget_override(&network_path, local_id, i).is_some()
|
||||
|| !source.input_data(&network_path, local_id, i).is_empty()
|
||||
});
|
||||
|
||||
let any_metadata = expected_position.is_some()
|
||||
|| expected_is_layer
|
||||
|| expected_display.as_deref().is_some_and(|s| !s.is_empty())
|
||||
|| expected_locked
|
||||
|| expected_pinned
|
||||
|| any_input_metadata_present
|
||||
|| !expected_output_names.is_empty();
|
||||
|
||||
if !any_metadata {
|
||||
assert!(
|
||||
!entries_by_address.contains_key(&(network_path.clone(), local_id)),
|
||||
"node {local_id:?} in network {network_path:?} has no editor metadata but produced an entry"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry = entries_by_address
|
||||
.get(&(network_path.clone(), local_id))
|
||||
.unwrap_or_else(|| panic!("missing entry for node {local_id:?} in network {network_path:?}"));
|
||||
|
||||
assert_eq!(entry.position, expected_position, "position mismatch for node {local_id:?} in network {network_path:?}");
|
||||
assert_eq!(entry.is_layer, expected_is_layer, "is_layer mismatch for node {local_id:?} in network {network_path:?}");
|
||||
// The trait round-trip drops empty display names (treats them as "unset") so the entry's
|
||||
// `display_name` is `None` when the runtime carries `""`. Normalize before comparing.
|
||||
let normalized_expected_display = expected_display.as_deref().filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
assert_eq!(
|
||||
entry.display_name, normalized_expected_display,
|
||||
"display_name mismatch for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
assert_eq!(entry.locked, expected_locked, "locked mismatch for node {local_id:?} in network {network_path:?}");
|
||||
assert_eq!(entry.pinned, expected_pinned, "pinned mismatch for node {local_id:?} in network {network_path:?}");
|
||||
assert_eq!(entry.output_names, expected_output_names, "output_names mismatch for node {local_id:?} in network {network_path:?}");
|
||||
|
||||
// Per-input metadata: the entry's `input_metadata` has one slot per input, each matching the trait's
|
||||
// per-field view (same "empty string ↔ None" normalization as `display_name`).
|
||||
assert_eq!(
|
||||
entry.input_metadata.len(),
|
||||
input_count,
|
||||
"input_metadata length mismatch for node {local_id:?} in network {network_path:?}: entry has {}, node has {input_count}",
|
||||
entry.input_metadata.len(),
|
||||
);
|
||||
for (index, input_entry) in entry.input_metadata.iter().enumerate() {
|
||||
let expected_name = source.input_name(&network_path, local_id, index).filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
let expected_description = source.input_description(&network_path, local_id, index).filter(|s| !s.is_empty()).map(str::to_owned);
|
||||
let expected_widget = source.widget_override(&network_path, local_id, index).map(str::to_owned);
|
||||
let expected_data = source.input_data(&network_path, local_id, index);
|
||||
|
||||
assert_eq!(
|
||||
input_entry.input_name, expected_name,
|
||||
"input_name mismatch at slot {index} for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
input_entry.input_description, expected_description,
|
||||
"input_description mismatch at slot {index} for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
input_entry.widget_override, expected_widget,
|
||||
"widget_override mismatch at slot {index} for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
input_entry.input_data, expected_data,
|
||||
"input_data mismatch at slot {index} for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
}
|
||||
|
||||
if entry.position.is_some() {
|
||||
checked_any_position = true;
|
||||
}
|
||||
if entry.is_layer {
|
||||
checked_any_layer = true;
|
||||
}
|
||||
if any_input_metadata_present {
|
||||
checked_any_input_metadata = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: a real artwork should exercise at least these two shapes; otherwise the test is
|
||||
// just iterating empty metadata and proving nothing.
|
||||
assert!(checked_any_position, "demo artwork produced no positioned nodes: fixture is wrong or extraction is broken");
|
||||
assert!(checked_any_layer, "demo artwork produced no layer nodes: fixture is wrong or extraction is broken");
|
||||
// Demo artworks always have some custom widget overrides / input names; if not, the
|
||||
// per-input round-trip isn't actually being exercised here.
|
||||
assert!(checked_any_input_metadata, "demo artwork produced no per-input metadata: fixture is wrong or extraction is broken");
|
||||
}
|
||||
|
||||
/// Full editor-side round-trip: original interface → Registry → (NodeNetwork, Vec<entry>) →
|
||||
/// freshly-built interface. Asserts the rebuilt interface presents the same `ui::*` state as
|
||||
/// the original when read through `StorageMetadataView`.
|
||||
#[test]
|
||||
fn editor_interface_rebuild_round_trip() {
|
||||
let document = load_demo("changing-seasons.graphite");
|
||||
let original = &document.network_interface;
|
||||
let original_view = StorageMetadataView::new(original);
|
||||
|
||||
let network = original.document_network().clone();
|
||||
let conversion = Registry::convert_from_runtime(&network, &original_view, &Default::default(), PeerId(0)).expect("convert_from_runtime failed");
|
||||
let declarations = conversion.declarations().expect("rebuild declarations");
|
||||
let registry = conversion.registry;
|
||||
let (rebuilt_network, node_entries, network_entries) = registry.to_runtime_with_full_metadata(&declarations).expect("to_runtime_with_full_metadata failed");
|
||||
|
||||
let rebuilt = build_interface_from_storage(rebuilt_network, node_entries, network_entries).expect("build_interface_from_storage failed");
|
||||
let rebuilt_view = StorageMetadataView::new(&rebuilt);
|
||||
|
||||
// Every node the original carried must also resolve identically through the rebuilt view.
|
||||
// Iterating over the *rebuilt* interface verifies that the rebuild covered every node, not
|
||||
// just the ones the entries vec mentioned.
|
||||
for (network_path, local_id) in node_paths(&rebuilt) {
|
||||
assert_eq!(
|
||||
rebuilt_view.position(&network_path, local_id),
|
||||
original_view.position(&network_path, local_id),
|
||||
"position mismatch for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
rebuilt_view.is_layer(&network_path, local_id),
|
||||
original_view.is_layer(&network_path, local_id),
|
||||
"is_layer mismatch for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
// Original display names are returned by the source as-is (including `""`). After
|
||||
// round-trip the rebuilt interface also stores `""` for nodes that had no name set,
|
||||
// so this comparison is exact.
|
||||
assert_eq!(
|
||||
rebuilt_view.display_name(&network_path, local_id),
|
||||
original_view.display_name(&network_path, local_id),
|
||||
"display_name mismatch for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
rebuilt_view.locked(&network_path, local_id),
|
||||
original_view.locked(&network_path, local_id),
|
||||
"locked mismatch for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
rebuilt_view.pinned(&network_path, local_id),
|
||||
original_view.pinned(&network_path, local_id),
|
||||
"pinned mismatch for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
|
||||
// Per-input metadata: walk both interfaces' input slots in lockstep.
|
||||
let original_inputs = original.document_node(&local_id, &network_path).map(|n| n.inputs.len()).unwrap_or(0);
|
||||
let rebuilt_inputs = rebuilt.document_node(&local_id, &network_path).map(|n| n.inputs.len()).unwrap_or(0);
|
||||
assert_eq!(
|
||||
rebuilt_inputs, original_inputs,
|
||||
"input count mismatch for node {local_id:?} in network {network_path:?}: rebuilt={rebuilt_inputs} original={original_inputs}"
|
||||
);
|
||||
|
||||
for index in 0..rebuilt_inputs {
|
||||
assert_eq!(
|
||||
rebuilt_view.input_name(&network_path, local_id, index),
|
||||
original_view.input_name(&network_path, local_id, index),
|
||||
"input_name mismatch at slot {index} for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
rebuilt_view.input_description(&network_path, local_id, index),
|
||||
original_view.input_description(&network_path, local_id, index),
|
||||
"input_description mismatch at slot {index} for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
rebuilt_view.widget_override(&network_path, local_id, index),
|
||||
original_view.widget_override(&network_path, local_id, index),
|
||||
"widget_override mismatch at slot {index} for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
rebuilt_view.input_data(&network_path, local_id, index),
|
||||
original_view.input_data(&network_path, local_id, index),
|
||||
"input_data mismatch at slot {index} for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
rebuilt_view.output_names(&network_path, local_id),
|
||||
original_view.output_names(&network_path, local_id),
|
||||
"output_names mismatch for node {local_id:?} in network {network_path:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Symmetric: every node in the original must also exist in the rebuilt interface.
|
||||
for (network_path, local_id) in node_paths(original) {
|
||||
assert!(
|
||||
rebuilt.nested_network(&network_path).and_then(|n| n.nodes.get(&local_id)).is_some(),
|
||||
"original node {local_id:?} in network {network_path:?} missing after rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
// Per-network metadata: every nested network path (including the root) must resolve to the same
|
||||
// `reference` in the rebuilt interface. Node-graph nav + previewing are per-peer view state that
|
||||
// lives in `session.json`, not the registry, so they're not round-tripped here.
|
||||
let mut network_paths_to_check: Vec<Vec<NodeId>> = vec![Vec::new()];
|
||||
for (network_path, local_id) in node_paths(original) {
|
||||
let mut child = network_path.clone();
|
||||
child.push(local_id);
|
||||
if original.nested_network(&child).is_some() {
|
||||
network_paths_to_check.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
let mut checked_any_reference = false;
|
||||
for path in &network_paths_to_check {
|
||||
assert_eq!(rebuilt_view.reference(path), original_view.reference(path), "reference mismatch for network {path:?}");
|
||||
if original_view.reference(path).is_some() {
|
||||
checked_any_reference = true;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(checked_any_reference, "demo artwork produced no reference metadata: fixture is wrong or extraction is broken");
|
||||
}
|
||||
|
||||
/// Per-peer view settings (`ui::doc::*`) survive the `session.json` round-trip: serialize them into
|
||||
/// the view map, then apply it onto a fresh handler and confirm each field matches.
|
||||
#[test]
|
||||
fn document_settings_round_trip() {
|
||||
let mut document = load_demo("changing-seasons.graphite");
|
||||
|
||||
// Set distinctive, non-default values so the round-trip proves real data moved, not defaults.
|
||||
document.render_mode = RenderMode::Outline;
|
||||
document.rulers_visible = false;
|
||||
document.collapsed = CollapsedLayers(vec![vec![NodeId(7)], vec![NodeId(7), NodeId(42)]]);
|
||||
|
||||
let view_settings = DocumentSettings {
|
||||
document_ptz: &document.document_ptz,
|
||||
render_mode: &document.render_mode,
|
||||
overlays_visibility: &document.overlays_visibility_settings,
|
||||
rulers_visible: document.rulers_visible,
|
||||
snapping_state: &document.snapping_state,
|
||||
collapsed: &document.collapsed,
|
||||
}
|
||||
.to_view_map();
|
||||
|
||||
// Apply the serialized view settings onto a fresh handler and compare each field's serialized
|
||||
// form (avoids requiring `PartialEq` on every setting type).
|
||||
let mut restored = DocumentMessageHandler::default();
|
||||
restored.apply_stored_document_settings(&view_settings);
|
||||
|
||||
assert_eq!(serde_json::to_value(restored.render_mode).unwrap(), serde_json::to_value(document.render_mode).unwrap(), "render_mode");
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.rulers_visible).unwrap(),
|
||||
serde_json::to_value(document.rulers_visible).unwrap(),
|
||||
"rulers_visible"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.document_ptz).unwrap(),
|
||||
serde_json::to_value(document.document_ptz).unwrap(),
|
||||
"document_ptz"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.overlays_visibility_settings).unwrap(),
|
||||
serde_json::to_value(document.overlays_visibility_settings).unwrap(),
|
||||
"overlays"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.snapping_state).unwrap(),
|
||||
serde_json::to_value(document.snapping_state).unwrap(),
|
||||
"snapping_state"
|
||||
);
|
||||
assert_eq!(serde_json::to_value(restored.collapsed).unwrap(), serde_json::to_value(document.collapsed).unwrap(), "collapsed");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Integration tests for the `.gdd` document-storage layer, split by altitude:
|
||||
//! - [`metadata_tests`] drives a demo document through the conversion bridge in-process (no save/reopen).
|
||||
//! - [`round_trip_tests`] drives real editor edits through a full `Gdd` save/reopen pipeline.
|
||||
//!
|
||||
//! Shared fixtures live in [`test_support`].
|
||||
|
||||
mod metadata_tests;
|
||||
mod round_trip_tests;
|
||||
mod test_support;
|
||||
@@ -0,0 +1,710 @@
|
||||
//! End-to-end storage round-trip tests: drive real edits through the editor, push the document
|
||||
//! through a fresh in-memory `Gdd` (stage → retire → persist), reopen from the same container, and
|
||||
//! assert the reopened document matches. Exercises the full persistence pipeline (conversion,
|
||||
//! MessagePack codecs, hot-op retirement, file layout, replay-on-open) that the debug-only
|
||||
//! `verify_storage_round_trip` only checks in-process without an actual save/reopen.
|
||||
|
||||
use document_container::AnyContainer;
|
||||
use document_container::backends::memory::MemoryBackend;
|
||||
use document_format::{GddV1, GddV1Layout};
|
||||
use graph_craft::application_io::resource::HashMapResourceStorage;
|
||||
use graph_storage::{NodeMetadataSource, PeerId};
|
||||
|
||||
use super::test_support::{RoundTrip, node_paths, round_trip_through_gdd};
|
||||
use crate::messages::portfolio::document::document_message_handler::DocumentMessageHandler;
|
||||
use crate::messages::portfolio::document::utility_types::misc::GroupFolderType;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{StorageMetadataView, build_interface_from_storage};
|
||||
use crate::test_utils::test_prelude::*;
|
||||
use graphene_std::vector::style::RenderMode;
|
||||
|
||||
/// Every node addressable in `original` resolves identically through the round-tripped interface:
|
||||
/// the compute graph itself, then per-node positions, layer flags, names, locked/pinned.
|
||||
fn assert_documents_match(original: &NodeNetworkInterface, round_trip: &RoundTrip) {
|
||||
let original_view = StorageMetadataView::new(original);
|
||||
let rebuilt_view = StorageMetadataView::new(&round_trip.rebuilt);
|
||||
|
||||
assert_eq!(
|
||||
round_trip.rebuilt.document_network(),
|
||||
original.document_network(),
|
||||
"compute graph drifted through the storage round-trip"
|
||||
);
|
||||
|
||||
for (network_path, local_id) in node_paths(original) {
|
||||
let at = format!("node {local_id:?} in network {network_path:?}");
|
||||
assert_eq!(rebuilt_view.position(&network_path, local_id), original_view.position(&network_path, local_id), "position: {at}");
|
||||
assert_eq!(rebuilt_view.is_layer(&network_path, local_id), original_view.is_layer(&network_path, local_id), "is_layer: {at}");
|
||||
assert_eq!(
|
||||
rebuilt_view.display_name(&network_path, local_id),
|
||||
original_view.display_name(&network_path, local_id),
|
||||
"display_name: {at}"
|
||||
);
|
||||
assert_eq!(rebuilt_view.locked(&network_path, local_id), original_view.locked(&network_path, local_id), "locked: {at}");
|
||||
assert_eq!(rebuilt_view.pinned(&network_path, local_id), original_view.pinned(&network_path, local_id), "pinned: {at}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_drawn_shapes() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
editor.draw_rect(100., 200., 300., 400.).await;
|
||||
editor.draw_ellipse(120., 220., 280., 380.).await;
|
||||
editor.draw_polygon(50., 60., 250., 360.).await;
|
||||
|
||||
let document = editor.active_document();
|
||||
let round_trip = round_trip_through_gdd(document).await;
|
||||
assert_documents_match(&document.network_interface, &round_trip);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_after_reorder() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
editor.draw_rect(50., 50., 150., 150.).await;
|
||||
editor.draw_rect(100., 100., 200., 200.).await;
|
||||
|
||||
// Reorder: raise the bottom layer so the layer stack differs from creation order.
|
||||
let bottom = editor.active_document().metadata().all_layers().next_back().expect("at least one layer");
|
||||
editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![bottom.to_node()] }).await;
|
||||
editor.handle_message(DocumentMessage::SelectedLayersRaise).await;
|
||||
|
||||
let document = editor.active_document();
|
||||
let round_trip = round_trip_through_gdd(document).await;
|
||||
assert_documents_match(&document.network_interface, &round_trip);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_grouped_into_nested_network() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
editor.draw_ellipse(50., 50., 150., 150.).await;
|
||||
|
||||
// Group both layers into a folder (a nested network), exercising flat-Registry addressing across
|
||||
// depth and `NetworkId` round-tripping.
|
||||
let layers: Vec<_> = editor.active_document().metadata().all_layers().map(|layer| layer.to_node()).collect();
|
||||
editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: layers }).await;
|
||||
editor
|
||||
.handle_message(DocumentMessage::GroupSelectedLayers {
|
||||
group_folder_type: GroupFolderType::Layer,
|
||||
})
|
||||
.await;
|
||||
|
||||
let document = editor.active_document();
|
||||
let round_trip = round_trip_through_gdd(document).await;
|
||||
assert_documents_match(&document.network_interface, &round_trip);
|
||||
}
|
||||
|
||||
/// Reproduces the first-commit-after-open failure: after reopening a `.gdd` and rebuilding the
|
||||
/// runtime, re-converting that runtime with `from_runtime` must reproduce the reopened registry.
|
||||
/// This is exactly what `verify_storage_round_trip` does on the first autosave after opening a `.gdd`.
|
||||
/// Uses a grouped document (nested network) because the bug is `from_runtime` assigning `NetworkId`s
|
||||
/// by traversal order rather than reproducing the stored ones, which cascades into node-path hashes.
|
||||
#[tokio::test]
|
||||
async fn recommit_after_open_is_stable() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
editor.draw_ellipse(50., 50., 150., 150.).await;
|
||||
|
||||
let layers: Vec<_> = editor.active_document().metadata().all_layers().map(|layer| layer.to_node()).collect();
|
||||
editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: layers }).await;
|
||||
editor
|
||||
.handle_message(DocumentMessage::GroupSelectedLayers {
|
||||
group_folder_type: GroupFolderType::Layer,
|
||||
})
|
||||
.await;
|
||||
|
||||
let document = editor.active_document();
|
||||
let round_trip = round_trip_through_gdd(document).await;
|
||||
|
||||
// Re-convert the rebuilt runtime and assert it reproduces the reopened registry. `round_trip_through_gdd`
|
||||
// builds the `Gdd` with `PeerId(1)`, which the reopened registry inherits, so the re-conversion must use
|
||||
// that same peer for deterministic node-ID derivation.
|
||||
let rebuilt_network = round_trip.rebuilt.document_network().clone();
|
||||
let view = StorageMetadataView::new(&round_trip.rebuilt);
|
||||
let reconverted = graph_storage::Registry::convert_from_runtime(&rebuilt_network, &view, &Default::default(), PeerId(1)).expect("re-convert from_runtime");
|
||||
|
||||
assert!(
|
||||
round_trip.registry.value_equal(&reconverted.registry),
|
||||
"re-converting the reopened runtime drifted from the reopened registry (network/node IDs not stable across to_runtime -> from_runtime)\n{}",
|
||||
crate::messages::portfolio::document::document_diff::diff_registries(&round_trip.registry, &reconverted.registry)
|
||||
);
|
||||
}
|
||||
|
||||
/// Reproduces the live "edit after opening a .gdd fails to commit" bug: build a grouped document,
|
||||
/// persist + reopen it as the editor does (registry -> runtime), then edit and commit through the
|
||||
/// reopened document. The commit must not fail with "Target node does not exist".
|
||||
#[tokio::test]
|
||||
async fn edit_after_open_commits_cleanly() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
editor.draw_ellipse(50., 50., 150., 150.).await;
|
||||
|
||||
let layers: Vec<_> = editor.active_document().metadata().all_layers().map(|layer| layer.to_node()).collect();
|
||||
editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: layers }).await;
|
||||
editor
|
||||
.handle_message(DocumentMessage::GroupSelectedLayers {
|
||||
group_folder_type: GroupFolderType::Layer,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Persist the document into a fresh Gdd and reopen it, then build a runtime document from the
|
||||
// reopened registry: the editor's .gdd-open path.
|
||||
let byte_store = HashMapResourceStorage::new();
|
||||
let source = editor.active_document();
|
||||
let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into())
|
||||
.await
|
||||
.expect("create_in");
|
||||
let source_network = source.network_interface.document_network().clone();
|
||||
let source_view = StorageMetadataView::new(&source.network_interface);
|
||||
gdd.commit_from_runtime(&source_network, &source_view, &source.resources.registry, &byte_store)
|
||||
.expect("commit_from_runtime");
|
||||
|
||||
let (working, layout) = gdd.into_storage();
|
||||
let reopened = GddV1::open_in(working, layout).await.expect("open_in");
|
||||
let declarations = reopened.declarations(&byte_store).await;
|
||||
let (rebuilt_network, node_entries, network_entries) = reopened.registry().to_runtime_with_full_metadata(&declarations).expect("to_runtime");
|
||||
let rebuilt = build_interface_from_storage(rebuilt_network, node_entries, network_entries).expect("build_interface_from_storage");
|
||||
|
||||
// Install the rebuilt interface + reopened storage and finalize like the editor's open path does.
|
||||
{
|
||||
let document = editor.active_document_mut();
|
||||
document.network_interface = rebuilt;
|
||||
document.set_storage(Some(reopened));
|
||||
document.finalize_storage_load();
|
||||
}
|
||||
|
||||
// Mirror the live sequence via the real `commit_storage_snapshot`: initial commit after open, then a
|
||||
// deletion, then commit again. Deletion (not addition) is the trigger, since it emits a `RemoveNode`
|
||||
// whose retire/reverse is where "Target node does not exist" surfaced live.
|
||||
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
|
||||
|
||||
let layer = editor.active_document().metadata().all_layers().next().expect("at least one layer");
|
||||
editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await;
|
||||
editor.handle_message(DocumentMessage::DeleteSelectedLayers).await;
|
||||
|
||||
// `commit_storage_snapshot` only logs commit failures, so assert on the underlying `commit_from_runtime`
|
||||
// result directly to make the deletion commit a hard test failure rather than a silent log line.
|
||||
let document = editor.active_document_mut();
|
||||
// Clone the interface for the metadata view so it doesn't borrow `document` across the mutable
|
||||
// `storage_mut()` borrow below.
|
||||
let interface = document.network_interface.clone();
|
||||
let network = interface.document_network().clone();
|
||||
let view = StorageMetadataView::new(&interface);
|
||||
document
|
||||
.storage_mut()
|
||||
.expect("storage mounted")
|
||||
.commit_from_runtime(&network, &view, &Default::default(), &byte_store)
|
||||
.expect("commit after deleting a layer post-open must not fail");
|
||||
}
|
||||
|
||||
/// Drives the per-interaction undo/redo cursor: commit two interactions into a `Gdd`, then undo back to the
|
||||
/// one-interaction state and redo forward, asserting the registry's node set matches at each cursor
|
||||
/// position. Exercises `Gdd::commit_from_runtime` (inline interaction-end mark) + `Session::undo/redo`.
|
||||
#[tokio::test]
|
||||
async fn gdd_undo_redo_walks_interactions() {
|
||||
let byte_store = HashMapResourceStorage::new();
|
||||
let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into())
|
||||
.await
|
||||
.expect("create_in");
|
||||
|
||||
// Commit the active document's current runtime state as one interaction.
|
||||
async fn commit_interaction(gdd: &mut GddV1, document: &DocumentMessageHandler, byte_store: &HashMapResourceStorage) {
|
||||
let network = document.network_interface.document_network().clone();
|
||||
let view = StorageMetadataView::new(&document.network_interface);
|
||||
gdd.commit_from_runtime(&network, &view, &document.resources.registry, byte_store).expect("commit_from_runtime");
|
||||
}
|
||||
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
commit_interaction(&mut gdd, editor.active_document(), &byte_store).await;
|
||||
let nodes_after_one: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect();
|
||||
|
||||
editor.draw_ellipse(50., 50., 150., 150.).await;
|
||||
commit_interaction(&mut gdd, editor.active_document(), &byte_store).await;
|
||||
let nodes_after_two: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect();
|
||||
|
||||
assert!(nodes_after_two.len() > nodes_after_one.len(), "second interaction should add nodes");
|
||||
assert!(gdd.can_undo(), "two committed interactions should be undoable");
|
||||
assert!(!gdd.can_redo());
|
||||
|
||||
// Undo the second interaction: registry returns to the one-interaction node set.
|
||||
gdd.undo().expect("undo");
|
||||
let undone: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect();
|
||||
assert_eq!(undone, nodes_after_one, "undo should restore the post-first-interaction node set");
|
||||
assert!(gdd.can_redo(), "after undo, redo must be available");
|
||||
|
||||
// Redo: back to the two-interaction state.
|
||||
gdd.redo().expect("redo");
|
||||
let redone: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect();
|
||||
assert_eq!(redone, nodes_after_two, "redo should restore the post-second-interaction node set");
|
||||
|
||||
// A new edit after undo clears the redo stack.
|
||||
gdd.undo().expect("undo");
|
||||
editor.draw_rect(200., 200., 300., 300.).await;
|
||||
commit_interaction(&mut gdd, editor.active_document(), &byte_store).await;
|
||||
assert!(!gdd.can_redo(), "a new edit after undo must abandon the redo branch");
|
||||
}
|
||||
|
||||
/// Reopen must restore the cursor *and* a working registry consistent with it. `Session::load` trusts
|
||||
/// the persisted `registry.bin` to match the persisted `head`, so an undo that rewinds the working
|
||||
/// registry has to re-snapshot it. Repro of the live "undo acts like redo after reload" bug: commit
|
||||
/// three interactions, undo one (registry at two), reopen, and assert the reopened registry is the
|
||||
/// two-interaction state (not the three-interaction state the last retirement wrote) and that undo from there
|
||||
/// still walks back to the one-interaction node set.
|
||||
#[tokio::test]
|
||||
async fn reopen_after_undo_restores_consistent_registry() {
|
||||
let byte_store = HashMapResourceStorage::new();
|
||||
let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into())
|
||||
.await
|
||||
.expect("create_in");
|
||||
|
||||
async fn commit_interaction(gdd: &mut GddV1, document: &DocumentMessageHandler, byte_store: &HashMapResourceStorage) {
|
||||
let network = document.network_interface.document_network().clone();
|
||||
let view = StorageMetadataView::new(&document.network_interface);
|
||||
gdd.commit_from_runtime(&network, &view, &document.resources.registry, byte_store).expect("commit_from_runtime");
|
||||
}
|
||||
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
commit_interaction(&mut gdd, editor.active_document(), &byte_store).await;
|
||||
let nodes_one: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect();
|
||||
|
||||
editor.draw_ellipse(50., 50., 150., 150.).await;
|
||||
commit_interaction(&mut gdd, editor.active_document(), &byte_store).await;
|
||||
let nodes_two: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect();
|
||||
|
||||
editor.draw_rect(200., 200., 300., 300.).await;
|
||||
commit_interaction(&mut gdd, editor.active_document(), &byte_store).await;
|
||||
let nodes_three: std::collections::BTreeSet<_> = gdd.registry().node_instances.keys().copied().collect();
|
||||
|
||||
// Undo the third interaction: the in-memory registry is back at the two-interaction state, but the last
|
||||
// retirement wrote the three-interaction registry to disk. The undo must re-snapshot it.
|
||||
gdd.undo().expect("undo");
|
||||
assert_eq!(gdd.registry().node_instances.keys().copied().collect::<std::collections::BTreeSet<_>>(), nodes_two);
|
||||
|
||||
// Reopen from the same container: the cursor and the working registry are read back from disk.
|
||||
let (working, layout) = gdd.into_storage();
|
||||
let mut reopened = GddV1::open_in(working, layout).await.expect("open_in");
|
||||
|
||||
assert_eq!(
|
||||
reopened.registry().node_instances.keys().copied().collect::<std::collections::BTreeSet<_>>(),
|
||||
nodes_two,
|
||||
"reopened registry must match the undone (two-interaction) state, not the three-interaction state on disk"
|
||||
);
|
||||
assert!(reopened.can_undo(), "head is still above the first interaction, so undo is available after reopen");
|
||||
assert!(reopened.can_redo(), "the undone third interaction is still redoable after reopen");
|
||||
|
||||
// Redo after reopen: the persisted redo stack must still reach the three-interaction state (redo across
|
||||
// reopen must not be capped at the open point).
|
||||
reopened.redo().expect("redo after reopen");
|
||||
assert_eq!(
|
||||
reopened.registry().node_instances.keys().copied().collect::<std::collections::BTreeSet<_>>(),
|
||||
nodes_three,
|
||||
"redo after reopen must restore the third interaction, not stop at the reopened (two-interaction) state"
|
||||
);
|
||||
|
||||
// Undo twice from there: back through the third interaction to the one-interaction state, not a redo.
|
||||
reopened.undo().expect("first undo after reopen redo");
|
||||
reopened.undo().expect("second undo after reopen redo");
|
||||
assert_eq!(
|
||||
reopened.registry().node_instances.keys().copied().collect::<std::collections::BTreeSet<_>>(),
|
||||
nodes_one,
|
||||
"undo after reopen must rewind toward the one-interaction state, not act like a redo"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drives the *live* shadow path: a real edit through the message pipeline (which fires
|
||||
/// `CommitTransaction`, committing one interaction into the `Gdd`), then a real `DocumentMessage::Undo`
|
||||
/// (which runs `shadow_storage_undo_redo` + the debug cursor-vs-runtime check). A clean run asserts
|
||||
/// the `Gdd` undo cursor reproduces the legacy-restored interface.
|
||||
#[tokio::test]
|
||||
async fn live_undo_shadows_storage_cursor() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
|
||||
let byte_store = mount_in_memory_storage(&mut editor).await;
|
||||
// Capture the loaded state as the base interaction, then make a real edit (fires CommitTransaction).
|
||||
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
|
||||
let before_edit = editor.active_document().network_interface.document_network().clone();
|
||||
editor.draw_ellipse(50., 50., 150., 150.).await;
|
||||
let after_edit = editor.active_document().network_interface.document_network().clone();
|
||||
assert_ne!(&before_edit, &after_edit, "edit should change the network");
|
||||
|
||||
// Real undo: the shadow drives gdd.undo() and (debug) asserts the cursor matches the restored interface.
|
||||
editor.handle_message(DocumentMessage::Undo).await;
|
||||
assert_eq!(editor.active_document().network_interface.document_network(), &before_edit, "undo should restore the pre-edit network");
|
||||
|
||||
// Real redo: the shadow drives gdd.redo() and must reproduce the post-edit network.
|
||||
editor.handle_message(DocumentMessage::Redo).await;
|
||||
assert_eq!(editor.active_document().network_interface.document_network(), &after_edit, "redo should restore the post-edit network");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_trip_document_settings() {
|
||||
use graph_storage::attr::session::doc;
|
||||
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
|
||||
// Set distinctive, non-default document-level settings so the round-trip proves real values move.
|
||||
{
|
||||
let document = editor.active_document_mut();
|
||||
document.render_mode = RenderMode::Outline;
|
||||
document.rulers_visible = false;
|
||||
document.snapping_state.snapping_enabled = !document.snapping_state.snapping_enabled;
|
||||
}
|
||||
|
||||
let document = editor.active_document();
|
||||
let round_trip = round_trip_through_gdd(document).await;
|
||||
assert_documents_match(&document.network_interface, &round_trip);
|
||||
|
||||
// The `ui::doc::*` view settings survived the persist/reopen cycle (in session.json, not the registry).
|
||||
let settings = &round_trip.view_settings;
|
||||
assert_eq!(settings.get(doc::RENDER_MODE), Some(&serde_json::to_value(RenderMode::Outline).unwrap()), "render_mode");
|
||||
assert_eq!(settings.get(doc::RULERS_VISIBLE), Some(&serde_json::to_value(false).unwrap()), "rulers_visible");
|
||||
assert_eq!(settings.get(doc::SNAPPING), Some(&serde_json::to_value(&document.snapping_state).unwrap()), "snapping_state");
|
||||
}
|
||||
|
||||
/// The exported `.gdd` archive must carry `session.json` so view settings (notably the document PTZ)
|
||||
/// survive a save/open on another machine. `session.json` is working-copy-only otherwise, so without it
|
||||
/// the archive opens with empty `view_settings` and the viewport resets to default. Exports to an archive
|
||||
/// and reopens via `open_from_archive`, asserting the PTZ round-trips.
|
||||
#[tokio::test]
|
||||
async fn gdd_archive_round_trips_view_settings() {
|
||||
use graph_storage::attr::session::doc;
|
||||
|
||||
let byte_store = HashMapResourceStorage::new();
|
||||
let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into())
|
||||
.await
|
||||
.expect("create_in");
|
||||
|
||||
// Stage a distinctive PTZ into the working copy's `view_settings`, as `commit_storage_snapshot` does.
|
||||
let mut ptz = crate::messages::portfolio::document::utility_types::misc::PTZ::default();
|
||||
ptz.pan = glam::DVec2::new(-960., -540.);
|
||||
ptz.set_zoom(0.459);
|
||||
let view_settings = std::collections::BTreeMap::from([(doc::PTZ.to_string(), serde_json::to_value(ptz).unwrap())]);
|
||||
gdd.set_view_settings(view_settings).expect("set_view_settings");
|
||||
|
||||
// Export to an in-memory archive, then reopen it from bytes into a fresh container.
|
||||
let archive = gdd
|
||||
.export_to_bytes(document_format::ExportFormat::Zip, document_format::ExportOptions::default(), &byte_store, None)
|
||||
.await
|
||||
.expect("export_to_bytes");
|
||||
|
||||
let reopened = GddV1::open_from_archive(&archive, AnyContainer::Memory(MemoryBackend::new()), GddV1Layout)
|
||||
.await
|
||||
.expect("open_from_archive");
|
||||
|
||||
assert_eq!(
|
||||
reopened.view_settings().get(doc::PTZ),
|
||||
Some(&serde_json::to_value(ptz).unwrap()),
|
||||
"the document PTZ must survive a .gdd archive export/open (session.json travels in the archive)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-network node-graph navigation is per-peer view state: it must round-trip through `session.json`
|
||||
/// (keyed by stable `NetworkId`), not the registry/history. Sets a distinctive node-graph PTZ on the root
|
||||
/// network, persists + reopens through a `Gdd`, and asserts the nav is restored on the rebuilt interface
|
||||
/// while being absent from the stored registry's attributes.
|
||||
#[tokio::test]
|
||||
async fn per_network_navigation_round_trips_via_session_not_registry() {
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{apply_network_view_settings, collect_network_view_settings, network_ids_from_entries};
|
||||
use graph_storage::attr::session::network;
|
||||
|
||||
let byte_store = HashMapResourceStorage::new();
|
||||
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
|
||||
// Set a distinctive node-graph PTZ on the root network (path `[]`).
|
||||
let ptz = editor.active_document_mut().network_interface.node_graph_ptz_mut(&[]).expect("root network ptz");
|
||||
ptz.pan = glam::DVec2::new(-321., 654.);
|
||||
ptz.set_zoom(0.75);
|
||||
let expected_pan = editor.active_document().network_interface.node_graph_ptz(&[]).unwrap().pan;
|
||||
|
||||
// Commit the document into a fresh `Gdd`, collecting the per-network view state the editor persists.
|
||||
let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into())
|
||||
.await
|
||||
.expect("create_in");
|
||||
let document = editor.active_document();
|
||||
let network = document.network_interface.document_network().clone();
|
||||
let view = StorageMetadataView::new(&document.network_interface);
|
||||
gdd.commit_from_runtime(&network, &view, &document.resources.registry, &byte_store).expect("commit_from_runtime");
|
||||
|
||||
let network_ids = gdd.network_ids(&network, &view).expect("network_ids");
|
||||
let network_view_settings = collect_network_view_settings(&document.network_interface, &network_ids);
|
||||
assert!(!network_view_settings.is_empty(), "the root network's non-default nav should be collected");
|
||||
gdd.set_network_view_settings(network_view_settings).expect("set_network_view_settings");
|
||||
|
||||
// The registry must NOT carry the node-graph nav (it's per-peer, not document content).
|
||||
let root_network = gdd.registry().networks.get(&graph_storage::ROOT_NETWORK).expect("root network in registry");
|
||||
assert!(!root_network.attributes.contains_key(network::NAV_PTZ), "node-graph nav must not be stored in the registry attributes");
|
||||
|
||||
// Reopen, rebuild the interface, and apply the persisted per-network view state.
|
||||
let (working, layout) = gdd.into_storage();
|
||||
let reopened = GddV1::open_in(working, layout).await.expect("open_in");
|
||||
let declarations = reopened.declarations(&byte_store).await;
|
||||
let (rebuilt_network, node_entries, network_entries) = reopened.registry().to_runtime_with_full_metadata(&declarations).expect("to_runtime");
|
||||
let mut rebuilt = build_interface_from_storage(rebuilt_network, node_entries, network_entries.clone()).expect("build_interface_from_storage");
|
||||
|
||||
let reopened_ids = network_ids_from_entries(&network_entries);
|
||||
apply_network_view_settings(&mut rebuilt, &reopened_ids, reopened.network_view_settings());
|
||||
|
||||
assert_eq!(
|
||||
rebuilt.node_graph_ptz(&[]).map(|ptz| ptz.pan),
|
||||
Some(expected_pan),
|
||||
"the node-graph PTZ must be restored from session.json on reopen"
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirror the real "new document, draw a rect, press undo" flow: mount storage and capture the
|
||||
/// new-document base as the mount-time snapshot (as `DocumentStorageMounted` does), then draw a rect
|
||||
/// (one `CommitTransaction` interaction) and undo. The shadow must reproduce the legacy-restored interface.
|
||||
#[tokio::test]
|
||||
async fn live_undo_new_document_draw_rect() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let byte_store = mount_in_memory_storage(&mut editor).await;
|
||||
// Mount-time snapshot: capture the new-document graph as the base interaction.
|
||||
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
|
||||
|
||||
let before_rect = editor.active_document().network_interface.document_network().clone();
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
assert_ne!(&before_rect, editor.active_document().network_interface.document_network(), "drawing a rect should change the network");
|
||||
|
||||
editor.handle_message(DocumentMessage::Undo).await;
|
||||
assert_eq!(editor.active_document().network_interface.document_network(), &before_rect, "undo should restore the pre-rect network");
|
||||
}
|
||||
|
||||
/// Pasting an image is one user action and must be one undo step: the paste handler brackets the layer add,
|
||||
/// name set, reparent, and transform in a single transaction. Were the name set to open its own nested
|
||||
/// transaction (a historical wart), the first undo would revert only the name and leave the layer behind, so
|
||||
/// this asserts the layer count returns to its pre-paste value after exactly one undo.
|
||||
#[tokio::test]
|
||||
async fn paste_image_with_name_is_one_undo_step() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let layers_before = editor.active_document().metadata().all_layers().count();
|
||||
|
||||
// Paste with a name so the handler emits the `SetDisplayName` sub-step that historically opened its
|
||||
// own transaction. `create_raster_image` passes `name: None` and so wouldn't exercise this path.
|
||||
let image = Image::new(2, 2, Color::WHITE);
|
||||
editor
|
||||
.handle_message(PortfolioMessage::InsertImage {
|
||||
name: Some("pasted".into()),
|
||||
image,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
editor.active_document().metadata().all_layers().count(),
|
||||
layers_before + 1,
|
||||
"pasting an image should add exactly one layer"
|
||||
);
|
||||
|
||||
editor.handle_message(DocumentMessage::Undo).await;
|
||||
assert_eq!(
|
||||
editor.active_document().metadata().all_layers().count(),
|
||||
layers_before,
|
||||
"one undo should remove the whole pasted layer, not just its name"
|
||||
);
|
||||
}
|
||||
|
||||
/// Undoing an image paste reverts the interaction's `AddResource` in the `Gdd` cursor while the runtime keeps
|
||||
/// the resource alive for legacy redo, so the cursor legitimately holds fewer resources than a fresh
|
||||
/// `from_runtime`. Drives the relaxed comparison: every resource the current network references must be in
|
||||
/// the cursor, and any extra the runtime carries must be history-only.
|
||||
#[tokio::test]
|
||||
async fn undo_image_paste_resources_subset_of_runtime() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let byte_store = mount_in_memory_storage(&mut editor).await;
|
||||
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
|
||||
|
||||
let image = Image::new(2, 2, Color::WHITE);
|
||||
editor
|
||||
.handle_message(PortfolioMessage::InsertImage {
|
||||
name: Some("pasted".into()),
|
||||
image,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
editor.handle_message(DocumentMessage::Undo).await;
|
||||
|
||||
let document = editor.active_document();
|
||||
let storage = document.storage().expect("storage mounted");
|
||||
let stored: std::collections::BTreeSet<_> = storage.registry().resources.keys().copied().collect();
|
||||
|
||||
// Every resource the restored network still references must be in the cursor.
|
||||
let current: std::collections::BTreeSet<_> = document.used_resources(false).iter().copied().collect();
|
||||
assert!(
|
||||
current.is_subset(&stored),
|
||||
"cursor is missing a resource the restored network references: current={current:?} stored={stored:?}"
|
||||
);
|
||||
|
||||
// The runtime, which retains the undone paste's resource for redo, may carry strictly more.
|
||||
let runtime: std::collections::BTreeSet<_> = document.resources.registry.ids().collect();
|
||||
assert!(stored.is_subset(&runtime), "cursor holds a resource the runtime dropped: stored={stored:?} runtime={runtime:?}");
|
||||
}
|
||||
|
||||
/// Two edits (draw, then paste) on an opened document are two undo steps; undoing twice must step the `Gdd`
|
||||
/// cursor back two interactions in lockstep with the legacy path. The loaded base is not itself an undo
|
||||
/// step, so the mount-time base must not become an undoable `Gdd` interaction, or the cursor lags the legacy
|
||||
/// path by one once undo reaches the base. Reproduces the live "open, draw, paste image, undo twice" divergence.
|
||||
#[tokio::test]
|
||||
async fn undo_twice_steps_cursor_two_interactions() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let byte_store = mount_in_memory_storage(&mut editor).await;
|
||||
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
|
||||
let base = editor.active_document().network_interface.document_network().clone();
|
||||
|
||||
editor.draw_rect(0., 0., 100., 100.).await;
|
||||
let after_rect = editor.active_document().network_interface.document_network().clone();
|
||||
|
||||
let image = Image::new(2, 2, Color::WHITE);
|
||||
editor
|
||||
.handle_message(PortfolioMessage::InsertImage {
|
||||
name: Some("pasted".into()),
|
||||
image,
|
||||
mouse: None,
|
||||
parent_and_insert_index: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// First undo: removes the paste, back to the post-rectangle network. Cursor and legacy must agree.
|
||||
editor.handle_message(DocumentMessage::Undo).await;
|
||||
assert_eq!(
|
||||
editor.active_document().network_interface.document_network(),
|
||||
&after_rect,
|
||||
"first undo should restore the post-rectangle network (paste removed)"
|
||||
);
|
||||
assert_cursor_matches_runtime(editor.active_document(), "after first undo");
|
||||
|
||||
// Re-stage after the undo (the live editor re-evaluates the graph). The pasted image's resource is still
|
||||
// cached for redo though its node is gone, so a naive snapshot would re-detect it as a new `AddResource`
|
||||
// and retire a phantom interaction, knocking the cursor out of lockstep. Scoping the snapshot to
|
||||
// network-referenced resources must keep this a no-op.
|
||||
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
|
||||
|
||||
// Second undo: removes the rectangle, back to the loaded base. The cursor must step a second interaction
|
||||
// and land on the base, not one short.
|
||||
editor.handle_message(DocumentMessage::Undo).await;
|
||||
assert_eq!(
|
||||
editor.active_document().network_interface.document_network(),
|
||||
&base,
|
||||
"second undo should restore the loaded base network"
|
||||
);
|
||||
assert_cursor_matches_runtime(editor.active_document(), "after second undo");
|
||||
|
||||
// Back at the loaded base, neither path can undo further: the load is not an undo step. Had the `Gdd`
|
||||
// retired the mount base as its own interaction, the cursor could still undo here, the off-by-one.
|
||||
let storage = editor.active_document().storage().expect("storage mounted");
|
||||
assert!(!storage.can_undo(), "cursor must not undo past the loaded base (the load is not an undo step)");
|
||||
}
|
||||
|
||||
/// Assert the `Gdd` cursor's stored registry matches a fresh `from_runtime` of the current interface (node
|
||||
/// set, network set, per-network export wiring), mirroring the live `verify_cursor_matches_runtime` check.
|
||||
/// The runtime's resource set may be a superset (it keeps undone resources alive for redo), but every
|
||||
/// resource the current network references must be present.
|
||||
fn assert_cursor_matches_runtime(document: &DocumentMessageHandler, at: &str) {
|
||||
let storage = document.storage().expect("storage mounted");
|
||||
let peer = storage.session().peer();
|
||||
|
||||
let network = document.network_interface.document_network().clone();
|
||||
let view = StorageMetadataView::new(&document.network_interface);
|
||||
let target = graph_storage::Registry::convert_from_runtime(&network, &view, &document.resources.registry, peer).expect("from_runtime");
|
||||
|
||||
let stored = storage.registry();
|
||||
|
||||
let stored_nodes: std::collections::BTreeSet<_> = stored.node_instances.keys().copied().collect();
|
||||
let target_nodes: std::collections::BTreeSet<_> = target.registry.node_instances.keys().copied().collect();
|
||||
assert_eq!(stored_nodes, target_nodes, "cursor node set diverged from the restored interface {at}");
|
||||
|
||||
let stored_networks: std::collections::BTreeSet<_> = stored.networks.keys().copied().collect();
|
||||
let target_networks: std::collections::BTreeSet<_> = target.registry.networks.keys().copied().collect();
|
||||
assert_eq!(stored_networks, target_networks, "cursor network set diverged from the restored interface {at}");
|
||||
|
||||
// Compare export wiring per network: the paste rewires the document export, and undo must revert it.
|
||||
for (id, stored_network) in &stored.networks {
|
||||
let target_network = target.registry.networks.get(id).expect("network present in both (checked above)");
|
||||
let stored_targets: Vec<_> = stored_network.exports.iter().map(|export| export.target.clone()).collect();
|
||||
let target_targets: Vec<_> = target_network.exports.iter().map(|export| export.target.clone()).collect();
|
||||
assert_eq!(stored_targets, target_targets, "cursor export wiring diverged for network {id} {at}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Mount a fresh in-memory `Gdd` onto the active document so `commit_storage_snapshot` (the real
|
||||
/// autosave path) runs against it. Returns the byte store the document's resources resolve through.
|
||||
async fn mount_in_memory_storage(editor: &mut EditorTestUtils) -> HashMapResourceStorage {
|
||||
let gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0x5EED, "test".into(), "test".into())
|
||||
.await
|
||||
.expect("create_in");
|
||||
editor.active_document_mut().set_storage(Some(gdd));
|
||||
HashMapResourceStorage::new()
|
||||
}
|
||||
|
||||
/// Open a real demo artwork, mount storage, edit it, and trigger autosave. The autosave runs
|
||||
/// `verify_storage_round_trip`, which panics in tests on any conversion or round-trip drift, so a clean run
|
||||
/// is itself the assertion that the whole pipeline holds on real document data. Also checks that committing
|
||||
/// edits grows the retired-delta history and that undo reverts the modification.
|
||||
#[tokio::test]
|
||||
async fn demo_artwork_edit_autosaves_and_round_trips() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
|
||||
// Open a real demo artwork through the normal open path and let it render.
|
||||
let content = std::fs::read_to_string("../demo-artwork/changing-seasons.graphite").expect("read demo artwork");
|
||||
editor
|
||||
.handle_message(PortfolioMessage::OpenFile {
|
||||
path: "changing-seasons.graphite".into(),
|
||||
content: content.bytes().collect(),
|
||||
})
|
||||
.await;
|
||||
|
||||
let byte_store = mount_in_memory_storage(&mut editor).await;
|
||||
|
||||
// First autosave: captures the loaded document. `verify_storage_round_trip` panics on drift.
|
||||
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
|
||||
let history_after_open = editor.active_document().storage().unwrap().session().history().count();
|
||||
|
||||
// Snapshot the network, then make a real modification.
|
||||
let before_edit = editor.active_document().network_interface.document_network().clone();
|
||||
editor.draw_rect(64., 64., 192., 192.).await;
|
||||
let after_edit = editor.active_document().network_interface.document_network().clone();
|
||||
assert_ne!(before_edit, after_edit, "drawing a rectangle should change the document network");
|
||||
|
||||
// Second autosave: again verifies the round-trip, and the edit must produce new retired history.
|
||||
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
|
||||
let history_after_edit = editor.active_document().storage().unwrap().session().history().count();
|
||||
assert!(
|
||||
history_after_edit > history_after_open,
|
||||
"committing an edit should append retired deltas: {history_after_open} -> {history_after_edit}"
|
||||
);
|
||||
|
||||
// The editor's undo stack reverts the modification on a real document.
|
||||
editor.handle_message(DocumentMessage::Undo).await;
|
||||
let after_undo = editor.active_document().network_interface.document_network().clone();
|
||||
assert_eq!(after_undo, before_edit, "undo should restore the pre-edit network");
|
||||
|
||||
// Autosaving the undone state still round-trips cleanly (no drift panic).
|
||||
editor.active_document_mut().commit_storage_snapshot(&byte_store, true);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Shared helpers for the storage integration tests ([`metadata_tests`](super::metadata_tests) and
|
||||
//! [`round_trip_tests`](super::round_trip_tests)): loading demo artwork, walking every addressable
|
||||
//! node, and pushing a document through a real `Gdd` save/reopen.
|
||||
|
||||
use document_container::AnyContainer;
|
||||
use document_container::backends::memory::MemoryBackend;
|
||||
use document_format::{GddV1, GddV1Layout};
|
||||
use graph_craft::application_io::resource::HashMapResourceStorage;
|
||||
use graph_craft::document::{DocumentNodeImplementation, NodeId};
|
||||
use graph_storage::PeerId;
|
||||
|
||||
use crate::messages::portfolio::document::document_message_handler::DocumentMessageHandler;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::storage_metadata::{DocumentSettings, StorageMetadataView, build_interface_from_storage};
|
||||
|
||||
/// Load a demo `.graphite` straight into a `DocumentMessageHandler` for inspection.
|
||||
pub fn load_demo(file_name: &str) -> DocumentMessageHandler {
|
||||
let path = format!("../demo-artwork/{file_name}");
|
||||
let content = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("Failed to read {path}: {e}"));
|
||||
DocumentMessageHandler::deserialize_document(&content).unwrap_or_else(|e| panic!("Failed to deserialize {path}: {e:?}"))
|
||||
}
|
||||
|
||||
/// Walk every node in every nested network and collect `(network_path, local_id)` pairs, so a test can
|
||||
/// iterate every node addressable from the metadata side.
|
||||
pub fn node_paths(interface: &NodeNetworkInterface) -> Vec<(Vec<NodeId>, NodeId)> {
|
||||
fn walk(interface: &NodeNetworkInterface, path: Vec<NodeId>, out: &mut Vec<(Vec<NodeId>, NodeId)>) {
|
||||
let Some(network) = interface.nested_network(&path) else { return };
|
||||
for (&local_id, node) in &network.nodes {
|
||||
out.push((path.clone(), local_id));
|
||||
|
||||
if matches!(node.implementation, DocumentNodeImplementation::Network(_)) {
|
||||
let mut child = path.clone();
|
||||
child.push(local_id);
|
||||
walk(interface, child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
walk(interface, Vec::new(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Result of round-tripping a document through a `Gdd`: the runtime interface rebuilt from the reopened
|
||||
/// storage, the reopened registry, and the reopened per-peer view settings (`ui::doc::*`).
|
||||
pub struct RoundTrip {
|
||||
pub rebuilt: NodeNetworkInterface,
|
||||
pub registry: graph_storage::Registry,
|
||||
pub view_settings: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Push `document`'s current runtime state through a fresh in-memory `Gdd` and reopen it. The commit goes
|
||||
/// through `commit_from_runtime` (the real autosave path: stage hot ops -> retire -> persist); the reopen
|
||||
/// reads the working copy back from the same container bytes, exercising the whole codec / file / replay
|
||||
/// pipeline rather than just the in-memory conversion.
|
||||
pub async fn round_trip_through_gdd(document: &DocumentMessageHandler) -> RoundTrip {
|
||||
let byte_store = HashMapResourceStorage::new();
|
||||
|
||||
let mut gdd = GddV1::create_in(AnyContainer::Memory(MemoryBackend::new()), GddV1Layout, PeerId(1), 0xABCD, "test".into(), "test".into())
|
||||
.await
|
||||
.expect("create_in");
|
||||
|
||||
let network = document.network_interface.document_network().clone();
|
||||
let view = StorageMetadataView::new(&document.network_interface);
|
||||
gdd.commit_from_runtime(&network, &view, &document.resources.registry, &byte_store).expect("commit_from_runtime");
|
||||
|
||||
// Per-peer view settings persist in session.json, separate from the registry commit.
|
||||
let view_settings = DocumentSettings {
|
||||
document_ptz: &document.document_ptz,
|
||||
render_mode: &document.render_mode,
|
||||
overlays_visibility: &document.overlays_visibility_settings,
|
||||
rulers_visible: document.rulers_visible,
|
||||
snapping_state: &document.snapping_state,
|
||||
collapsed: &document.collapsed,
|
||||
}
|
||||
.to_view_map();
|
||||
gdd.set_view_settings(view_settings).expect("set_view_settings");
|
||||
|
||||
let (working, layout) = gdd.into_storage();
|
||||
let reopened = GddV1::open_in(working, layout).await.expect("open_in");
|
||||
let declarations = reopened.declarations(&byte_store).await;
|
||||
let registry = reopened.registry().clone();
|
||||
let (rebuilt_network, node_entries, network_entries) = registry.to_runtime_with_full_metadata(&declarations).expect("to_runtime_with_full_metadata");
|
||||
let rebuilt = build_interface_from_storage(rebuilt_network, node_entries, network_entries).expect("build_interface_from_storage");
|
||||
let view_settings = reopened.view_settings().clone();
|
||||
|
||||
RoundTrip { rebuilt, registry, view_settings }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod deserialization;
|
||||
mod memo_network;
|
||||
mod resolved_types;
|
||||
pub mod storage_metadata;
|
||||
|
||||
use super::document_metadata::{DocumentMetadata, LayerNodeIdentifier, NodeRelations};
|
||||
use super::misc::PTZ;
|
||||
@@ -3452,22 +3453,67 @@ impl NodeNetworkInterface {
|
||||
|
||||
// Public mutable methods
|
||||
impl NodeNetworkInterface {
|
||||
pub fn copy_all_navigation_metadata(&mut self, other_interface: &NodeNetworkInterface) {
|
||||
/// Walk every network at every nesting level, invoking `visit` with each network's path and a mutable
|
||||
/// reference to its metadata. Only nodes that contain a nested network are recursed into.
|
||||
fn for_each_network_metadata_mut(&mut self, mut visit: impl FnMut(&[NodeId], &mut NodeNetworkMetadata)) {
|
||||
let mut stack = vec![vec![]];
|
||||
while let Some(path) = stack.pop() {
|
||||
let Some(self_network_metadata) = self.network_metadata_mut(&path) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(other_network_metadata) = other_interface.network_metadata(&path) {
|
||||
|
||||
visit(&path, self_network_metadata);
|
||||
|
||||
// Only nodes that contain a nested network have metadata to recurse into, so skip leaf nodes.
|
||||
stack.extend(
|
||||
self_network_metadata
|
||||
.persistent_metadata
|
||||
.node_metadata
|
||||
.iter()
|
||||
.filter(|(_, node_metadata)| node_metadata.persistent_metadata.network_metadata.is_some())
|
||||
.map(|(node_id, _)| {
|
||||
let mut current_path: Vec<NodeId> = path.clone();
|
||||
current_path.push(*node_id);
|
||||
current_path
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_all_navigation_metadata(&mut self, other_interface: &NodeNetworkInterface) {
|
||||
self.for_each_network_metadata_mut(|path, self_network_metadata| {
|
||||
if let Some(other_network_metadata) = other_interface.network_metadata(path) {
|
||||
self_network_metadata.persistent_metadata.navigation_metadata = other_network_metadata.persistent_metadata.navigation_metadata.clone();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stack.extend(self_network_metadata.persistent_metadata.node_metadata.keys().map(|node_id| {
|
||||
let mut current_path: Vec<NodeId> = path.clone();
|
||||
current_path.push(*node_id);
|
||||
current_path
|
||||
}));
|
||||
}
|
||||
/// Copy all transient view and selection state from `other_interface` onto `self` at every nesting level:
|
||||
/// navigation metadata (pan/zoom), selection undo/redo history, and the document-to-viewport camera. Lets
|
||||
/// an interface rebuilt from storage keep the user's current view rather than reset it. `resolved_types`
|
||||
/// is a separate runtime cache the caller handles.
|
||||
pub fn copy_all_transient_view_state(&mut self, other_interface: &NodeNetworkInterface) {
|
||||
self.for_each_network_metadata_mut(|path, self_network_metadata| {
|
||||
if let Some(other_network_metadata) = other_interface.network_metadata(path) {
|
||||
let self_persistent = &mut self_network_metadata.persistent_metadata;
|
||||
let other_persistent = &other_network_metadata.persistent_metadata;
|
||||
self_persistent.navigation_metadata = other_persistent.navigation_metadata.clone();
|
||||
|
||||
// The live preview root may name a node that the rebuilt-from-storage network no longer contains
|
||||
// (e.g. after a storage undo), so only carry it over when the root still exists; otherwise drop it.
|
||||
self_persistent.previewing = match other_persistent.previewing {
|
||||
Previewing::Yes {
|
||||
root_node_to_restore: Some(root_node),
|
||||
} if !self_persistent.node_metadata.contains_key(&root_node.node_id) => Previewing::No,
|
||||
previewing => previewing,
|
||||
};
|
||||
|
||||
self_persistent.selection_undo_history = other_persistent.selection_undo_history.clone();
|
||||
self_persistent.selection_redo_history = other_persistent.selection_redo_history.clone();
|
||||
}
|
||||
});
|
||||
|
||||
self.document_metadata.document_to_viewport = other_interface.document_metadata.document_to_viewport;
|
||||
}
|
||||
|
||||
pub fn set_transform(&mut self, transform: DAffine2, network_path: &[NodeId]) {
|
||||
@@ -6858,6 +6904,9 @@ impl NodePersistentMetadata {
|
||||
pub fn new(position: NodePosition) -> Self {
|
||||
Self { position }
|
||||
}
|
||||
pub fn position(&self) -> &NodePosition {
|
||||
&self.position
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer can either be position as Absolute or in a Stack
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Bridge between `NodeNetworkInterface` and `graph-storage`'s `NodeMetadataSource` trait.
|
||||
//! Conversion round-trip tests live in `storage_metadata_tests`.
|
||||
//!
|
||||
//! The trait impl lives on the [`StorageMetadataView`] wrapper (not on `NodeNetworkInterface`
|
||||
//! directly) because several trait method names collide with inherent methods, and Rust silently
|
||||
//! resolves bare calls to the inherent ones.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use glam::IVec2;
|
||||
use graph_craft::document::{DocumentNodeImplementation, NodeId, NodeNetwork};
|
||||
use graph_storage::attr::session;
|
||||
use graph_storage::{InputMetadataEntry, NetworkMetadataEntry, NodeMetadataEntry, NodeMetadataSource, Position};
|
||||
use graphene_std::vector::style::RenderMode;
|
||||
|
||||
use super::memo_network::MemoNetwork;
|
||||
use super::{
|
||||
DocumentNodePersistentMetadata, DocumentNodeTransientMetadata, InputMetadata, InputPersistentMetadata, LayerPosition, NavigationMetadata, NodeNetworkInterface, NodeNetworkMetadata,
|
||||
NodePersistentMetadata, NodePosition, NodeTypePersistentMetadata, PTZ, Previewing,
|
||||
};
|
||||
use crate::messages::portfolio::document::overlays::utility_types::OverlaysVisibilitySettings;
|
||||
use crate::messages::portfolio::document::utility_types::misc::SnappingState;
|
||||
use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers;
|
||||
|
||||
/// Fired when the storage entry list disagrees with the converted `NodeNetwork`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum InterfaceRebuildError {
|
||||
#[error("input_metadata length {got} does not match inputs length {expected} for node {node:?} in network {network_path:?}")]
|
||||
InputMetadataLengthMismatch { node: NodeId, network_path: Vec<NodeId>, expected: usize, got: usize },
|
||||
}
|
||||
|
||||
/// Per-peer view settings persisted in `session.json` under `ui::doc::*` (viewport view, render mode,
|
||||
/// overlay/ruler visibility, snapping, collapsed layers). Not part of the registry/CRDT/history; see
|
||||
/// [`DocumentSettings::to_view_map`].
|
||||
pub struct DocumentSettings<'a> {
|
||||
pub document_ptz: &'a PTZ,
|
||||
pub render_mode: &'a RenderMode,
|
||||
pub overlays_visibility: &'a OverlaysVisibilitySettings,
|
||||
pub rulers_visible: bool,
|
||||
pub snapping_state: &'a SnappingState,
|
||||
pub collapsed: &'a CollapsedLayers,
|
||||
}
|
||||
|
||||
/// Adapts a `&NodeNetworkInterface` to `graph-storage`'s `NodeMetadataSource` (node/network metadata
|
||||
/// only; document-level view settings live in `session.json`, not the registry).
|
||||
pub struct StorageMetadataView<'a> {
|
||||
interface: &'a NodeNetworkInterface,
|
||||
}
|
||||
|
||||
impl<'a> StorageMetadataView<'a> {
|
||||
pub fn new(interface: &'a NodeNetworkInterface) -> Self {
|
||||
Self { interface }
|
||||
}
|
||||
}
|
||||
|
||||
impl StorageMetadataView<'_> {
|
||||
fn persistent(&self, network_path: &[NodeId], local_id: NodeId) -> Option<&DocumentNodePersistentMetadata> {
|
||||
Some(&self.interface.node_metadata(&local_id, network_path)?.persistent_metadata)
|
||||
}
|
||||
|
||||
fn input_persistent(&self, network_path: &[NodeId], local_id: NodeId, input_index: usize) -> Option<&InputPersistentMetadata> {
|
||||
Some(&self.persistent(network_path, local_id)?.input_metadata.get(input_index)?.persistent_metadata)
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeMetadataSource for StorageMetadataView<'_> {
|
||||
fn position(&self, network_path: &[NodeId], local_id: NodeId) -> Option<Position> {
|
||||
match &self.persistent(network_path, local_id)?.node_type_metadata {
|
||||
NodeTypePersistentMetadata::Layer(layer) => match layer.position {
|
||||
LayerPosition::Absolute(v) => Some(Position::Absolute([v.x, v.y])),
|
||||
LayerPosition::Stack(offset) => Some(Position::Stack(offset)),
|
||||
},
|
||||
NodeTypePersistentMetadata::Node(node) => match *node.position() {
|
||||
NodePosition::Absolute(v) => Some(Position::Absolute([v.x, v.y])),
|
||||
NodePosition::Chain => Some(Position::Chain),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn is_layer(&self, network_path: &[NodeId], local_id: NodeId) -> bool {
|
||||
self.persistent(network_path, local_id)
|
||||
.is_some_and(|p| matches!(p.node_type_metadata, NodeTypePersistentMetadata::Layer(_)))
|
||||
}
|
||||
|
||||
fn display_name(&self, network_path: &[NodeId], local_id: NodeId) -> Option<&str> {
|
||||
Some(self.persistent(network_path, local_id)?.display_name.as_str())
|
||||
}
|
||||
|
||||
fn locked(&self, network_path: &[NodeId], local_id: NodeId) -> bool {
|
||||
self.persistent(network_path, local_id).is_some_and(|p| p.locked)
|
||||
}
|
||||
|
||||
fn pinned(&self, network_path: &[NodeId], local_id: NodeId) -> bool {
|
||||
self.persistent(network_path, local_id).is_some_and(|p| p.pinned)
|
||||
}
|
||||
|
||||
fn input_name(&self, network_path: &[NodeId], local_id: NodeId, input_index: usize) -> Option<&str> {
|
||||
Some(self.input_persistent(network_path, local_id, input_index)?.input_name.as_str())
|
||||
}
|
||||
|
||||
fn input_description(&self, network_path: &[NodeId], local_id: NodeId, input_index: usize) -> Option<&str> {
|
||||
Some(self.input_persistent(network_path, local_id, input_index)?.input_description.as_str())
|
||||
}
|
||||
|
||||
fn widget_override(&self, network_path: &[NodeId], local_id: NodeId, input_index: usize) -> Option<&str> {
|
||||
self.input_persistent(network_path, local_id, input_index)?.widget_override.as_deref()
|
||||
}
|
||||
|
||||
fn input_data(&self, network_path: &[NodeId], local_id: NodeId, input_index: usize) -> HashMap<String, serde_json::Value> {
|
||||
self.input_persistent(network_path, local_id, input_index).map(|p| p.input_data.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn output_names(&self, network_path: &[NodeId], local_id: NodeId) -> Vec<String> {
|
||||
self.persistent(network_path, local_id).map(|p| p.output_names.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn reference(&self, network_path: &[NodeId]) -> Option<&str> {
|
||||
let network_metadata = self.interface.network_metadata.nested_metadata(network_path)?;
|
||||
network_metadata.persistent_metadata.reference.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl DocumentSettings<'_> {
|
||||
/// Serialize the per-peer view settings into the `ui::doc::*`-keyed map persisted in `session.json`
|
||||
/// (via `Gdd::set_view_settings`). A field that fails to serialize is skipped, not fatal.
|
||||
pub fn to_view_map(&self) -> BTreeMap<String, serde_json::Value> {
|
||||
let entries = [
|
||||
(session::doc::PTZ, serde_json::to_value(self.document_ptz)),
|
||||
(session::doc::RENDER_MODE, serde_json::to_value(self.render_mode)),
|
||||
(session::doc::OVERLAYS, serde_json::to_value(self.overlays_visibility)),
|
||||
(session::doc::RULERS_VISIBLE, serde_json::to_value(self.rulers_visible)),
|
||||
(session::doc::SNAPPING, serde_json::to_value(self.snapping_state)),
|
||||
(session::doc::COLLAPSED, serde_json::to_value(self.collapsed)),
|
||||
];
|
||||
|
||||
entries
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| match value {
|
||||
Ok(value) => Some((key.to_string(), value)),
|
||||
Err(error) => {
|
||||
log::error!("Failed to serialize document setting {key}: {error}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of the position extraction in `NodeMetadataSource::position`.
|
||||
/// `(Stack, !is_layer)` and `(Chain, is_layer)` shouldn't arise from a faithful round-trip; they fall back to a default of the matching variant.
|
||||
pub fn position_to_runtime(position: Position, is_layer: bool) -> NodeTypePersistentMetadata {
|
||||
match (position, is_layer) {
|
||||
(Position::Absolute([x, y]), true) => NodeTypePersistentMetadata::layer(IVec2::new(x, y)),
|
||||
(Position::Absolute([x, y]), false) => NodeTypePersistentMetadata::node(IVec2::new(x, y)),
|
||||
(Position::Stack(offset), _) => {
|
||||
let mut metadata = NodeTypePersistentMetadata::layer(IVec2::ZERO);
|
||||
if let NodeTypePersistentMetadata::Layer(layer) = &mut metadata {
|
||||
layer.position = LayerPosition::Stack(offset);
|
||||
}
|
||||
metadata
|
||||
}
|
||||
(Position::Chain, _) => NodeTypePersistentMetadata::Node(NodePersistentMetadata::new(NodePosition::Chain)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `NodeNetworkInterface` from a `NodeNetwork` plus the metadata vecs `Registry::to_runtime_with_full_metadata` emits.
|
||||
///
|
||||
/// Sets private fields directly (rather than via public setters) because we're constructing a fresh self-consistent snapshot;
|
||||
/// the setters' transient-cache invalidation isn't needed.
|
||||
pub fn build_interface_from_storage(network: NodeNetwork, node_entries: Vec<NodeMetadataEntry>, network_entries: Vec<NetworkMetadataEntry>) -> Result<NodeNetworkInterface, InterfaceRebuildError> {
|
||||
let mut network_metadata = NodeNetworkMetadata::default();
|
||||
seed_metadata_tree(&network, &mut network_metadata);
|
||||
apply_entries_into_tree(&network, &mut network_metadata, node_entries)?;
|
||||
apply_network_entries_into_tree(&mut network_metadata, network_entries);
|
||||
|
||||
let interface = NodeNetworkInterface {
|
||||
network: MemoNetwork::new(network),
|
||||
network_metadata,
|
||||
..Default::default()
|
||||
};
|
||||
Ok(interface)
|
||||
}
|
||||
|
||||
/// Build the runtime-`network_path` -> stable-`NetworkId` map from the `NetworkMetadataEntry`s that
|
||||
/// `to_runtime_with_full_metadata` emits, so the open path can apply per-network view settings.
|
||||
pub fn network_ids_from_entries(network_entries: &[NetworkMetadataEntry]) -> HashMap<Vec<NodeId>, graph_storage::NetworkId> {
|
||||
network_entries.iter().map(|entry| (entry.network_path.clone(), entry.network_id)).collect()
|
||||
}
|
||||
|
||||
/// Collect the per-network, per-peer view state (node-graph nav + previewing) from `interface` into a
|
||||
/// `session.json` map keyed by the stable storage [`NetworkId`](graph_storage::NetworkId), which
|
||||
/// `network_ids` resolves from each runtime `network_path`. Networks at their default nav with no preview
|
||||
/// produce no entry.
|
||||
pub fn collect_network_view_settings(
|
||||
interface: &NodeNetworkInterface,
|
||||
network_ids: &HashMap<Vec<NodeId>, graph_storage::NetworkId>,
|
||||
) -> BTreeMap<graph_storage::NetworkId, BTreeMap<String, serde_json::Value>> {
|
||||
let mut out = BTreeMap::new();
|
||||
|
||||
for (network_path, &network_id) in network_ids {
|
||||
let Some(network_metadata) = interface.network_metadata.nested_metadata(network_path) else {
|
||||
continue;
|
||||
};
|
||||
let navigation = &network_metadata.persistent_metadata.navigation_metadata;
|
||||
let default_navigation = NavigationMetadata::default();
|
||||
|
||||
// Only persist nav fields that diverge from the default, so a network at its default view stays out
|
||||
// of `session.json` (the `if !settings.is_empty()` guard below skips it entirely).
|
||||
let mut settings = BTreeMap::new();
|
||||
if navigation.node_graph_ptz != default_navigation.node_graph_ptz
|
||||
&& let Ok(value) = serde_json::to_value(navigation.node_graph_ptz)
|
||||
{
|
||||
settings.insert(session::network::NAV_PTZ.to_string(), value);
|
||||
}
|
||||
if navigation.node_graph_to_viewport != default_navigation.node_graph_to_viewport
|
||||
&& let Ok(value) = serde_json::to_value(navigation.node_graph_to_viewport)
|
||||
{
|
||||
settings.insert(session::network::NAV_TRANSFORM.to_string(), value);
|
||||
}
|
||||
if navigation.node_graph_width != default_navigation.node_graph_width
|
||||
&& let Ok(value) = serde_json::to_value(navigation.node_graph_width)
|
||||
{
|
||||
settings.insert(session::network::NAV_WIDTH.to_string(), value);
|
||||
}
|
||||
|
||||
// Skip the inert `Previewing::No` default so a network that has never been previewed stays empty.
|
||||
if !matches!(network_metadata.persistent_metadata.previewing, Previewing::No)
|
||||
&& let Ok(value) = serde_json::to_value(network_metadata.persistent_metadata.previewing)
|
||||
{
|
||||
settings.insert(session::network::PREVIEWING.to_string(), value);
|
||||
}
|
||||
|
||||
if !settings.is_empty() {
|
||||
out.insert(network_id, settings);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Apply persisted per-network view state (node-graph nav + previewing) from `session.json` onto
|
||||
/// `interface`. Inverse of [`collect_network_view_settings`]: `network_ids` resolves each runtime
|
||||
/// `network_path` to its [`NetworkId`](graph_storage::NetworkId), and the matching inner map is decoded
|
||||
/// back onto the network's navigation/previewing metadata.
|
||||
pub fn apply_network_view_settings(
|
||||
interface: &mut NodeNetworkInterface,
|
||||
network_ids: &HashMap<Vec<NodeId>, graph_storage::NetworkId>,
|
||||
network_view_settings: &BTreeMap<graph_storage::NetworkId, BTreeMap<String, serde_json::Value>>,
|
||||
) {
|
||||
for (network_path, network_id) in network_ids {
|
||||
let Some(settings) = network_view_settings.get(network_id) else { continue };
|
||||
let Some(network_metadata) = interface.network_metadata.nested_metadata_mut(network_path) else {
|
||||
continue;
|
||||
};
|
||||
let persistent = &mut network_metadata.persistent_metadata;
|
||||
|
||||
if let Some(value) = settings.get(session::network::NAV_PTZ)
|
||||
&& let Ok(ptz) = serde_json::from_value::<PTZ>(value.clone())
|
||||
{
|
||||
persistent.navigation_metadata.node_graph_ptz = ptz;
|
||||
}
|
||||
if let Some(value) = settings.get(session::network::NAV_TRANSFORM)
|
||||
&& let Ok(transform) = serde_json::from_value(value.clone())
|
||||
{
|
||||
persistent.navigation_metadata.node_graph_to_viewport = transform;
|
||||
}
|
||||
if let Some(value) = settings.get(session::network::NAV_WIDTH)
|
||||
&& let Ok(width) = serde_json::from_value(value.clone())
|
||||
{
|
||||
persistent.navigation_metadata.node_graph_width = width;
|
||||
}
|
||||
if let Some(value) = settings.get(session::network::PREVIEWING)
|
||||
&& let Ok(previewing) = serde_json::from_value::<Previewing>(value.clone())
|
||||
{
|
||||
persistent.previewing = previewing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries whose `network_path` doesn't resolve are skipped with a warning (per-network drift is more often a stale path than corruption).
|
||||
fn apply_network_entries_into_tree(metadata: &mut NodeNetworkMetadata, entries: Vec<NetworkMetadataEntry>) {
|
||||
for entry in entries {
|
||||
let Some(network_metadata) = metadata.nested_metadata_mut(&entry.network_path) else {
|
||||
log::warn!("apply_network_entries_into_tree: nested network at {:?} not found, skipping", entry.network_path);
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(reference) = entry.reference {
|
||||
network_metadata.persistent_metadata.reference = Some(reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks `network` recursively, ensuring `metadata` has a default `DocumentNodeMetadata` slot for every node at every nesting level.
|
||||
/// Mirrors the editor invariant that `NodeNetworkPersistentMetadata::node_metadata` contains every document-node key.
|
||||
fn seed_metadata_tree(network: &NodeNetwork, metadata: &mut NodeNetworkMetadata) {
|
||||
for (&local_id, node) in &network.nodes {
|
||||
let node_metadata = metadata.persistent_metadata.node_metadata.entry(local_id).or_default();
|
||||
|
||||
if let DocumentNodeImplementation::Network(nested) = &node.implementation {
|
||||
let child = node_metadata.persistent_metadata.network_metadata.get_or_insert_with(NodeNetworkMetadata::default);
|
||||
seed_metadata_tree(nested, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Patches entries onto the metadata tree previously seeded by [`seed_metadata_tree`].
|
||||
/// Entries with stale `(network_path, local_id)` are skipped with a warning; `input_metadata` length mismatches escalate to `InterfaceRebuildError`.
|
||||
fn apply_entries_into_tree(network: &NodeNetwork, metadata: &mut NodeNetworkMetadata, entries: Vec<NodeMetadataEntry>) -> Result<(), InterfaceRebuildError> {
|
||||
// Group entries by network_path so we do one nested_metadata_mut lookup per network.
|
||||
let mut by_path: HashMap<Vec<NodeId>, Vec<NodeMetadataEntry>> = HashMap::new();
|
||||
for entry in entries {
|
||||
by_path.entry(entry.network_path.clone()).or_default().push(entry);
|
||||
}
|
||||
|
||||
for (path, entries) in by_path {
|
||||
let nested_network = nested_network_at(network, &path);
|
||||
|
||||
let Some(network_metadata) = metadata.nested_metadata_mut(&path) else {
|
||||
log::warn!("apply_entries_into_tree: nested network at {path:?} not found, skipping {} entries", entries.len());
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let Some(document_node_metadata) = network_metadata.persistent_metadata.node_metadata.get_mut(&entry.local_id) else {
|
||||
log::warn!("apply_entries_into_tree: node {:?} not seeded under network {path:?}, skipping", entry.local_id);
|
||||
continue;
|
||||
};
|
||||
|
||||
let persistent = &mut document_node_metadata.persistent_metadata;
|
||||
|
||||
if let Some(position) = entry.position {
|
||||
persistent.node_type_metadata = position_to_runtime(position, entry.is_layer);
|
||||
} else if entry.is_layer {
|
||||
persistent.node_type_metadata = NodeTypePersistentMetadata::layer(IVec2::ZERO);
|
||||
}
|
||||
|
||||
if let Some(name) = entry.display_name {
|
||||
persistent.display_name = name;
|
||||
}
|
||||
persistent.locked = entry.locked;
|
||||
persistent.pinned = entry.pinned;
|
||||
|
||||
// The converted runtime is the source of truth for input count; drift indicates a bug worth surfacing.
|
||||
let expected_inputs = nested_network.and_then(|net| net.nodes.get(&entry.local_id)).map(|doc_node| doc_node.inputs.len());
|
||||
if let Some(expected) = expected_inputs
|
||||
&& expected != entry.input_metadata.len()
|
||||
{
|
||||
return Err(InterfaceRebuildError::InputMetadataLengthMismatch {
|
||||
node: entry.local_id,
|
||||
network_path: path.clone(),
|
||||
expected,
|
||||
got: entry.input_metadata.len(),
|
||||
});
|
||||
}
|
||||
|
||||
persistent.input_metadata = entry.input_metadata.into_iter().map(input_metadata_entry_to_runtime).collect();
|
||||
|
||||
if !entry.output_names.is_empty() {
|
||||
persistent.output_names = entry.output_names;
|
||||
}
|
||||
|
||||
document_node_metadata.transient_metadata = DocumentNodeTransientMetadata::default();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `None` when the path is invalid; callers treat that as "skip silently" since the surrounding `nested_metadata_mut` already warns.
|
||||
fn nested_network_at<'a>(root: &'a NodeNetwork, path: &[NodeId]) -> Option<&'a NodeNetwork> {
|
||||
let mut current = root;
|
||||
for segment in path {
|
||||
let node = current.nodes.get(segment)?;
|
||||
let DocumentNodeImplementation::Network(nested) = &node.implementation else { return None };
|
||||
current = nested;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
/// Storage-side `None` restores as `""` (the runtime's "unset" sentinel for `input_name` / `input_description`).
|
||||
fn input_metadata_entry_to_runtime(entry: InputMetadataEntry) -> InputMetadata {
|
||||
InputMetadata {
|
||||
persistent_metadata: InputPersistentMetadata {
|
||||
input_name: entry.input_name.unwrap_or_default(),
|
||||
input_description: entry.input_description.unwrap_or_default(),
|
||||
widget_override: entry.widget_override,
|
||||
input_data: entry.input_data,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
231
editor/src/messages/portfolio/document_storage_io.rs
Normal file
231
editor/src/messages/portfolio/document_storage_io.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! Asynchronous `.gdd` working-copy IO, spawned by `PortfolioMessageHandler` as `FutureMessage`s:
|
||||
//! building/opening containers, opening `.gdd` archives into documents, and rebuilding the interface
|
||||
//! from the undo/redo cursor. The `validate` flag is the `validate_storage_round_trip` preference; when
|
||||
//! set, the registry build is compared against the legacy oracle (logged, not fatal) for the soak.
|
||||
|
||||
use document_container::AnyContainer;
|
||||
use document_format::{Error as DocumentFormatError, GddV1, GddV1Layout};
|
||||
use graph_craft::application_io::resource::{LoadResource, ResourceStorage};
|
||||
use graph_craft::document::NodeNetwork;
|
||||
|
||||
use super::document::DocumentMessageHandler;
|
||||
use super::document::diff_networks;
|
||||
use super::document::utility_types::network_interface::storage_metadata::{apply_network_view_settings, build_interface_from_storage, network_ids_from_entries};
|
||||
use super::document_migration::document_migration_string_preprocessing;
|
||||
use super::portfolio_message::PortfolioMessage;
|
||||
use crate::messages::message::Message;
|
||||
use crate::messages::portfolio::document::utility_types::misc::DocumentId;
|
||||
use crate::messages::resource_storage::ResourcesHandle;
|
||||
|
||||
/// Build the container backend at `path` (folder on native, OPFS directory name on web) or in-memory
|
||||
/// when `None`. Also returns whether an existing working copy was found (its `manifest.json` is present).
|
||||
async fn build_per_document_container(path: Option<&std::path::Path>) -> Result<(AnyContainer, bool), DocumentFormatError> {
|
||||
let Some(path) = path else {
|
||||
return Ok((AnyContainer::Memory(document_container::backends::memory::MemoryBackend::new()), false));
|
||||
};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
use document_container::backends::folder::FolderBackend;
|
||||
let exists = path.join("manifest.json").is_file();
|
||||
let backend = if exists { FolderBackend::open(path)? } else { FolderBackend::create(path)? };
|
||||
Ok((AnyContainer::Folder(backend), exists))
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
use document_container::AsyncContainer;
|
||||
use document_container::backends::opfs::OpfsBackend;
|
||||
let directory_name = path
|
||||
.to_str()
|
||||
.ok_or(DocumentFormatError::Container(document_container::ContainerError::InvalidPath(path.display().to_string())))?;
|
||||
let backend = OpfsBackend::open(directory_name).await?;
|
||||
let exists = backend.exists("manifest.json").await;
|
||||
Ok((AnyContainer::Opfs(backend), exists))
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the existing working copy at `path`, or create a fresh one bound to `peer` (in-memory when
|
||||
/// `path` is `None`). Returns the working copy plus whether it was reopened (vs freshly created); only a
|
||||
/// reopen has independently-stored state worth comparing against the legacy load.
|
||||
pub(super) async fn build_or_open_working_copy(path: Option<&std::path::Path>, peer: graph_storage::PeerId, document_uuid: u64, version: String) -> Result<(GddV1, bool), DocumentFormatError> {
|
||||
let (container, exists) = build_per_document_container(path).await?;
|
||||
|
||||
let gdd = if exists {
|
||||
GddV1::open_in(container, GddV1Layout).await?
|
||||
} else {
|
||||
GddV1::create_in(container, GddV1Layout, peer, document_uuid, version.clone(), version).await?
|
||||
};
|
||||
Ok((gdd, exists))
|
||||
}
|
||||
|
||||
/// `FutureMessage` that opens a `.gdd` archive into a document, delivered via
|
||||
/// [`PortfolioMessage::GddDocumentLoaded`]. See [`build_document_from_gdd`] for the build itself.
|
||||
pub(super) async fn open_gdd_document(
|
||||
working_copy_root: Option<std::path::PathBuf>,
|
||||
document_id: DocumentId,
|
||||
document_name: Option<String>,
|
||||
document_path: Option<std::path::PathBuf>,
|
||||
content: Vec<u8>,
|
||||
store_handle: ResourcesHandle,
|
||||
validate: bool,
|
||||
) -> Message {
|
||||
let path = working_copy_root.map(|root| root.join(format!("{:x}", document_id.0)));
|
||||
|
||||
let document = build_document_from_gdd(path.as_deref(), &content, &store_handle, document_id, validate).await;
|
||||
Message::Portfolio(PortfolioMessage::GddDocumentLoaded {
|
||||
document_id,
|
||||
document_name,
|
||||
document_path,
|
||||
document: document.map(Box::new),
|
||||
})
|
||||
}
|
||||
|
||||
/// `FutureMessage` that rebuilds a document's interface from a post-move `Gdd` cursor snapshot and
|
||||
/// delivers it via [`PortfolioMessage::GddUndoRedoRebuilt`] (`None` interface on failure, logged here).
|
||||
pub(crate) async fn rebuild_gdd_cursor(gdd: GddV1, store_handle: ResourcesHandle, document_id: DocumentId, had_oracle: bool) -> Message {
|
||||
let declarations = gdd.declarations(&store_handle).await;
|
||||
let interface = match gdd.registry().to_runtime_with_full_metadata(&declarations) {
|
||||
Ok((network, node_entries, network_entries)) => match build_interface_from_storage(network, node_entries, network_entries) {
|
||||
Ok(interface) => Some(Box::new(interface)),
|
||||
Err(error) => {
|
||||
log::error!("Gdd undo/redo rebuild for {document_id:?}: failed to build interface: {error}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
log::error!("Gdd undo/redo rebuild for {document_id:?}: failed to convert registry to runtime: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Message::Portfolio(PortfolioMessage::GddUndoRedoRebuilt { document_id, had_oracle, interface })
|
||||
}
|
||||
|
||||
/// Core of the `.gdd` open: archive -> working copy -> `Gdd` -> runtime interface. The registry build is
|
||||
/// authoritative; the embedded legacy blob is the soak oracle and the fallback if the build fails.
|
||||
/// Returns `None` only if neither the build nor the legacy fallback worked.
|
||||
async fn build_document_from_gdd(path: Option<&std::path::Path>, content: &[u8], store_handle: &impl ResourceStorage, document_id: DocumentId, validate: bool) -> Option<DocumentMessageHandler> {
|
||||
let (container, _exists) = match build_per_document_container(path).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
log::error!("Opening .gdd for {document_id:?}: failed to build working copy container: {error}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let gdd = match GddV1::open_from_archive(content, container, GddV1Layout).await {
|
||||
Ok(gdd) => gdd,
|
||||
Err(error) => {
|
||||
log::error!("Opening .gdd for {document_id:?}: failed to open archive: {error}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract archived resource bytes into the global cache so declarations + runtime resolve.
|
||||
match gdd.resource_hashes().await {
|
||||
Ok(hashes) => {
|
||||
for hash in hashes {
|
||||
match gdd.read_resource(&hash).await {
|
||||
Ok(holder) => {
|
||||
store_handle.store(holder.as_slice());
|
||||
}
|
||||
Err(error) => log::error!("Opening .gdd for {document_id:?}: failed to read resource {hash}: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => log::error!("Opening .gdd for {document_id:?}: failed to list resources: {error}"),
|
||||
}
|
||||
|
||||
let legacy_document = gdd
|
||||
.read_legacy_document()
|
||||
.await
|
||||
.and_then(|holder| String::from_utf8(holder.as_slice().to_vec()).ok())
|
||||
.and_then(|serialized| DocumentMessageHandler::deserialize_document(&document_migration_string_preprocessing(serialized)).ok());
|
||||
|
||||
let declarations = gdd.declarations(store_handle).await;
|
||||
let interface = match gdd.registry().to_runtime_with_full_metadata(&declarations) {
|
||||
Ok((network, node_entries, network_entries)) => {
|
||||
let network_ids = network_ids_from_entries(&network_entries);
|
||||
match build_interface_from_storage(network, node_entries, network_entries) {
|
||||
Ok(mut interface) => {
|
||||
// Per-network view state lives in `session.json`, not the registry, so restore it here.
|
||||
apply_network_view_settings(&mut interface, &network_ids, gdd.network_view_settings());
|
||||
Some(interface)
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Opening .gdd for {document_id:?}: failed to build interface: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Opening .gdd for {document_id:?}: failed to convert registry to runtime: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Soak compare against the legacy oracle, normalizing the benign generic-vs-resolved `call_argument`.
|
||||
if let Some(interface) = interface {
|
||||
if validate && let Some(legacy) = &legacy_document {
|
||||
let mut built = interface.document_network().clone();
|
||||
let mut oracle = legacy.network_interface.document_network().clone();
|
||||
normalize_call_arguments_for_compare(&mut built);
|
||||
normalize_call_arguments_for_compare(&mut oracle);
|
||||
if built != oracle {
|
||||
log::error!(
|
||||
"Open-.gdd divergence for {document_id:?}: registry-built document differs from the embedded legacy blob\n{}",
|
||||
diff_networks(&oracle, &built)
|
||||
);
|
||||
}
|
||||
}
|
||||
return Some(DocumentMessageHandler::from_storage(interface, gdd, String::new(), None));
|
||||
}
|
||||
|
||||
log::warn!("Opening .gdd for {document_id:?}: registry build failed, falling back to embedded legacy document");
|
||||
if legacy_document.is_none() {
|
||||
log::error!("Opening .gdd for {document_id:?}: no embedded legacy document to fall back to");
|
||||
}
|
||||
legacy_document
|
||||
}
|
||||
|
||||
/// Soak check that the reopened `.gdd`'s stored registry, converted back to a runtime network, matches
|
||||
/// the legacy load. Logs divergence only (legacy stays authoritative); runs once per open.
|
||||
pub(super) async fn compare_storage_against_runtime(gdd: &GddV1, legacy_network: &NodeNetwork, byte_store: &dyn LoadResource, document_id: DocumentId) {
|
||||
let declarations = gdd.declarations(byte_store).await;
|
||||
let mut candidate = match gdd.registry().to_runtime_with_metadata(&declarations) {
|
||||
Ok((network, _entries)) => network,
|
||||
Err(error) => {
|
||||
log::error!("Compare-on-open for {document_id:?}: .gdd registry failed to convert to runtime: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Normalize the benign generic-vs-resolved `call_argument` on both sides before comparing.
|
||||
let mut legacy = legacy_network.clone();
|
||||
normalize_call_arguments_for_compare(&mut legacy);
|
||||
normalize_call_arguments_for_compare(&mut candidate);
|
||||
|
||||
if candidate != legacy {
|
||||
log::error!(
|
||||
"Compare-on-open divergence for {document_id:?}: the reopened .gdd does not match the legacy load\n{}",
|
||||
diff_networks(&legacy, &candidate)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively rewrite a concrete `Context` `call_argument` back to the generic `T` storage authored, so
|
||||
/// the generic-vs-resolved difference (recomputed during compilation) compares equal. Other types untouched.
|
||||
fn normalize_call_arguments_for_compare(network: &mut NodeNetwork) {
|
||||
use graph_craft::Type;
|
||||
|
||||
let context = graph_craft::concrete!(graphene_std::Context);
|
||||
for node in network.nodes.values_mut() {
|
||||
if node.call_argument == context {
|
||||
node.call_argument = Type::Generic(std::borrow::Cow::Borrowed("T"));
|
||||
}
|
||||
if let graph_craft::document::DocumentNodeImplementation::Network(inner) = &mut node.implementation {
|
||||
normalize_call_arguments_for_compare(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod portfolio_message_handler;
|
||||
|
||||
pub mod document;
|
||||
pub mod document_migration;
|
||||
pub mod document_storage_io;
|
||||
pub mod fonts;
|
||||
pub mod persistent_state;
|
||||
pub mod utility_types;
|
||||
|
||||
@@ -8,7 +8,8 @@ use graphene_std::raster::Image;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[impl_message(Message, Portfolio)]
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(derivative::Derivative, serde::Serialize, serde::Deserialize)]
|
||||
#[derivative(Clone, Debug, PartialEq)]
|
||||
pub enum PortfolioMessage {
|
||||
// Sub-messages
|
||||
#[child]
|
||||
@@ -41,6 +42,18 @@ pub enum PortfolioMessage {
|
||||
DeleteDocument {
|
||||
document_id: DocumentId,
|
||||
},
|
||||
/// Delivers an asynchronously-built `Gdd` working copy into its document, emitted by the mount future
|
||||
/// spawned in `load_document`. The `gdd` payload is non-serializable and a clone carries none
|
||||
/// (`clone_to_none`), so it travels exactly once. `reopened` is true when an existing working copy was
|
||||
/// opened: the persisted cursor is trusted as-is and the mount-time re-commit is skipped, since
|
||||
/// re-committing would stack a spurious interaction on the restored cursor and make the first undo a no-op.
|
||||
DocumentStorageMounted {
|
||||
document_id: DocumentId,
|
||||
reopened: bool,
|
||||
#[serde(skip, default)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore", Clone(clone_with = "clone_to_none"))]
|
||||
gdd: Option<document_format::GddV1>,
|
||||
},
|
||||
DestroyAllDocuments,
|
||||
EditorPreferences,
|
||||
GarbageCollectResources,
|
||||
@@ -90,6 +103,33 @@ pub enum PortfolioMessage {
|
||||
document_path: Option<PathBuf>,
|
||||
document_serialized_content: String,
|
||||
},
|
||||
/// Open a `.gdd` document container (archive bytes), building the runtime from its stored registry.
|
||||
OpenGddDocument {
|
||||
document_name: Option<String>,
|
||||
document_path: Option<PathBuf>,
|
||||
content: Vec<u8>,
|
||||
},
|
||||
/// Delivers a document built asynchronously from a `.gdd` archive (registry → runtime, working copy
|
||||
/// mounted) into the portfolio. Travels once like [`DocumentStorageMounted`](Self::DocumentStorageMounted).
|
||||
GddDocumentLoaded {
|
||||
document_id: DocumentId,
|
||||
document_name: Option<String>,
|
||||
document_path: Option<PathBuf>,
|
||||
#[serde(skip, default)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore", Clone(clone_with = "clone_to_none"))]
|
||||
document: Option<Box<DocumentMessageHandler>>,
|
||||
},
|
||||
/// Delivers the interface rebuilt from the `Gdd` undo/redo cursor so the async rebuild can swap into the
|
||||
/// live document. `interface` is `None` if the rebuild failed (logged at the source). `had_oracle` records
|
||||
/// whether the legacy snapshot applied synchronously, so the swap can debug-compare against it. Travels
|
||||
/// once like [`DocumentStorageMounted`](Self::DocumentStorageMounted).
|
||||
GddUndoRedoRebuilt {
|
||||
document_id: DocumentId,
|
||||
had_oracle: bool,
|
||||
#[serde(skip, default)]
|
||||
#[derivative(Debug = "ignore", PartialEq = "ignore", Clone(clone_with = "clone_to_none"))]
|
||||
interface: Option<Box<crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface>>,
|
||||
},
|
||||
LoadDocument {
|
||||
document_id: DocumentId,
|
||||
document_name: Option<String>,
|
||||
@@ -180,3 +220,8 @@ pub enum PortfolioMessage {
|
||||
sizes: Vec<f64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Clone helper for the non-serializable `gdd` payload: a cloned mount message carries no `Gdd`.
|
||||
fn clone_to_none<T>(_: &Option<T>) -> Option<T> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use super::persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageHandler};
|
||||
use super::utility_types::{PanelLayoutSubdivision, PanelType, WorkspacePanelLayout};
|
||||
use crate::application::{Editor, generate_uuid};
|
||||
use crate::consts::{DEFAULT_DOCUMENT_NAME, FILE_EXTENSION};
|
||||
use crate::consts::{DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, GDD_FILE_EXTENSION};
|
||||
use crate::messages::animation::TimingInformation;
|
||||
use crate::messages::dialog::simple_dialogs;
|
||||
use crate::messages::frontend::utility_types::{DocumentInfo, PersistedState};
|
||||
@@ -14,6 +14,7 @@ use crate::messages::portfolio::document::graph_operation::utility_types::Transf
|
||||
use crate::messages::portfolio::document::node_graph::document_node_definitions;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::OutputConnector;
|
||||
use crate::messages::portfolio::document_migration::*;
|
||||
use crate::messages::portfolio::document_storage_io::{build_or_open_working_copy, compare_storage_against_runtime, open_gdd_document};
|
||||
use crate::messages::portfolio::utility_types::FileContent;
|
||||
use crate::messages::preferences::SelectionMode;
|
||||
use crate::messages::prelude::*;
|
||||
@@ -64,6 +65,9 @@ pub struct PortfolioMessageHandler {
|
||||
pub selection_mode: SelectionMode,
|
||||
pub reset_node_definitions_on_open: bool,
|
||||
pub workspace_panel_layout: WorkspacePanelLayout,
|
||||
working_copy_root: Option<PathBuf>,
|
||||
/// Number of document not fully loaded. While non-zero, resource GC is skipped.
|
||||
pending_opens: usize,
|
||||
}
|
||||
|
||||
#[message_handler_data]
|
||||
@@ -197,7 +201,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
responses.add(PortfolioMessage::GarbageCollectResources);
|
||||
}
|
||||
PortfolioMessage::AutoSaveDocument { document_id } => {
|
||||
let Some(document) = self.document(document_id) else { return };
|
||||
let validate = preferences.validate_storage_round_trip;
|
||||
let Some(document) = self.documents.get_mut(&document_id) else { return };
|
||||
|
||||
document.commit_storage_snapshot(&resource_storage.resources_mut(), validate);
|
||||
|
||||
responses.add(PersistentStateMessage::WriteDocument {
|
||||
document_id,
|
||||
document: document.serialize_document(),
|
||||
@@ -292,6 +300,17 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
responses.add(PortfolioMessage::SelectDocument { document_id });
|
||||
}
|
||||
}
|
||||
PortfolioMessage::DocumentStorageMounted { document_id, reopened, gdd } => {
|
||||
let Some(document) = self.documents.get_mut(&document_id) else {
|
||||
// Document was closed before its working copy finished mounting.
|
||||
return;
|
||||
};
|
||||
document.set_storage(gdd);
|
||||
if !reopened {
|
||||
document.commit_storage_snapshot(&resource_storage.resources_mut(), preferences.validate_storage_round_trip);
|
||||
document.retire_storage_interaction();
|
||||
}
|
||||
}
|
||||
PortfolioMessage::DestroyAllDocuments => {
|
||||
// Empty the list of internal document data
|
||||
self.documents.clear();
|
||||
@@ -307,6 +326,10 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
// We don't know what can be safely garbage collected
|
||||
return;
|
||||
}
|
||||
if self.pending_opens > 0 {
|
||||
// A document not fully loaded, skip garbage collection.
|
||||
return;
|
||||
}
|
||||
|
||||
let mut used_resources = HashSet::new();
|
||||
for (id, info) in self.unloaded_documents.iter() {
|
||||
@@ -320,6 +343,10 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
for document in self.documents.values_mut() {
|
||||
document.garbage_collect_resources();
|
||||
used_resources.extend(document.resources.registry.resolved().filter_map(|info| info.hash.cloned()));
|
||||
|
||||
if let Some(storage) = document.storage() {
|
||||
used_resources.extend(storage.all_referenced_resource_hashes());
|
||||
}
|
||||
}
|
||||
used_resources.extend(self.fonts.used_resources());
|
||||
responses.add(ResourceStorageMessage::GarbageCollect {
|
||||
@@ -505,7 +532,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
responses.add(NavigationMessage::CanvasPan { delta: (0., 0.).into() });
|
||||
}
|
||||
|
||||
self.load_document(new_document, document_id, responses);
|
||||
self.load_document(new_document, document_id, resource_storage, preferences.validate_storage_round_trip, responses);
|
||||
responses.add(PortfolioMessage::SelectDocument { document_id });
|
||||
}
|
||||
PortfolioMessage::MoveAllPanelTabs {
|
||||
@@ -642,6 +669,14 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
document_serialized_content: content,
|
||||
});
|
||||
}
|
||||
FileContent::GddDocument(content) => {
|
||||
let document_path = if path.is_absolute() { Some(path) } else { None };
|
||||
responses.add(PortfolioMessage::OpenGddDocument {
|
||||
document_name: name,
|
||||
document_path,
|
||||
content,
|
||||
});
|
||||
}
|
||||
FileContent::Svg(svg) => {
|
||||
responses.add(PortfolioMessage::OpenSvg { name, svg });
|
||||
}
|
||||
@@ -669,6 +704,13 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
document_serialized_content: content,
|
||||
});
|
||||
}
|
||||
FileContent::GddDocument(content) => {
|
||||
responses.add(PortfolioMessage::OpenGddDocument {
|
||||
document_name: name,
|
||||
document_path: Some(path),
|
||||
content,
|
||||
});
|
||||
}
|
||||
FileContent::Svg(svg) => {
|
||||
responses.add(PortfolioMessage::InsertSvg {
|
||||
name,
|
||||
@@ -710,6 +752,68 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
});
|
||||
responses.add(PortfolioMessage::SelectDocument { document_id });
|
||||
}
|
||||
PortfolioMessage::OpenGddDocument {
|
||||
document_name,
|
||||
document_path,
|
||||
content,
|
||||
} => {
|
||||
let document_id = DocumentId(generate_uuid());
|
||||
|
||||
// Suppress resource GC until the open completes
|
||||
self.pending_opens += 1;
|
||||
|
||||
responses.add(open_gdd_document(
|
||||
self.working_copy_root.clone(),
|
||||
document_id,
|
||||
document_name,
|
||||
document_path,
|
||||
content,
|
||||
resource_storage.resources_mut(),
|
||||
preferences.validate_storage_round_trip,
|
||||
));
|
||||
}
|
||||
PortfolioMessage::GddDocumentLoaded {
|
||||
document_id,
|
||||
document_name,
|
||||
document_path,
|
||||
document,
|
||||
} => {
|
||||
self.pending_opens = self.pending_opens.saturating_sub(1);
|
||||
|
||||
let Some(mut document) = document.map(|boxed| *boxed) else {
|
||||
let name = document_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.or_else(|| document_path.as_ref().and_then(|path| path.file_stem()).map(|stem| stem.to_string_lossy().into_owned()))
|
||||
.unwrap_or_default();
|
||||
simple_dialogs::FailedToOpenDocumentDialog { document_name: name }.send_dialog_to_frontend(responses);
|
||||
return;
|
||||
};
|
||||
document.finalize_storage_load();
|
||||
document.set_save_state(true);
|
||||
|
||||
let name = document_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.or_else(|| document_path.as_ref().and_then(|path| path.file_stem()).map(|stem| stem.to_string_lossy().into_owned()))
|
||||
.unwrap_or_else(|| DEFAULT_DOCUMENT_NAME.to_string());
|
||||
document.name = self.resolve_document_name(name, None);
|
||||
document.path = document_path;
|
||||
|
||||
// The working copy is already mounted (we opened the .gdd), so skip the async re-mount.
|
||||
self.load_document(document, document_id, resource_storage, preferences.validate_storage_round_trip, responses);
|
||||
responses.add(PortfolioMessage::SelectDocument { document_id });
|
||||
}
|
||||
PortfolioMessage::GddUndoRedoRebuilt { document_id, had_oracle, interface } => {
|
||||
let Some(document) = self.documents.get_mut(&document_id) else {
|
||||
// Document was closed before its undo/redo rebuild completed; drop the payload.
|
||||
return;
|
||||
};
|
||||
let Some(interface) = interface.map(|boxed| *boxed) else {
|
||||
// The rebuild failed and already logged; leave the live document untouched.
|
||||
return;
|
||||
};
|
||||
|
||||
document.apply_gdd_cursor_rebuild(interface, had_oracle, preferences.validate_storage_round_trip, responses);
|
||||
}
|
||||
PortfolioMessage::ToggleResetNodesToDefinitionsOnOpen => {
|
||||
self.reset_node_definitions_on_open = !self.reset_node_definitions_on_open;
|
||||
responses.add(MenuBarMessage::SendLayout);
|
||||
@@ -836,8 +940,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
document.set_save_state(document_is_saved);
|
||||
|
||||
let document_name_from_path = document_path.as_ref().and_then(|path| {
|
||||
if path.extension().is_some_and(|e| e == FILE_EXTENSION) {
|
||||
path.file_stem().map(|n| n.to_string_lossy().to_string())
|
||||
if path.extension().is_some_and(|extension| extension == FILE_EXTENSION || extension == GDD_FILE_EXTENSION) {
|
||||
path.file_stem().map(|stem| stem.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -855,7 +959,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
document.name = self.resolve_document_name(candidate_name, None);
|
||||
|
||||
// Load the document into the portfolio so it opens in the editor
|
||||
self.load_document(document, document_id, responses);
|
||||
self.load_document(document, document_id, resource_storage, preferences.validate_storage_round_trip, responses);
|
||||
|
||||
responses.add(AppWindowMessage::Focus);
|
||||
|
||||
@@ -1601,6 +1705,10 @@ impl PortfolioMessageHandler {
|
||||
Self { executor, ..Default::default() }
|
||||
}
|
||||
|
||||
pub fn set_working_copy_root(&mut self, root: Option<PathBuf>) {
|
||||
self.working_copy_root = root;
|
||||
}
|
||||
|
||||
pub fn document(&self, document_id: DocumentId) -> Option<&DocumentMessageHandler> {
|
||||
self.documents.get(&document_id)
|
||||
}
|
||||
@@ -1698,6 +1806,7 @@ impl PortfolioMessageHandler {
|
||||
Ok(content) => FileContent::Document(content),
|
||||
Err(_) => FileContent::Unsupported,
|
||||
},
|
||||
GDD_FILE_EXTENSION => FileContent::GddDocument(content),
|
||||
"svg" => match String::from_utf8(content) {
|
||||
Ok(content) => FileContent::Svg(content),
|
||||
Err(_) => FileContent::Unsupported,
|
||||
@@ -1717,7 +1826,14 @@ impl PortfolioMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_document(&mut self, mut new_document: DocumentMessageHandler, document_id: DocumentId, responses: &mut VecDeque<Message>) {
|
||||
fn load_document(
|
||||
&mut self,
|
||||
mut new_document: DocumentMessageHandler,
|
||||
document_id: DocumentId,
|
||||
resource_storage: &ResourceStorageMessageHandler,
|
||||
validate: bool,
|
||||
responses: &mut VecDeque<Message>,
|
||||
) {
|
||||
let is_new_document = !self.document_ids.contains(&document_id);
|
||||
if is_new_document {
|
||||
self.document_ids.push_back(document_id);
|
||||
@@ -1744,6 +1860,64 @@ impl PortfolioMessageHandler {
|
||||
if is_new_document {
|
||||
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
|
||||
}
|
||||
|
||||
// Mount the per-document `Gdd` working copy asynchronously.
|
||||
//
|
||||
// Only mount for legacy loads: a `.gdd` open already mounted its working copy
|
||||
// and remounting would overwrite it and drop that history state.
|
||||
if self.documents.get(&document_id).and_then(|document| document.storage()).is_none() {
|
||||
let legacy_network = self
|
||||
.documents
|
||||
.get(&document_id)
|
||||
.map(|document| document.network_interface.document_network().clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
responses.add(Self::mount_document_storage(
|
||||
self.working_copy_root.clone(),
|
||||
document_id,
|
||||
legacy_network,
|
||||
resource_storage.resources(),
|
||||
validate,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// With a configured root the working copy lives at `<root>/<id_hex>`; without one it is in-memory.
|
||||
///
|
||||
/// On a *reopen* (existing working copy), the freshly-read `.gdd` is converted back to a runtime
|
||||
/// network and compared against `legacy_network` before
|
||||
/// the working copy attaches. It validates the `.gdd` *read* path.
|
||||
fn mount_document_storage(
|
||||
working_copy_root: Option<std::path::PathBuf>,
|
||||
document_id: DocumentId,
|
||||
legacy_network: graph_craft::document::NodeNetwork,
|
||||
byte_store: Box<dyn graph_craft::application_io::resource::LoadResource>,
|
||||
validate: bool,
|
||||
) -> Message {
|
||||
let path = working_copy_root.map(|root| root.join(format!("{:016x}", document_id.0)));
|
||||
let editor_version = crate::application::GRAPHITE_GIT_COMMIT_HASH.to_string();
|
||||
let peer = graph_storage::PeerId(generate_uuid());
|
||||
|
||||
let future = async move {
|
||||
let (gdd, reopened) = match build_or_open_working_copy(path.as_deref(), peer, document_id.0, editor_version).await {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
log::error!("Failed to mount document storage for {document_id:?}: {error}");
|
||||
return Message::NoOp;
|
||||
}
|
||||
};
|
||||
|
||||
if validate && reopened {
|
||||
compare_storage_against_runtime(&gdd, &legacy_network, byte_store.as_ref(), document_id).await;
|
||||
}
|
||||
|
||||
Message::Portfolio(PortfolioMessage::DocumentStorageMounted {
|
||||
document_id,
|
||||
reopened,
|
||||
gdd: Some(gdd),
|
||||
})
|
||||
};
|
||||
future.into()
|
||||
}
|
||||
|
||||
/// Returns an iterator over the open documents in order.
|
||||
|
||||
@@ -789,8 +789,10 @@ impl PanelLayoutSubdivision {
|
||||
}
|
||||
|
||||
pub enum FileContent {
|
||||
/// A Graphite document.
|
||||
/// A legacy `.graphite` document (serialized runtime JSON).
|
||||
Document(String),
|
||||
/// A `.gdd` document container (archive bytes).
|
||||
GddDocument(Vec<u8>),
|
||||
/// A bitmap image.
|
||||
Image(Image<Color>),
|
||||
/// An SVG file string.
|
||||
|
||||
@@ -36,6 +36,13 @@ pub enum PreferencesMessage {
|
||||
DisableUIAcceleration {
|
||||
disable_ui_acceleration: bool,
|
||||
},
|
||||
ValidateStorageRoundTrip {
|
||||
enabled: bool,
|
||||
},
|
||||
SaveAsGdd {
|
||||
enabled: bool,
|
||||
},
|
||||
ToggleShowStoragePreferences,
|
||||
#[cfg(target_os = "macos")]
|
||||
VSync {
|
||||
vsync: bool,
|
||||
|
||||
@@ -23,6 +23,9 @@ pub struct PreferencesMessageHandler {
|
||||
pub ui_scale: f64,
|
||||
pub max_render_region_size: u32,
|
||||
pub disable_ui_acceleration: bool,
|
||||
pub validate_storage_round_trip: bool,
|
||||
pub save_as_gdd: bool,
|
||||
pub show_storage_preferences: bool,
|
||||
#[cfg(target_os = "macos")]
|
||||
pub vsync: bool,
|
||||
}
|
||||
@@ -66,6 +69,9 @@ impl Default for PreferencesMessageHandler {
|
||||
ui_scale: UI_SCALE_DEFAULT,
|
||||
max_render_region_size: EditorPreferences::default().max_render_region_size,
|
||||
disable_ui_acceleration: cfg!(target_os = "linux"), // TODO: Set this back to false once we have ui acceleration working more reliably on linux
|
||||
validate_storage_round_trip: false,
|
||||
save_as_gdd: false,
|
||||
show_storage_preferences: false,
|
||||
#[cfg(target_os = "macos")]
|
||||
vsync: false,
|
||||
}
|
||||
@@ -132,6 +138,16 @@ impl MessageHandler<PreferencesMessage, PreferencesMessageContext<'_>> for Prefe
|
||||
PreferencesMessage::DisableUIAcceleration { disable_ui_acceleration } => {
|
||||
self.disable_ui_acceleration = disable_ui_acceleration;
|
||||
}
|
||||
PreferencesMessage::ValidateStorageRoundTrip { enabled } => {
|
||||
self.validate_storage_round_trip = enabled;
|
||||
}
|
||||
PreferencesMessage::SaveAsGdd { enabled } => {
|
||||
self.save_as_gdd = enabled;
|
||||
}
|
||||
PreferencesMessage::ToggleShowStoragePreferences => {
|
||||
self.show_storage_preferences = !self.show_storage_preferences;
|
||||
responses.add(MenuBarMessage::SendLayout);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
PreferencesMessage::VSync { vsync } => {
|
||||
self.vsync = vsync;
|
||||
|
||||
@@ -13,6 +13,20 @@ impl LoadResource for ResourcesHandle {
|
||||
}
|
||||
}
|
||||
|
||||
impl ResourceStorage for ResourcesHandle {
|
||||
fn store(&self, data: &[u8]) -> ResourceHash {
|
||||
self.inner.store(data)
|
||||
}
|
||||
|
||||
fn contains(&self, hash: &ResourceHash) -> bool {
|
||||
self.inner.contains(hash)
|
||||
}
|
||||
|
||||
fn garbage_collect(&self, used: &[ResourceHash]) {
|
||||
self.inner.garbage_collect(used)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ExtractField)]
|
||||
pub struct ResourceStorageMessageHandler {
|
||||
storage: Option<Arc<dyn ResourceStorage>>,
|
||||
@@ -28,6 +42,12 @@ impl ResourceStorageMessageHandler {
|
||||
inner: self.storage.clone().expect("Resource storage not initialized"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resources_mut(&self) -> ResourcesHandle {
|
||||
ResourcesHandle {
|
||||
inner: self.storage.clone().expect("Resource storage not initialized"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ResourceStorageMessageHandler {
|
||||
|
||||
@@ -28,6 +28,10 @@ impl EditorTestUtils {
|
||||
|
||||
editor.handle_message(PortfolioMessage::Init);
|
||||
|
||||
// Enable the dual-write soak validation in tests so storage round-trip drift fails loud, matching
|
||||
// the previous always-on-in-debug behavior now that it is gated by a preference.
|
||||
editor.handle_message(PreferencesMessage::ValidateStorageRoundTrip { enabled: true });
|
||||
|
||||
Self { editor, runtime }
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
|
||||
});
|
||||
|
||||
subscriptions.subscribeFrontendMessage("TriggerOpen", async () => {
|
||||
const files = await upload(`image/*,.${editor.fileExtension()}`, "data", true);
|
||||
const files = await upload(`image/*,.${editor.fileExtension()},.${editor.gddFileExtension()}`, "data", true);
|
||||
files.forEach((file) => editor.openFile(file.filename, file.content));
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{EDITOR_HAS_CRASHED, Error, FRONTEND_READY, MESSAGE_BUFFER};
|
||||
#[cfg(not(feature = "native"))]
|
||||
#[cfg(all(not(feature = "native"), target_family = "wasm"))]
|
||||
use editor::application::{Editor, Environment, Host, Platform};
|
||||
use editor::consts::FILE_EXTENSION;
|
||||
use editor::consts::{FILE_EXTENSION, GDD_FILE_EXTENSION};
|
||||
use editor::messages::clipboard::utility_types::ClipboardContentRaw;
|
||||
use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys;
|
||||
use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta};
|
||||
@@ -95,7 +95,10 @@ impl EditorWrapper {
|
||||
|
||||
let application_io = PlatformApplicationIo::new().await;
|
||||
let wake = crate::helpers::async_wake_callback();
|
||||
let editor = Editor::new(Environment { platform: Platform::Web, host }, uuid_random_seed, storage, application_io, wake);
|
||||
// On web the working-copy root is an OPFS directory name (no real filesystem path); each
|
||||
// document mounts under `documents/<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() {
|
||||
log::error!("Attempted to initialize the editor more than once");
|
||||
@@ -333,6 +336,12 @@ impl EditorWrapper {
|
||||
FILE_EXTENSION.into()
|
||||
}
|
||||
|
||||
/// Get the constant `GDD_FILE_EXTENSION`
|
||||
#[wasm_bindgen(js_name = gddFileExtension)]
|
||||
pub fn gdd_file_extension(&self) -> String {
|
||||
GDD_FILE_EXTENSION.into()
|
||||
}
|
||||
|
||||
/// Update the value of a given UI widget, but don't commit it to the history (unless `commit_layout()` is called, which handles that)
|
||||
#[wasm_bindgen(js_name = widgetValueUpdate)]
|
||||
pub fn widget_value_update(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> {
|
||||
|
||||
Reference in New Issue
Block a user