Skip to content

Commit 9d876ab

Browse files
authored
Add support for opening multiple selected files from disk (#4128)
1 parent dff8ac5 commit 9d876ab

8 files changed

Lines changed: 109 additions & 33 deletions

File tree

desktop/src/app.rs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -198,21 +198,29 @@ impl App {
198198
};
199199
self.send_or_queue_web_message(bytes);
200200
}
201-
DesktopFrontendMessage::OpenFileDialog { title, filters, context } => {
201+
DesktopFrontendMessage::OpenFileDialog { title, filters, multiple, context } => {
202202
let app_event_scheduler = self.app_event_scheduler.clone();
203203
let _ = thread::spawn(move || {
204204
let mut dialog = AsyncFileDialog::new().set_title(title);
205205
for filter in filters {
206206
dialog = dialog.add_filter(filter.name, &filter.extensions);
207207
}
208208

209-
let show_dialog = async move { dialog.pick_file().await.map(|f| f.path().to_path_buf()) };
209+
let handles = if multiple {
210+
futures::executor::block_on(dialog.pick_files()).unwrap_or_default()
211+
} else {
212+
futures::executor::block_on(dialog.pick_file()).into_iter().collect()
213+
};
210214

211-
if let Some(path) = futures::executor::block_on(show_dialog)
212-
&& let Ok(content) = fs::read(&path)
213-
{
214-
let message = DesktopWrapperMessage::FileDialogResult { path, content, context };
215-
app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
215+
for handle in handles {
216+
let path = handle.path().to_path_buf();
217+
match fs::read(&path) {
218+
Ok(content) => {
219+
let message = DesktopWrapperMessage::FileDialogResult { path, content, context };
220+
app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
221+
}
222+
Err(e) => tracing::error!("Failed to read file {}: {}", path.display(), e),
223+
}
216224
}
217225
});
218226
}

desktop/wrapper/src/intercept_frontend_message.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
1414
dispatcher.respond(DesktopFrontendMessage::OpenFileDialog {
1515
title: "Open Document".to_string(),
1616
filters: vec![],
17+
multiple: true,
1718
context: OpenFileDialogContext::Open,
1819
});
1920
}
2021
FrontendMessage::TriggerImport => {
2122
dispatcher.respond(DesktopFrontendMessage::OpenFileDialog {
2223
title: "Import File".to_string(),
2324
filters: vec![],
25+
multiple: false,
2426
context: OpenFileDialogContext::Import,
2527
});
2628
}

desktop/wrapper/src/messages.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub enum DesktopFrontendMessage {
1616
OpenFileDialog {
1717
title: String,
1818
filters: Vec<FileFilter>,
19+
multiple: bool,
1920
context: OpenFileDialogContext,
2021
},
2122
SaveFileDialog {
@@ -102,6 +103,7 @@ pub struct FileFilter {
102103
pub extensions: Vec<String>,
103104
}
104105

106+
#[derive(Clone, Copy)]
105107
pub enum OpenFileDialogContext {
106108
Open,
107109
Import,

editor/src/messages/portfolio/document/utility_types/network_interface.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6442,6 +6442,7 @@ pub struct InputPersistentMetadata {
64426442
/// A general datastore than can store key value pairs of any types for any input
64436443
/// Each instance of the input node needs to store its own data, since it can lose the reference to its
64446444
/// node definition if the node signature is modified by the user. For example adding/removing/renaming an import/export of a network node.
6445+
#[serde(serialize_with = "graphene_std::vector::serialize_hashmap_as_sorted_object")]
64456446
pub input_data: HashMap<String, Value>,
64466447
// An input can override a widget, which would otherwise be automatically generated from the type
64476448
// The string is the identifier to the widget override function stored in INPUT_OVERRIDES

frontend/src/stores/portfolio.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
9494
});
9595

9696
subscriptions.subscribeFrontendMessage("TriggerOpen", async () => {
97-
const data = await upload(`image/*,.${editor.fileExtension()}`, "data");
98-
editor.openFile(data.filename, data.content);
97+
const files = await upload(`image/*,.${editor.fileExtension()}`, "data", true);
98+
files.forEach((file) => editor.openFile(file.filename, file.content));
9999
});
100100

101101
subscriptions.subscribeFrontendMessage("TriggerImport", async () => {

frontend/src/utility-functions/files.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,29 +32,44 @@ export function downloadFile(filename: string, content: Uint8Array) {
3232
export async function upload(accept: string, textOrData: "text"): Promise<UploadResult<string>>;
3333
export async function upload(accept: string, textOrData: "data"): Promise<UploadResult<Uint8Array>>;
3434
export async function upload(accept: string, textOrData: "both"): Promise<UploadResult<{ text: string; data: Uint8Array }>>;
35-
export async function upload(accept: string, textOrData: "text" | "data" | "both"): Promise<UploadResult<string | Uint8Array | { text: string; data: Uint8Array }>> {
35+
export async function upload(accept: string, textOrData: "data", multiple: true): Promise<UploadResult<Uint8Array>[]>;
36+
export async function upload(
37+
accept: string,
38+
textOrData: "text" | "data" | "both",
39+
multiple = false,
40+
): Promise<UploadResult<string | Uint8Array | { text: string; data: Uint8Array }> | UploadResult<Uint8Array>[]> {
3641
return new Promise((resolve) => {
3742
const element = document.createElement("input");
3843
element.type = "file";
3944
element.accept = accept;
45+
element.multiple = multiple;
4046

4147
element.addEventListener(
4248
"change",
4349
async () => {
44-
if (element.files?.length) {
45-
const file = element.files[0];
46-
47-
const filename = file.name;
48-
const type = file.type;
49-
const content =
50-
textOrData === "text"
51-
? await file.text()
52-
: textOrData === "data"
53-
? new Uint8Array(await file.arrayBuffer())
54-
: { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) };
55-
56-
resolve({ filename, type, content });
50+
if (!element.files?.length) return;
51+
52+
// The `multiple: true` overload constrains `textOrData` to "data", so we know each file produces a Uint8Array
53+
if (multiple) {
54+
const results = await Promise.all(
55+
Array.from(element.files).map(async (file) => ({
56+
filename: file.name,
57+
type: file.type,
58+
content: new Uint8Array(await file.arrayBuffer()),
59+
})),
60+
);
61+
resolve(results);
62+
return;
5763
}
64+
65+
const file = element.files[0];
66+
const content =
67+
textOrData === "text"
68+
? await file.text()
69+
: textOrData === "data"
70+
? new Uint8Array(await file.arrayBuffer())
71+
: { text: await file.text(), data: new Uint8Array(await file.arrayBuffer()) };
72+
resolve({ filename: file.name, type: file.type, content });
5873
},
5974
{ capture: false, once: true },
6075
);

node-graph/libraries/vector-types/src/vector/vector_modification.rs

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ use core_types::uuid::generate_uuid;
55
use dyn_any::DynAny;
66
use glam::DVec2;
77
use kurbo::{BezPath, PathEl, Point};
8+
use serde::de::{SeqAccess, Visitor};
9+
use serde::ser::SerializeSeq;
10+
use serde::{Deserialize, Deserializer, Serialize, Serializer};
811
use std::collections::{HashMap, HashSet};
12+
use std::fmt;
913
use std::hash::BuildHasher;
14+
use std::hash::Hash;
1015

1116
/// Represents a procedural change to the [`PointDomain`] in [`Vector`].
1217
#[derive(Clone, Debug, Default, PartialEq)]
1318
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1419
pub struct PointModification {
1520
add: Vec<PointId>,
21+
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
1622
remove: HashSet<PointId>,
1723
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))]
1824
delta: HashMap<PointId, DVec2>,
@@ -79,6 +85,7 @@ impl PointModification {
7985
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8086
pub struct SegmentModification {
8187
add: Vec<SegmentId>,
88+
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
8289
remove: HashSet<SegmentId>,
8390
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))]
8491
start_point: HashMap<SegmentId, PointId>,
@@ -250,6 +257,7 @@ impl SegmentModification {
250257
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
251258
pub struct RegionModification {
252259
add: Vec<RegionId>,
260+
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
253261
remove: HashSet<RegionId>,
254262
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))]
255263
segment_range: HashMap<RegionId, std::ops::RangeInclusive<SegmentId>>,
@@ -297,7 +305,9 @@ pub struct VectorModification {
297305
points: PointModification,
298306
segments: SegmentModification,
299307
regions: RegionModification,
308+
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
300309
add_g1_continuous: HashSet<[HandleId; 2]>,
310+
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
301311
remove_g1_continuous: HashSet<[HandleId; 2]>,
302312
}
303313

@@ -520,27 +530,65 @@ impl graphene_hash::CacheHash for VectorModification {
520530
}
521531
}
522532

523-
// Do we want to enforce that all serialized/deserialized hashmaps are a vec of tuples?
533+
// TODO: Do we want to enforce that all serialized/deserialized hashmaps are a vec of tuples?
524534
// TODO: Eventually remove this document upgrade code
525-
use serde::de::{SeqAccess, Visitor};
526-
use serde::ser::SerializeSeq;
527-
use serde::{Deserialize, Deserializer, Serialize, Serializer};
528-
use std::fmt;
529-
use std::hash::Hash;
535+
/// Serializes as sorted `[[key, value], ...]` (sequence of pairs)
530536
pub fn serialize_hashmap<K, V, S, H>(hashmap: &HashMap<K, V, H>, serializer: S) -> Result<S::Ok, S::Error>
531537
where
532-
K: Serialize + Eq + Hash,
538+
K: Serialize + Eq + Hash + Ord,
533539
V: Serialize,
534540
S: Serializer,
535541
H: BuildHasher,
536542
{
537-
let mut seq = serializer.serialize_seq(Some(hashmap.len()))?;
538-
for (key, value) in hashmap {
543+
// Sort entries by key so the serialized output is deterministic across runs (HashMap iteration order is randomized).
544+
// Removes a major source of churn in saved-document diffs without affecting load behavior.
545+
let mut entries: Vec<_> = hashmap.iter().collect();
546+
entries.sort_by(|a, b| a.0.cmp(b.0));
547+
548+
let mut seq = serializer.serialize_seq(Some(entries.len()))?;
549+
for (key, value) in entries {
539550
seq.serialize_element(&(key, value))?;
540551
}
541552
seq.end()
542553
}
543554

555+
/// Serializes as sorted `{"key": value, ...}` (JSON object)
556+
pub fn serialize_hashmap_as_sorted_object<K, V, S, H>(hashmap: &HashMap<K, V, H>, serializer: S) -> Result<S::Ok, S::Error>
557+
where
558+
K: Serialize + Eq + Hash + Ord,
559+
V: Serialize,
560+
S: Serializer,
561+
H: BuildHasher,
562+
{
563+
use serde::ser::SerializeMap;
564+
565+
let mut entries: Vec<_> = hashmap.iter().collect();
566+
entries.sort_by(|a, b| a.0.cmp(b.0));
567+
568+
let mut map = serializer.serialize_map(Some(entries.len()))?;
569+
for (key, value) in entries {
570+
map.serialize_entry(key, value)?;
571+
}
572+
map.end()
573+
}
574+
575+
/// Serializes as sorted `[value, ...]` (JSON array)
576+
pub fn serialize_hashset<T, S, H>(set: &HashSet<T, H>, serializer: S) -> Result<S::Ok, S::Error>
577+
where
578+
T: Serialize + Eq + Hash + Ord,
579+
S: Serializer,
580+
H: BuildHasher,
581+
{
582+
let mut entries: Vec<_> = set.iter().collect();
583+
entries.sort();
584+
585+
let mut seq = serializer.serialize_seq(Some(entries.len()))?;
586+
for value in entries {
587+
seq.serialize_element(value)?;
588+
}
589+
seq.end()
590+
}
591+
544592
pub fn deserialize_hashmap<'de, K, V, D, H>(deserializer: D) -> Result<HashMap<K, V, H>, D::Error>
545593
where
546594
K: Deserialize<'de> + Eq + Hash,

node-graph/nodes/gstd/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ pub mod vector {
3232
pub use vector_types::vector::click_target;
3333
pub use vector_types::vector::misc::HandleId;
3434
pub use vector_types::vector::{PointId, RegionId, SegmentId, StrokeId};
35-
pub use vector_types::vector::{deserialize_hashmap, serialize_hashmap};
35+
pub use vector_types::vector::{deserialize_hashmap, serialize_hashmap, serialize_hashmap_as_sorted_object};
3636

3737
// Re-export HandleExt trait and NoHashBuilder
3838
pub use vector_types::vector::HandleExt;

0 commit comments

Comments
 (0)