diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 54348372..195d7308 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`", + "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`", false, open ), @@ -653,8 +653,13 @@ fn open_recent(s: &mut Session, p: &Value) -> Result { } fn open(s: &mut Session, p: &Value) -> Result { - let path = req_string("file.open", p, "path")?; let variables = variables_param("file.open", p)?; + if string(p, "path").is_none() + && let Some(d) = string(p, "data") + { + return open_data(s, &d, p, variables.as_ref()); + } + let path = req_string("file.open", p, "path")?; // Already open: switch to it. if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { s.active = i; @@ -679,6 +684,21 @@ fn open(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells +/// the format. It opens as a new document with no path, so the first save asks where to put it. +fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { + let name = req_string("file.open", p, "name")?; + let data = data.split_once(',').map_or(data, |(_, b)| b); + let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; + let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; + let warnings = rest || ext(&name) == "svg"; + s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); + s.active = s.docs.len() - 1; + let mut out = json!({"index": s.active}); + out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); + Ok(out) +} + /// Thumbnail PNG of the first page's content (for file browsers / the home screen). pub fn thumbnail(doc: &Document) -> Option> { let page = doc.first_page()?; @@ -1351,6 +1371,25 @@ mod rest_import_tests { assert!(thumbnail(&doc).is_some()); } + #[test] + fn open_takes_data_and_a_name_as_a_new_unsaved_document() { + let mut saved = Session::new(); + saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let bytes = file_bytes(saved.ds().unwrap()).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mut s = Session::new(); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap(); + let open = s.docs.len(); + assert_eq!(r["index"], open - 1, "a document of its own, made active"); + assert_eq!(s.ds().unwrap().path, None, "no path to save over"); + assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle))); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format"); + assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err()); + assert_eq!(s.docs.len(), open, "a refused open adds nothing"); + } + #[test] fn open_and_import_rest_json() { let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index 87363656..a975bec2 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -380,6 +380,43 @@ fn drop_file(u: &mut Ui, path: &std::path::Path) -> Vec { hovering } +/// A file dropped with its contents but no file on disk (a virtual file from another app). +#[derive(Debug)] +struct DroppedBytes(std::path::PathBuf, Vec); + +impl egui::DroppedFile for DroppedBytes { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + Ok(self.1.clone()) + } +} + +#[test] +fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() { + let dir = Temp::new(); + let path = dir.path("virtual.svg"); + assert!(!path.exists()); + let svg = br#""#.to_vec(); + let mut u = first_run(); + u.app.ui.home = true; + let file: egui::DroppedFileHandle = std::sync::Arc::new(DroppedBytes(path.clone(), svg)); + u.frame_raw(egui::RawInput { dropped_files: vec![file], ..Default::default() }); + assert_eq!(u.app.ui.pending_opens.len(), 1); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.ui.last_error, None); + assert_eq!(u.app.session.docs.len(), 1, "the dropped contents open"); + assert_eq!(u.app.session.docs[0].path, None, "with no save path made up for them"); + assert!(u.app.session.docs[0].doc.nodes().any(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)), "the SVG's content"); + assert!(!u.app.ui.home, "and the editor shows it"); + assert!(!path.exists(), "nothing is written"); +} + #[test] fn dropping_a_native_file_on_the_home_screen_opens_it() { let dir = Temp::new();