diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a4204..eb585551 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -223,12 +223,25 @@ fn picked(pick: impl Future> + 'static) -> Dial Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) } +/// Native Save as filters; other callers keep their existing combined filter. +fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> { + if exts == ["figcraft", "fig"] { + vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])] + } else { + vec![("File", exts)] + } +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { picked(dialog(title).add_filter("Supported files", exts).pick_file()) } fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { - picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file()) + let mut dialog = dialog(title).set_file_name(name); + for (label, extensions) in save_filters(exts) { + dialog = dialog.add_filter(label, extensions); + } + picked(dialog.save_file()) } fn pick_folder(&mut self, title: &str) -> DialogFuture { picked(dialog(title).pick_folder()) @@ -297,3 +310,24 @@ pub fn window_icon() -> Option { let (w, h) = img.dimensions(); Some(egui::IconData { rgba: img.into_raw(), width: w, height: h }) } + +#[cfg(test)] +mod save_filter_tests { + use super::save_filters; + + #[test] + fn native_and_fig_save_filters_are_separate_with_native_first() { + let extensions = ["figcraft", "fig"]; + assert_eq!( + save_filters(&extensions), + vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])] + ); + } + + #[test] + fn other_save_filters_keep_one_combined_group() { + for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] { + assert_eq!(save_filters(extensions), vec![("File", extensions)]); + } + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 54348372..9134bc71 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -41,8 +41,20 @@ pub fn specs() -> Vec { false, open_recent ), - spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save), - spec!("file.saveAs", "Save as", "{path}", false, save_as), + spec!( + "file.save", + "Save", + "{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + false, + save + ), + spec!( + "file.saveAs", + "Save as", + "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + false, + save_as + ), spec!( "file.export", "Export", @@ -775,8 +787,20 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { Ok(json!({"profile": profile.id(), "changed": changed})) } -/// Save the file of the active tab (the main file for a branch tab, with its branches). +/// Native saves act on the current document; a `.fig` copy exports the version shown, and a +/// pending Save As choice must leave it shown until its destination is known. +pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { + let path = + string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); + path.is_some_and(|path| ext(&path) != "fig") +} + +/// Save the file of the active tab (the main file for a branch tab, with its branches), or export +/// the active document as an experimental `.fig` copy without changing its native save state. fn save_to(s: &mut Session, path: String) -> Result { + if ext(&path) == "fig" { + return export_fig(s, &json!({"path": path})); + } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let bytes = file_bytes(ds)?; @@ -795,6 +819,11 @@ fn save(s: &mut Session, p: &Value) -> Result { return save_to(s, path); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { + // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. + Some(path) if ext(&path) == "fig" => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) + } Some(path) => save_to(s, path), None => { s.request_ui("dialog.saveAs", json!({})); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..cba3577e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -390,7 +390,8 @@ pub struct VersionPreview { /// Why edits are refused while a version is shown. pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit"; -/// Commands that first end a version preview: they act on the document itself. +/// Commands that first end a version preview: they act on the document itself. Saving does so +/// only with a native destination; export copies and pending format choices keep the preview. const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -870,6 +871,7 @@ impl Session { // Every other command acts on the document itself, not on a live text preview. textpreview::end(self); if ENDS_PREVIEW.contains(&id) + && (!matches!(id, "file.save" | "file.saveAs") || cmd::file::save_ends_preview(self, id, params)) && let Ok(ds) = self.ds_mut() { ds.end_preview(); @@ -1642,6 +1644,8 @@ mod rotated_select_tests; #[cfg(test)] mod rotation_origin_tests; #[cfg(test)] +mod save_format_tests; +#[cfg(test)] mod scale_tool_tests; #[cfg(test)] mod section_contents_tests; diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs new file mode 100644 index 00000000..4473ef7c --- /dev/null +++ b/crates/engine/src/save_format_tests.rs @@ -0,0 +1,366 @@ +//! Save As can write an experimental `.fig` copy without replacing native save state. + +use std::path::PathBuf; +use std::sync::Arc; + +use figcraft_format::Version; +use serde_json::{Value, json}; + +use crate::{DocState, EngineError, Session}; + +struct SaveFiles { + dir: PathBuf, + files: Vec, +} + +impl SaveFiles { + fn new() -> Self { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + Self { dir, files: Vec::new() } + } + + fn path(&mut self, name: &str) -> PathBuf { + let path = self.dir.join(name); + self.files.push(path.clone()); + path + } +} + +impl Drop for SaveFiles { + fn drop(&mut self) { + for path in &self.files { + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(path); + } + let _ = std::fs::remove_dir(&self.dir); + } +} + +fn rect(s: &mut Session, name: &str) { + s.execute("shape.rectangle", &json!({"name": name, "x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); +} + +fn assert_versions_unchanged(after: &[Version], before: &[Version]) { + assert_eq!(after.len(), before.len()); + for (after, before) in after.iter().zip(before) { + assert_eq!( + (&after.id, &after.name, &after.description, after.time, after.auto), + (&before.id, &before.name, &before.description, before.time, before.auto) + ); + assert_eq!(*after.document().unwrap(), *before.document().unwrap()); + } +} + +fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]) { + let after = s.ds().unwrap(); + assert_eq!(after.path, before.path); + assert_eq!(after.is_dirty(), before.is_dirty()); + assert_eq!(after.versions_changed, before.versions_changed); + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + assert!(Arc::ptr_eq(&after.saved_doc, &before.saved_doc)); + assert_eq!( + after.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)), + before.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)) + ); + if let (Some(after), Some(before)) = (&after.preview, &before.preview) { + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + } + assert_eq!((&after.selection, after.page), (&before.selection, before.page)); + assert_versions_unchanged(&after.versions, &before.versions); + assert_eq!(after.branches.len(), before.branches.len()); + for (after, before) in after.branches.iter().zip(&before.branches) { + assert_eq!( + (&after.id, &after.name, after.time, after.client, after.state), + (&before.id, &before.name, before.time, before.client, before.state) + ); + assert_eq!(*after.base, *before.base); + assert_eq!(*after.head, *before.head); + assert_versions_unchanged(&after.versions, &before.versions); + } + for (after, before) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] { + assert_eq!(after.len(), before.len()); + for (after, before) in after.iter().zip(before) { + assert_eq!((&after.label, &after.selection, after.page), (&before.label, &before.selection, before.page)); + assert!(Arc::ptr_eq(&after.doc, &before.doc)); + } + } + assert_eq!(s.prefs.recent_files, recent); +} + +fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document { + let bytes = std::fs::read(path).unwrap(); + assert_eq!(result["format"], "fig"); + assert_eq!(result["bytes"], bytes.len()); + assert!(result["warnings"].is_array()); + assert!(figcraft_format::load_bytes(&bytes).is_err(), "a .fig copy must not contain native FigCraft data"); + figcraft_format::fig::import(&bytes, "Copy").unwrap().0 +} + +#[test] +fn fig_save_as_keeps_native_path_dirty_state_and_history() { + let mut files = SaveFiles::new(); + let native = files.path("design.figcraft"); + let copy = files.path("copy.fig"); + let mut s = Session::new(); + rect(&mut s, "Saved rectangle"); + s.execute("file.saveAs", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + rect(&mut s, "New rectangle"); + s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Copy warning"})).unwrap(); + s.execute("file.saveVersion", &json!({"name": "Before copy"})).unwrap(); + rect(&mut s, "Undone rectangle"); + s.undo().unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(before.is_dirty()); + assert!(!before.history.redo.is_empty()); + + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert!(imported.nodes().any(|n| n.name == "New rectangle")); + assert!(!imported.nodes().any(|n| n.name == "Undone rectangle")); + assert!(result["warnings"].as_array().unwrap().iter().any(|w| w == "comments were left out")); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&native).unwrap(), saved); + + s.execute("file.save", &json!({})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(persisted.doc.nodes().any(|n| n.name == "New rectangle")); + assert_eq!(persisted.versions[0].name, "Before copy"); + assert_eq!(std::fs::read(©).unwrap().len(), result["bytes"].as_u64().unwrap() as usize); +} + +#[test] +fn unsaved_fig_copy_remains_unsaved() { + let mut files = SaveFiles::new(); + let copy = files.path("unsaved.fig"); + let mut s = Session::new(); + rect(&mut s, "Unsaved rectangle"); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == "Unsaved rectangle")); + assert_save_state_unchanged(&s, &before, &recent); + assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); +} + +#[test] +fn empty_document_fig_copy_keeps_clean_state() { + let mut files = SaveFiles::new(); + let copy = files.path("empty.fig"); + let mut s = Session::new(); + let before = s.ds().unwrap().clone(); + assert!(!before.is_dirty()); + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert_eq!(imported.pages.len(), 1); + assert_eq!(imported.node_count(), 1); + assert_save_state_unchanged(&s, &before, &[]); +} + +#[test] +fn fig_save_commands_accept_uppercase_extension() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let copy = files.path(&format!("copy-{index}.FIG")); + let mut s = Session::new(); + rect(&mut s, command); + let before = s.ds().unwrap().clone(); + let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == command)); + assert_save_state_unchanged(&s, &before, &[]); + } +} + +#[test] +fn fig_copy_from_branch_exports_active_document_without_saving_main() { + let mut files = SaveFiles::new(); + let native = files.path("main.figcraft"); + let copy = files.path("branch.fig"); + let mut s = Session::new(); + rect(&mut s, "Main rectangle"); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + s.execute("branch.create", &json!({"name": "Explore"})).unwrap(); + rect(&mut s, "Branch rectangle"); + s.execute("file.saveVersion", &json!({"name": "Branch version"})).unwrap(); + // Branch edits normally sync to main before commands; capture that state before exporting. + s.execute("branch.list", &json!({})).unwrap(); + let branch = s.ds().unwrap().clone(); + let main = s.docs[0].clone(); + let recent = s.prefs.recent_files.clone(); + + let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(read_fig(©, &result).nodes().any(|n| n.name == "Branch rectangle")); + assert_save_state_unchanged(&s, &branch, &recent); + assert_eq!(s.docs[0].path, main.path); + assert_eq!(s.docs[0].is_dirty(), main.is_dirty()); + assert!(Arc::ptr_eq(&s.docs[0].saved_doc, &main.saved_doc)); + assert!(Arc::ptr_eq(&s.docs[0].doc, &main.doc)); + assert_eq!(std::fs::read(&native).unwrap(), saved); + + s.execute("file.save", &json!({})).unwrap(); + let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle")); + assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle")); + assert_eq!(persisted.branches[0].versions[0].name, "Branch version"); + assert!(!s.ds().unwrap().is_dirty()); + assert!(!s.docs[0].is_dirty()); +} + +#[test] +fn fig_copy_write_failure_keeps_save_state() { + let mut files = SaveFiles::new(); + let blocked = files.path("blocked.fig"); + std::fs::create_dir(&blocked).unwrap(); + let missing = files.path("missing/copy.fig"); + let mut s = Session::new(); + rect(&mut s, "Kept rectangle"); + s.execute("file.saveVersion", &json!({"name": "Kept version"})).unwrap(); + rect(&mut s, "Later rectangle"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + for command in ["file.save", "file.saveAs"] { + for path in [&blocked, &missing] { + assert!(s.execute(command, &json!({"path": path.to_string_lossy()})).is_err()); + assert_save_state_unchanged(&s, &before, &[]); + assert!(!PathBuf::from(format!("{}.tmp-figcraft", path.display())).exists()); + } + } +} + +#[test] +fn save_as_without_extension_stays_native() { + let mut files = SaveFiles::new(); + let requested = files.dir.join("native"); + let native = files.path("native.figcraft"); + let mut s = Session::new(); + rect(&mut s, "Native rectangle"); + let result = s.execute("file.saveAs", &json!({"path": requested.to_string_lossy()})).unwrap(); + assert_eq!(result["path"], native.to_string_lossy().as_ref()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + assert!(!s.ds().unwrap().is_dirty()); + assert!(!requested.exists()); + let loaded = figcraft_format::load_bytes(&std::fs::read(native).unwrap()).unwrap(); + assert!(loaded.0.nodes().any(|n| n.name == "Native rectangle")); +} + +#[test] +fn fig_copy_of_version_preview_keeps_visible_version_and_save_state() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let native = files.path(&format!("current-{index}.figcraft")); + let copy = files.path(&format!("version-{index}.FIG")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + let saved = std::fs::read(&native).unwrap(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(before.preview.is_some()); + assert!(before.is_dirty()); + + let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap(); + let imported = read_fig(©, &result); + assert!(imported.nodes().any(|n| n.name == "A")); + assert!(!imported.nodes().any(|n| n.name == "B"), "the copy must export the version shown"); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&native).unwrap(), saved); + s.execute("version.exitPreview", &json!({})).unwrap(); + assert!(s.doc().unwrap().nodes().any(|n| n.name == "B")); + } +} + +#[test] +fn pending_save_choice_keeps_version_preview() { + let mut files = SaveFiles::new(); + for (index, (command, native_path)) in [("file.saveAs", false), ("file.saveAs", true), ("file.save", false)].into_iter().enumerate() { + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + if native_path { + let native = files.path(&format!("saved-{index}.figcraft")); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + } + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + assert!(matches!(s.execute(command, &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); + assert_save_state_unchanged(&s, &before, &recent); + } +} + +#[test] +fn native_save_from_version_preview_writes_current_document() { + let mut files = SaveFiles::new(); + for (index, (command, explicit)) in [("file.save", false), ("file.save", true), ("file.saveAs", true)].into_iter().enumerate() { + let native = files.path(&format!("native-version-{index}.figcraft")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let undo = s.ds().unwrap().history.undo.len(); + let params = if explicit { json!({"path": native.to_string_lossy()}) } else { json!({}) }; + s.execute(command, ¶ms).unwrap(); + assert!(s.ds().unwrap().preview.is_none()); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.doc.nodes().any(|n| n.name == "A")); + assert!(loaded.doc.nodes().any(|n| n.name == "B")); + assert!(!loaded.versions[0].document().unwrap().nodes().any(|n| n.name == "B")); + } +} + +#[test] +fn implicit_save_protects_legacy_fig_destination() { + let mut files = SaveFiles::new(); + for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() { + let legacy = files.path(&format!("legacy-{index}.FIG")); + let native = files.path(&format!("corrected-{index}.figcraft")); + let mut s = Session::new(); + rect(&mut s, "A"); + s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap(); + // Earlier builds could write native data under .fig; keep those bytes safe on plain Save. + let sentinel = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap(); + std::fs::write(&legacy, &sentinel).unwrap(); + s.ds_mut().unwrap().path = Some(legacy.to_string_lossy().into_owned()); + s.ds_mut().unwrap().mark_saved(); + rect(&mut s, "B"); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let before = s.ds().unwrap().clone(); + let recent = s.prefs.recent_files.clone(); + + assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..)))); + assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs"); + assert_save_state_unchanged(&s, &before, &recent); + assert_eq!(std::fs::read(&legacy).unwrap(), sentinel); + + s.execute(command, &json!({"path": native.to_string_lossy()})).unwrap(); + assert!(s.ds().unwrap().preview.is_none()); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str()); + let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.doc.nodes().any(|n| n.name == "A")); + assert!(loaded.doc.nodes().any(|n| n.name == "B")); + assert_eq!(loaded.versions[0].name, "A only"); + rect(&mut s, "C"); + s.execute("file.save", &json!({})).unwrap(); + let loaded = figcraft_format::load_bytes(&std::fs::read(&native).unwrap()).unwrap(); + assert!(loaded.0.nodes().any(|n| n.name == "C")); + assert_eq!(std::fs::read(&legacy).unwrap(), sentinel); + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 49fd272f..511d329e 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -250,8 +250,8 @@ pub fn tool_definitions() -> Vec { tool( "save_file", "Save file", - "Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it.", - obj(json!({"path": string("Output .figcraft path")}), &[]), + "Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it. A `path` ending in .fig writes an experimental .fig export copy instead and leaves the document's own path and unsaved changes alone.", + obj(json!({"path": string("Output .figcraft path, or a .fig path for an experimental export copy")}), &[]), false, ), tool("undo", "Undo", "Undo the last change (one command, batch or gesture). Returns {undone: bool}.", empty(), false), diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..1ecee425 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Eine .figcraft-Datei mit diesem Namen existiert bereits. Wählen Sie ihren vollständigen Namen, um das Ersetzen zu bestätigen." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer" "About" = "Über" @@ -1347,6 +1348,7 @@ status = "draft — needs native review" "Use as mask" = "Als Maske verwenden" "Use auto layout" = "Auto-Layout verwenden" "Use number keys for opacity" = "Zifferntasten für Deckkraft verwenden" +"Use the .figcraft or .fig extension." = "Verwenden Sie die Dateiendung .figcraft oder .fig." "Use the layer's fill" = "Füllung der Ebene verwenden" "Used in {files} · {instances}" = "Verwendet in {files} · {instances}" "Username" = "Benutzername" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..fbae012c 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -16,6 +16,7 @@ name = "English" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "A .figcraft file with this name already exists. Choose its full name to confirm replacement." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame" "A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer" "A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long" @@ -1413,6 +1414,7 @@ name = "English" "Use as mask" = "Use as mask" "Use auto layout" = "Use auto layout" "Use number keys for opacity" = "Use number keys for opacity" +"Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension." "Use the layer's fill" = "Use the layer's fill" "Used in {files} · {instances}" = "Used in {files} · {instances}" "Username" = "Username" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..df66fdc5 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -9,6 +9,7 @@ status = "draft — needs native review" [ui] "Couldn't download the variables" = "No se pudieron descargar las variables" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Ya existe un archivo .figcraft con este nombre. Elige su nombre completo para confirmar que deseas reemplazarlo." "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" @@ -1414,6 +1415,7 @@ status = "draft — needs native review" "Use as mask" = "Usar como máscara" "Use auto layout" = "Usar diseño automático" "Use number keys for opacity" = "Usar teclas numéricas para la opacidad" +"Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig." "Use the layer's fill" = "Usar el relleno de la capa" "Used in {files} · {instances}" = "Usado en {files} · {instances}" "Username" = "Usuario" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 236f8e0d..172d59c9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,11 +1,13 @@ //! Dialogs: About, Preferences, keyboard shortcuts, find & replace, rename layers, variables, //! export. +use std::path::Path; + use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2}; use figcraft_color::ColorProfile; use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId}; use figcraft_engine::cmd::file::{export_jobs, exportable_layers}; -use serde_json::json; +use serde_json::{Value, json}; use crate::FigApp; use crate::export_panel::{export_layers, save_one, suggested_name}; @@ -505,17 +507,35 @@ fn color_profile_dialog(app: &mut FigApp, ctx: &Context) { } } +/// A `.fig` Save as result is an export; only native saves report that the document was saved. +pub(crate) fn file_saved(app: &mut FigApp, result: &Value) { + if result["format"] == "fig" { + fig_exported(app, result); + } else { + app.toast(trf("Saved {name}", &[("name", &result_file_name(result))])); + } +} + +fn result_file_name(result: &Value) -> String { + result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default() +} + +/// The `.fig` result stays an export: native save state is unchanged, and omissions are reported. +pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) { + let file = result_file_name(result); + let left_out = result["warnings"].as_array().map_or(0, Vec::len); + app.toast(match left_out { + 0 => trf("Exported {name}", &[("name", &file)]), + n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), + }); +} + /// Save the whole file as `.fig`; the toast says how much was left out. fn save_fig(app: &mut FigApp, name: &str) { let filename = format!("{name}.fig"); app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { - let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { - let left_out = r["warnings"].as_array().map_or(0, Vec::len); - app.toast(match left_out { - 0 => trf("Exported {name}", &[("name", &file)]), - n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), - }); + fig_exported(app, &r); } }); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..cbd226a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -162,6 +162,8 @@ mod proto_reorder_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] +mod save_as_tests; +#[cfg(test)] mod select_all_tests; #[cfg(test)] mod shortcut_keys_tests; @@ -938,11 +940,31 @@ impl FigApp { } pub fn save_as_dialog(&mut self) { - let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); - self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"], |app, p| { - let ps = p.to_string_lossy().to_string(); - if app.run("file.saveAs", json!({"path": ps})).is_some() { - app.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return }; + self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| { + if p.extension().is_none_or(|e| e.is_empty()) { + if !p.set_extension("figcraft") { + app.toast(i18n::tr("Use the .figcraft or .fig extension.")); + return; + } + // This name wasn't confirmed by the system dialog: don't replace an existing file. + match p.try_exists() { + Ok(false) => {} + Ok(true) => { + app.toast(i18n::tr("A .figcraft file with this name already exists. Choose its full name to confirm replacement.")); + return; + } + Err(e) => { + app.toast(e.to_string()); + return; + } + } + } else if !p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) { + app.toast(i18n::tr("Use the .figcraft or .fig extension.")); + return; + } + if let Some(r) = app.run("file.saveAs", json!({"path": p.to_string_lossy()})) { + dialogs::file_saved(app, &r); } }); } @@ -950,7 +972,14 @@ impl FigApp { pub fn save(&mut self) { // A branch tab saves its main file. let tab = figcraft_engine::branches::file_tab(&self.session); - if self.session.docs.get(tab).is_some_and(|d| d.path.is_some()) { + // A path ending in .fig is never saved over silently: a .fig destination is an export copy. + let native_path = self + .session + .docs + .get(tab) + .and_then(|d| d.path.as_deref()) + .is_some_and(|path| !Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("fig"))); + if native_path { if self.run("file.save", json!({})).is_some() { self.toast(i18n::tr("Saved")); } diff --git a/crates/ui-egui/src/save_as_tests.rs b/crates/ui-egui/src/save_as_tests.rs new file mode 100644 index 00000000..8817ab1c --- /dev/null +++ b/crates/ui-egui/src/save_as_tests.rs @@ -0,0 +1,320 @@ +//! Native Save as accepts both file formats; .fig copies preserve native save state. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use figcraft_engine::Session; +use serde_json::json; + +use crate::dialogs::file_saved; +use crate::{DialogFuture, FigApp, Services}; + +type Picks = Arc)>>>; + +struct Picker { + path: Option, + picks: Picks, +} + +impl Services for Picker { + fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { + self.picks.lock().unwrap().push((title.into(), name.into(), exts.iter().map(|e| (*e).into()).collect())); + Box::pin(std::future::ready(self.path.take())) + } +} + +struct Temp(PathBuf); + +impl Temp { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("figcraft-save-as-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for Temp { + fn drop(&mut self) { + if let Ok(files) = std::fs::read_dir(&self.0) { + for file in files.flatten() { + let _ = std::fs::remove_file(file.path()); + } + } + let _ = std::fs::remove_dir(&self.0); + } +} + +fn session() -> Session { + let mut s = Session::new(); + s.doc_mut().unwrap().name = "Prototype".into(); + s.execute("shape.rectangle", &json!({"width": 100, "height": 80})).unwrap(); + s +} + +fn picker(path: Option) -> (Picker, Picks) { + let picks = Picks::default(); + (Picker { path, picks: picks.clone() }, picks) +} + +fn app(path: Option) -> (FigApp, Picks) { + let (picker, picks) = picker(path); + (FigApp::new(session(), Box::new(picker)), picks) +} + +fn toast(app: &FigApp) -> &str { + &app.ui.toast.as_ref().unwrap().0 +} + +fn expected_picker() -> Vec<(String, String, Vec)> { + vec![("Save as".into(), "Prototype.figcraft".into(), vec!["figcraft".into(), "fig".into()])] +} + +#[test] +fn save_as_menu_and_first_save_open_the_platform_picker_directly_with_both_extensions() { + for action in ["direct", "menu", "first"] { + let (mut app, picks) = app(None); + let before = app.session.doc().unwrap().clone(); + match action { + "direct" => app.save_as_dialog(), + "menu" => crate::menus::invoke(&mut app, "file.saveAs"), + "first" => app.save(), + _ => panic!("unknown save action {action}"), + } + app.poll_dialog(&egui::Context::default()); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert_eq!(app.session.doc().unwrap(), &before); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); + assert!(app.ui.toast.is_none()); + } +} + +#[test] +fn a_pathless_engine_request_uses_the_same_platform_picker() { + let (mut app, picks) = app(None); + let before = app.session.doc().unwrap().clone(); + assert!(app.run("file.saveAs", json!({})).is_none(), "the engine requests the UI and refuses a pathless programmatic save"); + app.poll_dialog(&egui::Context::default()); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert_eq!(app.session.doc().unwrap(), &before); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn native_paths_and_an_extensionless_default_save_native_files_and_show_the_written_name() { + let temp = Temp::new(); + for (chosen, written) in [("native", "native.figcraft"), ("saved.figcraft", "saved.figcraft"), ("upper.FIGCRAFT", "upper.FIGCRAFT")] { + let (mut app, picks) = app(Some(temp.path(chosen))); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + let path = temp.path(written); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert!(path.is_file()); + assert_eq!(app.session.ds().unwrap().path.as_deref(), path.to_str()); + assert!(!app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), format!("Saved {written}")); + let mut reopened = Session::empty(); + reopened.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(reopened.doc().unwrap().nodes().count(), app.session.doc().unwrap().nodes().count()); + assert_eq!(reopened.doc().unwrap().name, "Prototype"); + } +} + +#[test] +fn returned_fig_paths_export_real_copies_without_changing_native_save_state() { + let temp = Temp::new(); + for name in ["copy.fig", "upper.FIG"] { + let path = temp.path(name); + let (mut app, picks) = app(Some(path.clone())); + let native = temp.path("working.figcraft").to_string_lossy().into_owned(); + app.session.ds_mut().unwrap().path = Some(native.clone()); + let before = app.session.doc().unwrap().clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert!(path.is_file()); + assert_eq!(app.session.doc().unwrap(), &before); + assert_eq!(app.session.ds().unwrap().path.as_deref(), Some(native.as_str())); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), format!("Exported {name}")); + let mut reopened = Session::empty(); + reopened.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(reopened.doc().unwrap().nodes().count(), before.nodes().count()); + } +} + +#[test] +fn a_fig_copy_reports_actual_engine_warnings_and_keeps_the_original_content() { + let temp = Temp::new(); + let (mut app, _) = app(Some(temp.path("comments.fig"))); + app.run("comment.add", json!({"x": 10, "y": 20, "text": "Synthetic comment"})).unwrap(); + let before = app.session.doc().unwrap().clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert!(temp.path("comments.fig").is_file()); + assert_eq!(toast(&app), "Exported comments.fig; 1 kind of content was left out"); + assert_eq!(app.session.doc().unwrap(), &before); + assert_eq!(app.session.doc().unwrap().comments.len(), 1); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn cancelling_the_platform_dialog_preserves_document_selection_path_and_dirty_state() { + let (mut app, picks) = app(None); + app.session.ds_mut().unwrap().path = Some("working.figcraft".into()); + let before = app.session.doc().unwrap().clone(); + let selection = app.session.ds().unwrap().selection.clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert_eq!(app.session.doc().unwrap(), &before); + assert_eq!(app.session.ds().unwrap().selection, selection); + assert_eq!(app.session.ds().unwrap().path.as_deref(), Some("working.figcraft")); + assert!(app.session.ds().unwrap().is_dirty()); + assert!(app.ui.toast.is_none()); +} + +#[test] +fn unsupported_extensions_are_rejected_without_overwriting_the_chosen_file() { + let temp = Temp::new(); + for name in ["wrong.svg", "wrong.png"] { + let path = temp.path(name); + std::fs::write(&path, b"existing document").unwrap(); + let (mut app, _) = app(Some(path.clone())); + let before = app.session.doc().unwrap().clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(std::fs::read(&path).unwrap(), b"existing document"); + assert_eq!(app.session.doc().unwrap(), &before); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "Use the .figcraft or .fig extension."); + } +} + +#[test] +fn adding_the_default_extension_never_overwrites_another_existing_path_without_confirmation() { + let temp = Temp::new(); + let path = temp.path("existing.figcraft"); + std::fs::write(&path, b"existing document").unwrap(); + let (mut app, _) = app(Some(temp.path("existing"))); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(std::fs::read(&path).unwrap(), b"existing document"); + assert!(!temp.path("existing").exists()); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "A .figcraft file with this name already exists. Choose its full name to confirm replacement."); +} + +#[test] +fn fig_save_results_report_the_actual_name_and_plural_warning_count_without_marking_native_state_saved() { + let (mut app, _) = app(None); + let before = app.session.doc().unwrap().clone(); + app.session.ds_mut().unwrap().path = Some("working.figcraft".into()); + file_saved(&mut app, &json!({"format": "fig", "path": "written.fig", "warnings": ["synthetic warning", "another synthetic warning"]})); + assert_eq!(toast(&app), "Exported written.fig; 2 kinds of content were left out"); + assert_eq!(app.session.doc().unwrap(), &before); + assert_eq!(app.session.ds().unwrap().path.as_deref(), Some("working.figcraft")); + assert!(app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn save_with_a_native_path_keeps_using_it_without_a_dialog() { + let temp = Temp::new(); + let (mut app, picks) = app(None); + let path = temp.path("working.figcraft"); + app.run("file.saveAs", json!({"path": path})).unwrap(); + app.run("shape.rectangle", json!({"width": 10, "height": 20})).unwrap(); + assert!(app.session.ds().unwrap().is_dirty()); + app.save(); + assert!(picks.lock().unwrap().is_empty()); + assert_eq!(app.session.ds().unwrap().path.as_deref(), path.to_str()); + assert!(!app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "Saved"); +} + +#[test] +fn saving_legacy_fig_paths_keeps_the_success_toast_and_original_source_bytes() { + for legacy_name in ["legacy.fig", "legacy.FIG"] { + for destination in ["corrected.figcraft", "copy.fig"] { + let temp = Temp::new(); + let target = temp.path(destination); + let legacy = temp.path(legacy_name); + let (mut app, picks) = app(Some(target.clone())); + let original = temp.path("original.figcraft"); + app.run("file.saveAs", json!({"path": original})).unwrap(); + std::fs::rename(&original, &legacy).unwrap(); + let legacy_path = legacy.to_string_lossy().into_owned(); + app.session.ds_mut().unwrap().path = Some(legacy_path.clone()); + app.run("shape.rectangle", json!({"width": 10, "height": 20})).unwrap(); + let before = app.session.doc().unwrap().clone(); + let legacy_bytes = std::fs::read(&legacy).unwrap(); + app.save(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(*picks.lock().unwrap(), expected_picker()); + assert_eq!(std::fs::read(&legacy).unwrap(), legacy_bytes); + assert!(target.is_file()); + assert!(app.ui.last_error.is_none()); + assert_eq!(app.session.doc().unwrap(), &before); + if destination == "corrected.figcraft" { + assert_eq!(toast(&app), "Saved corrected.figcraft"); + assert_eq!(app.session.ds().unwrap().path.as_deref(), target.to_str()); + assert!(!app.session.ds().unwrap().is_dirty()); + } else { + assert_eq!(toast(&app), "Exported copy.fig"); + assert_eq!(app.session.ds().unwrap().path.as_deref(), Some(legacy_path.as_str())); + assert!(app.session.ds().unwrap().is_dirty()); + } + } + } +} + +#[test] +fn failed_writes_surface_the_engine_error_and_keep_the_document_dirty() { + let temp = Temp::new(); + for extension in ["figcraft", "fig"] { + let path = temp.path(&format!("missing/copy.{extension}")); + let (mut app, _) = app(Some(path.clone())); + let before = app.session.doc().unwrap().clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert!(!path.exists()); + assert_eq!(app.session.doc().unwrap(), &before); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(app.ui.last_error.as_deref(), Some(toast(&app))); + assert!(!toast(&app).starts_with("Saved") && !toast(&app).starts_with("Exported")); + } +} + +#[test] +fn an_empty_destination_is_rejected_without_writing_or_changing_save_state() { + let (mut app, _) = app(Some(PathBuf::new())); + let before = app.session.doc().unwrap().clone(); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.doc().unwrap(), &before); + assert!(app.session.ds().unwrap().path.is_none()); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "Use the .figcraft or .fig extension."); +} + +#[test] +fn save_as_with_no_document_never_opens_a_dialog() { + let (picker, picks) = picker(None); + let mut app = FigApp::new(Session::empty(), Box::new(picker)); + app.save_as_dialog(); + app.poll_dialog(&egui::Context::default()); + assert!(picks.lock().unwrap().is_empty()); + assert!(app.ui.toast.is_none()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..c25e33ea 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -60,6 +60,13 @@ export limit, not a native document nesting limit. ### Export to `.fig` (experimental) +File ▸ Save as offers `.figcraft` and `.fig` as separate file types in the system dialog. A `.fig` +destination writes the same experimental export copy of the document that is shown (`file.save` and +`file.saveAs` with a path ending in `.fig` do the same) and reports what was left out. The file's own +path, its unsaved changes, versions, branches and undo history are not touched, and a shown earlier +version stays shown. Other apps may not open every exported `.fig` file. A plain Save never writes over +a file whose saved path ends in `.fig`; it asks for a destination instead. + `file.export {format: "fig"}` (or a `path` ending in `.fig`), File ▸ Export… ▸ .fig (experimental), `figcraft-cli export -o .fig` (and `convert`) and MCP's `export {format: "fig"}` write the whole document. The writer (`crates/format/src/fig/export.rs`) works from the same public format