From 1bea31617241dd226364bca22ce230ab186413f6 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 7 May 2026 17:46:14 -0700 Subject: [PATCH] Add support for opening multiple selected files from disk --- desktop/src/app.rs | 22 +++++-- .../wrapper/src/intercept_frontend_message.rs | 2 + desktop/wrapper/src/messages.rs | 2 + .../utility_types/network_interface.rs | 1 + frontend/src/stores/portfolio.ts | 4 +- frontend/src/utility-functions/files.ts | 43 ++++++++---- .../src/vector/vector_modification.rs | 66 ++++++++++++++++--- node-graph/nodes/gstd/src/lib.rs | 2 +- 8 files changed, 109 insertions(+), 33 deletions(-) diff --git a/desktop/src/app.rs b/desktop/src/app.rs index d69b578350..2c69c4b3ec 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -198,7 +198,7 @@ impl App { }; self.send_or_queue_web_message(bytes); } - DesktopFrontendMessage::OpenFileDialog { title, filters, context } => { + DesktopFrontendMessage::OpenFileDialog { title, filters, multiple, context } => { let app_event_scheduler = self.app_event_scheduler.clone(); let _ = thread::spawn(move || { let mut dialog = AsyncFileDialog::new().set_title(title); @@ -206,13 +206,21 @@ impl App { dialog = dialog.add_filter(filter.name, &filter.extensions); } - let show_dialog = async move { dialog.pick_file().await.map(|f| f.path().to_path_buf()) }; + let handles = if multiple { + futures::executor::block_on(dialog.pick_files()).unwrap_or_default() + } else { + futures::executor::block_on(dialog.pick_file()).into_iter().collect() + }; - if let Some(path) = futures::executor::block_on(show_dialog) - && let Ok(content) = fs::read(&path) - { - let message = DesktopWrapperMessage::FileDialogResult { path, content, context }; - app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); + for handle in handles { + let path = handle.path().to_path_buf(); + match fs::read(&path) { + Ok(content) => { + let message = DesktopWrapperMessage::FileDialogResult { path, content, context }; + app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); + } + Err(e) => tracing::error!("Failed to read file {}: {}", path.display(), e), + } } }); } diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index da2e8a7ff4..2774532f28 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -14,6 +14,7 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { title: "Open Document".to_string(), filters: vec![], + multiple: true, context: OpenFileDialogContext::Open, }); } @@ -21,6 +22,7 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD dispatcher.respond(DesktopFrontendMessage::OpenFileDialog { title: "Import File".to_string(), filters: vec![], + multiple: false, context: OpenFileDialogContext::Import, }); } diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 4222dc4d86..5d4cbced67 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -16,6 +16,7 @@ pub enum DesktopFrontendMessage { OpenFileDialog { title: String, filters: Vec, + multiple: bool, context: OpenFileDialogContext, }, SaveFileDialog { @@ -102,6 +103,7 @@ pub struct FileFilter { pub extensions: Vec, } +#[derive(Clone, Copy)] pub enum OpenFileDialogContext { Open, Import, diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 60b4ce9c17..0253f707f6 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -6442,6 +6442,7 @@ pub struct InputPersistentMetadata { /// A general datastore than can store key value pairs of any types for any input /// Each instance of the input node needs to store its own data, since it can lose the reference to its /// node definition if the node signature is modified by the user. For example adding/removing/renaming an import/export of a network node. + #[serde(serialize_with = "graphene_std::vector::serialize_hashmap_as_sorted_object")] pub input_data: HashMap, // An input can override a widget, which would otherwise be automatically generated from the type // The string is the identifier to the widget override function stored in INPUT_OVERRIDES diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 5c838b7e0a..8665ccda7e 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -94,8 +94,8 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: }); subscriptions.subscribeFrontendMessage("TriggerOpen", async () => { - const data = await upload(`image/*,.${editor.fileExtension()}`, "data"); - editor.openFile(data.filename, data.content); + const files = await upload(`image/*,.${editor.fileExtension()}`, "data", true); + files.forEach((file) => editor.openFile(file.filename, file.content)); }); subscriptions.subscribeFrontendMessage("TriggerImport", async () => { diff --git a/frontend/src/utility-functions/files.ts b/frontend/src/utility-functions/files.ts index 11cbb837cf..aaf9dbe9d0 100644 --- a/frontend/src/utility-functions/files.ts +++ b/frontend/src/utility-functions/files.ts @@ -32,29 +32,44 @@ export function downloadFile(filename: string, content: Uint8Array) { export async function upload(accept: string, textOrData: "text"): Promise>; export async function upload(accept: string, textOrData: "data"): Promise>; export async function upload(accept: string, textOrData: "both"): Promise>; -export async function upload(accept: string, textOrData: "text" | "data" | "both"): Promise> { +export async function upload(accept: string, textOrData: "data", multiple: true): Promise[]>; +export async function upload( + accept: string, + textOrData: "text" | "data" | "both", + multiple = false, +): Promise | UploadResult[]> { return new Promise((resolve) => { const element = document.createElement("input"); element.type = "file"; element.accept = accept; + element.multiple = multiple; element.addEventListener( "change", async () => { - if (element.files?.length) { - const file = element.files[0]; - - const filename = file.name; - const type = file.type; - const content = - textOrData === "text" - ? await file.text() - : textOrData === "data" - ? new Uint8Array(await file.arrayBuffer()) - : { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) }; - - resolve({ filename, type, content }); + if (!element.files?.length) return; + + // The `multiple: true` overload constrains `textOrData` to "data", so we know each file produces a Uint8Array + if (multiple) { + const results = await Promise.all( + Array.from(element.files).map(async (file) => ({ + filename: file.name, + type: file.type, + content: new Uint8Array(await file.arrayBuffer()), + })), + ); + resolve(results); + return; } + + const file = element.files[0]; + const content = + textOrData === "text" + ? await file.text() + : textOrData === "data" + ? new Uint8Array(await file.arrayBuffer()) + : { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) }; + resolve({ filename: file.name, type: file.type, content }); }, { capture: false, once: true }, ); diff --git a/node-graph/libraries/vector-types/src/vector/vector_modification.rs b/node-graph/libraries/vector-types/src/vector/vector_modification.rs index f56d4cbbf5..2e5f0f4e39 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_modification.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_modification.rs @@ -5,14 +5,20 @@ use core_types::uuid::generate_uuid; use dyn_any::DynAny; use glam::DVec2; use kurbo::{BezPath, PathEl, Point}; +use serde::de::{SeqAccess, Visitor}; +use serde::ser::SerializeSeq; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::collections::{HashMap, HashSet}; +use std::fmt; use std::hash::BuildHasher; +use std::hash::Hash; /// Represents a procedural change to the [`PointDomain`] in [`Vector`]. #[derive(Clone, Debug, Default, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PointModification { add: Vec, + #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))] remove: HashSet, #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))] delta: HashMap, @@ -79,6 +85,7 @@ impl PointModification { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct SegmentModification { add: Vec, + #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))] remove: HashSet, #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))] start_point: HashMap, @@ -250,6 +257,7 @@ impl SegmentModification { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct RegionModification { add: Vec, + #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))] remove: HashSet, #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))] segment_range: HashMap>, @@ -297,7 +305,9 @@ pub struct VectorModification { points: PointModification, segments: SegmentModification, regions: RegionModification, + #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))] add_g1_continuous: HashSet<[HandleId; 2]>, + #[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))] remove_g1_continuous: HashSet<[HandleId; 2]>, } @@ -520,27 +530,65 @@ impl graphene_hash::CacheHash for VectorModification { } } -// Do we want to enforce that all serialized/deserialized hashmaps are a vec of tuples? +// TODO: Do we want to enforce that all serialized/deserialized hashmaps are a vec of tuples? // TODO: Eventually remove this document upgrade code -use serde::de::{SeqAccess, Visitor}; -use serde::ser::SerializeSeq; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::fmt; -use std::hash::Hash; +/// Serializes as sorted `[[key, value], ...]` (sequence of pairs) pub fn serialize_hashmap(hashmap: &HashMap, serializer: S) -> Result where - K: Serialize + Eq + Hash, + K: Serialize + Eq + Hash + Ord, V: Serialize, S: Serializer, H: BuildHasher, { - let mut seq = serializer.serialize_seq(Some(hashmap.len()))?; - for (key, value) in hashmap { + // Sort entries by key so the serialized output is deterministic across runs (HashMap iteration order is randomized). + // Removes a major source of churn in saved-document diffs without affecting load behavior. + let mut entries: Vec<_> = hashmap.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + + let mut seq = serializer.serialize_seq(Some(entries.len()))?; + for (key, value) in entries { seq.serialize_element(&(key, value))?; } seq.end() } +/// Serializes as sorted `{"key": value, ...}` (JSON object) +pub fn serialize_hashmap_as_sorted_object(hashmap: &HashMap, serializer: S) -> Result +where + K: Serialize + Eq + Hash + Ord, + V: Serialize, + S: Serializer, + H: BuildHasher, +{ + use serde::ser::SerializeMap; + + let mut entries: Vec<_> = hashmap.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + + let mut map = serializer.serialize_map(Some(entries.len()))?; + for (key, value) in entries { + map.serialize_entry(key, value)?; + } + map.end() +} + +/// Serializes as sorted `[value, ...]` (JSON array) +pub fn serialize_hashset(set: &HashSet, serializer: S) -> Result +where + T: Serialize + Eq + Hash + Ord, + S: Serializer, + H: BuildHasher, +{ + let mut entries: Vec<_> = set.iter().collect(); + entries.sort(); + + let mut seq = serializer.serialize_seq(Some(entries.len()))?; + for value in entries { + seq.serialize_element(value)?; + } + seq.end() +} + pub fn deserialize_hashmap<'de, K, V, D, H>(deserializer: D) -> Result, D::Error> where K: Deserialize<'de> + Eq + Hash, diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index efc025f0ca..f17c1c336c 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -32,7 +32,7 @@ pub mod vector { pub use vector_types::vector::click_target; pub use vector_types::vector::misc::HandleId; pub use vector_types::vector::{PointId, RegionId, SegmentId, StrokeId}; - pub use vector_types::vector::{deserialize_hashmap, serialize_hashmap}; + pub use vector_types::vector::{deserialize_hashmap, serialize_hashmap, serialize_hashmap_as_sorted_object}; // Re-export HandleExt trait and NoHashBuilder pub use vector_types::vector::HandleExt;