G2: Save As starts from the open project's name (#2788)

This commit is contained in:
Jonas Neubauer
2026-11-01 18:37:46 +01:00
parent e71dd629c5
commit cccd5cba8f
2 changed files with 62 additions and 17 deletions
+25 -17
View File
@@ -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))
}
};
+37
View File
@@ -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<RefCell<Vec<String>>> = 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<Mutex<Vec<crate::FileDialog>>> = 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() {