From 9a4c160f0e38dcdfe7315d032c49a7c7993b80b9 Mon Sep 17 00:00:00 2001 From: Jonas Neubauer <108294796+jonassneu@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:08:01 +0100 Subject: [PATCH] G2: distinct XML copy name and a name prompt for web Save As (#2788) --- apps/effectcraft-web/src/app.rs | 13 +++++++++++-- apps/effectcraft-web/src/store.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/menus/frontend.rs | 6 +++++- crates/ui-egui/src/menus/tests.rs | 7 +++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index d7e6e783d..984db22fc 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -213,8 +213,17 @@ fn install_hooks(app: &mut EffectcraftApp) { } None })); - // Saving downloads the file: the chosen name is all a "save dialog" needs. - app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name })))); + // Saving downloads the file: the chosen name is all a "save dialog" needs. A project (Save As, + // Save a Copy) asks for the name, starting from the open project's (#2788): without asking, + // Save As would replace the open project's file; Cancel cancels. + app.hooks.pick_save = Some(Box::new(|name: &str| { + let name = if name.is_empty() { "Untitled Project.ecproj" } else { name }; + if !std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("ecproj") || e.eq_ignore_ascii_case("ecprojx")) { + return Some(files::path_for(name)); + } + let typed = web_sys::window().and_then(|w| w.prompt_with_message_and_default("Save project as:", name).ok().flatten()); + crate::store::typed_save_name(name, typed).map(|n| files::path_for(&n)) + })); app.hooks.audio_device = Some(Box::new(audio::open)); app.hooks.audio_devices = Some(Box::new(|| vec!["Browser default output".into()])); } diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index c5c4ada94..d1c5ca291 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -152,6 +152,19 @@ pub fn key_path(key: &str) -> Option<&str> { key.strip_prefix(FILES).filter(|p| p.starts_with('/')) } +/// The project file name typed in the web's Save As prompt (#2788): `None` when it was cancelled or +/// left empty; folders are dropped (the file table is flat) and a name without an extension gets +/// the default's (`Intro copy` → `Intro copy.ecproj`). +pub fn typed_save_name(default: &str, typed: Option) -> Option { + let typed = typed?; + let name = typed.trim().rsplit(['/', '\\']).next().unwrap_or("").trim(); + if name.is_empty() { + return None; + } + let ext = Path::new(default).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + if ext.is_empty() || Path::new(name).extension().is_some() { Some(name.to_string()) } else { Some(format!("{name}.{ext}")) } +} + /// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write. fn content_hash(data: &[u8]) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; @@ -436,6 +449,17 @@ pub struct SnapshotMeta { mod tests { use super::*; + #[test] + fn typed_save_names() { + let t = |s: &str| Some(s.to_string()); + assert_eq!(typed_save_name("Intro.ecproj", t("Intro copy")).as_deref(), Some("Intro copy.ecproj")); + assert_eq!(typed_save_name("Intro.ecproj", t(" Other.ecproj ")).as_deref(), Some("Other.ecproj")); + assert_eq!(typed_save_name("Intro.ecproj", t("a/b\\Shot.ecproj")).as_deref(), Some("Shot.ecproj")); + assert_eq!(typed_save_name("Intro.ecproj", t(" ")), None); + assert_eq!(typed_save_name("Intro.ecproj", t("folder/")), None); + assert_eq!(typed_save_name("Intro.ecproj", None), None); + } + fn store() -> Arc { let s = Arc::new(Store::default()); let _ = s.clock.set(|| 1_000.0); diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index bc7b4dc04..71bf8214b 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -585,7 +585,11 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> "file.replaceFootage" => ("path", Ask::Import), "file.relinkFootage" => ("folder", Ask::Folder), "file.collectFiles" => ("folder", Ask::Save("Collected Files".into())), - "file.saveCopyAsXml" => ("path", Ask::Save(project_as("", "ecprojx"))), + // An open XML project gets ` copy` too: the copy never writes over the open file. + "file.saveCopyAsXml" => { + let xml = app.session.path.as_deref().and_then(|p| std::path::Path::new(p).extension()).is_some_and(|e| e.eq_ignore_ascii_case("ecprojx")); + ("path", Ask::Save(project_as(if xml { " copy" } else { "" }, "ecprojx"))) + } "keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate".into())), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 7f9241d78..0ed3fe7ad 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -328,6 +328,13 @@ fn save_as_dialogs_start_from_the_open_project() { let xml = dir.join("Intro.ecprojx").to_string_lossy().to_string(); // Save a Copy names another file: it would refuse to write over the open project. assert_eq!(*asked.borrow(), ["project Intro.ecproj".to_string(), "project Intro copy.ecproj".into(), format!("file {xml} [ecprojx]")]); + // An open XML project's XML copy is another file too. + app.session.path = Some(dir.join("Intro.ecprojx").to_string_lossy().to_string()); + asked.borrow_mut().clear(); + invoke(&mut app, &ctx, "file.saveCopyAsXml", json!({})).unwrap(); + let copy = dir.join("Intro copy.ecprojx").to_string_lossy().to_string(); + assert_eq!(*asked.borrow(), [format!("file {copy} [ecprojx]")]); + app.session.path = Some(project.to_string_lossy().to_string()); // The non-blocking dialog gets the whole path, so it opens in the project's folder. let pending: Arc>> = Arc::default(); let queue = pending.clone();