From cccd5cba8f13e19084fefd5419d6249607b5fe66 Mon Sep 17 00:00:00 2001 From: Jonas Neubauer <108294796+jonassneu@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:37:46 +0100 Subject: [PATCH] G2: Save As starts from the open project's name (#2788) --- crates/ui-egui/src/menus/frontend.rs | 42 +++++++++++++++++----------- crates/ui-egui/src/menus/tests.rs | 37 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index c3af37656..2327bbd3b 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -546,39 +546,47 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Folder, Import, OpenProject, - Save(&'static str), + Save(std::borrow::Cow<'static, str>), Open(&'static [&'static str]), /// A movie, or a file with one of these extensions. OpenFootage(&'static [&'static str]), } + // Save As, Save a Copy and Save a Copy As XML start from the open project's file, in its + // folder, as After Effects does (#2788); an untitled project starts from Untitled Project. + let project_as = |ext: &str| -> std::borrow::Cow<'static, str> { + match app.session.path.as_deref() { + Some(p) => std::path::Path::new(p).with_extension(ext).to_string_lossy().into_owned().into(), + None => format!("Untitled Project.{ext}").into(), + } + }; let (key, ask) = match id { "file.import" | "file.importMultiple" => ("paths", Ask::Import), "file.open" => ("path", Ask::OpenProject), - "file.save" if app.session.path.is_none() => ("path", Ask::Save("Untitled Project.ecproj")), - "file.saveAs" | "file.saveCopy" => ("path", Ask::Save("Untitled Project.ecproj")), - "comp.saveFrameAs" => ("path", Ask::Save("Frame.png")), - "comp.saveFrameAsPsd" => ("path", Ask::Save("Frame.psd")), - "comp.saveFrameAsExr" => ("path", Ask::Save("Frame.exr")), - "file.watchFolder" if params.get("stop").is_none() => ("folder", Ask::Save("Watch Folder")), - "anim.savePreset" => ("path", Ask::Save("Preset.ecpreset")), + "file.save" if app.session.path.is_none() => ("path", Ask::Save(project_as("ecproj"))), + "file.saveAs" | "file.saveCopy" => ("path", Ask::Save(project_as("ecproj"))), + "comp.saveFrameAs" => ("path", Ask::Save("Frame.png".into())), + "comp.saveFrameAsPsd" => ("path", Ask::Save("Frame.psd".into())), + "comp.saveFrameAsExr" => ("path", Ask::Save("Frame.exr".into())), + "file.watchFolder" if params.get("stop").is_none() => ("folder", Ask::Save("Watch Folder".into())), + "anim.savePreset" => ("path", Ask::Save("Preset.ecpreset".into())), "anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])), - "view.exportGuides" => ("path", Ask::Save("Guides.json")), + "view.exportGuides" => ("path", Ask::Save("Guides.json".into())), "view.importGuides" => ("path", Ask::Open(&["json"])), - "camera.exportBlender" => ("path", Ask::Save("Tracked Scene.py")), - "file.exportLottie" => ("path", Ask::Save("Animation.json")), + "camera.exportBlender" => ("path", Ask::Save("Tracked Scene.py".into())), + "file.exportLottie" => ("path", Ask::Save("Animation.json".into())), "file.importLottie" => ("path", Ask::Open(&["json", "lottie"])), "file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])), "file.importTimeline" => ("path", Ask::Open(&["xml", "fcpxml", "otio", "edl", "aaf", "omf"])), - "render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")), + "render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4".into())), "file.runScript" if params.get("name").is_none() && params.get("recent").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])), "file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])), "effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])), "file.replaceFootage" => ("path", Ask::Import), "file.relinkFootage" => ("folder", Ask::Folder), - "file.collectFiles" => ("folder", Ask::Save("Collected Files")), - "file.saveCopyAsXml" => ("path", Ask::Save("Untitled Project.ecprojx")), + "file.collectFiles" => ("folder", Ask::Save("Collected Files".into())), + "file.saveCopyAsXml" => ("path", Ask::Save(project_as("ecprojx"))), "keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])), - "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), + "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate".into())), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), "file.setProxy" => ("path", Ask::OpenFootage(&["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "exr"])), _ => return None, @@ -609,7 +617,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Ask::OpenProject => crate::FileDialog::OpenProject, Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false }, Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false }, - Ask::Save(default) => crate::FileDialog::save(default), + Ask::Save(default) => crate::FileDialog::save(&default), }; let tx = app.dialog_answers.0.clone(); let answer = crate::DialogAnswer { command: id.to_string(), key, params: params.clone(), paths: Vec::new() }; @@ -644,7 +652,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> f(&footage(more)).first().map(|s| json!(s)) } Ask::Save(default) => { - let Some(picked) = app.hooks.save_dialog(default) else { return Some(Err("no file dialog available (pass `path`)".into())) }; + let Some(picked) = app.hooks.save_dialog(&default) else { return Some(Err("no file dialog available (pass `path`)".into())) }; picked.map(|s| json!(s)) } }; diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 36d667a72..1d7aa861f 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -299,6 +299,43 @@ fn save_dialogs_offer_the_file_extension() { assert_eq!(*asked.borrow(), ["project Frame.png"]); } +/// #2788: Save As, Save a Copy and Save a Copy As XML start from the open project's name (in its +/// folder), not Untitled Project. +#[test] +fn save_as_dialogs_start_from_the_open_project() { + use std::cell::RefCell; + use std::rc::Rc; + use std::sync::{Arc, Mutex}; + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.session.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap(); + let ctx = egui::Context::default(); + let dir = std::env::temp_dir().join(format!("ec-save-as-name-{}", std::process::id())); + let project = dir.join("Intro.ecproj"); + app.session.path = Some(project.to_string_lossy().to_string()); + let asked: Rc>> = Rc::default(); + let (a, b) = (asked.clone(), asked.clone()); + app.hooks.pick_save = Some(Box::new(move |name: &str| { + a.borrow_mut().push(format!("project {name}")); + None + })); + app.hooks.pick_save_file = Some(Box::new(move |name: &str, ext: &str| { + b.borrow_mut().push(format!("file {name} [{ext}]")); + None + })); + for id in ["file.saveAs", "file.saveCopy", "file.saveCopyAsXml"] { + invoke(&mut app, &ctx, id, json!({})).unwrap(); + } + let xml = dir.join("Intro.ecprojx").to_string_lossy().to_string(); + assert_eq!(*asked.borrow(), ["project Intro.ecproj".to_string(), "project Intro.ecproj".into(), format!("file {xml} [ecprojx]")]); + // 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(); + app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog))); + invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap(); + let dialog = pending.lock().unwrap().pop().expect("a dialog was shown"); + assert_eq!(dialog, crate::FileDialog::Save { default: project.to_string_lossy().to_string(), ext: "ecproj".into(), project: true }); +} + /// File ▸ Scripts ▸ Recent Script Files and its key run a script again without a file dialog. #[test] fn recent_script_files_run_without_a_dialog() {