M7: open dropped files that have no file on disk

A home-screen drop that carries only bytes queued file.open with data and name, which file.open rejected because it requires a path. file.open now also takes base64 data with a name and opens it as a new document without a save path.
This commit is contained in:
Skyfuller1303
2026-10-16 00:22:14 +02:00
parent f948e08fbc
commit 1f6010e07c
2 changed files with 78 additions and 2 deletions
+41 -2
View File
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.open",
"Open",
"{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.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 `<name>.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<Value> {
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Vec<u8>> {
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()));
+37
View File
@@ -380,6 +380,43 @@ fn drop_file(u: &mut Ui, path: &std::path::Path) -> Vec<Shape> {
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<u8>);
impl egui::DroppedFile for DroppedBytes {
fn path(&self) -> &std::path::Path {
&self.0
}
fn bytes(&self) -> Result<Vec<u8>, 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#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#.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();