From d9b76d77f926f910de00c8db26459aeab33af59c Mon Sep 17 00:00:00 2001 From: max <45425238+notdezzi@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:18:16 +0200 Subject: [PATCH] fix(ui): Save As saves under the filename typed in the dialog (#689) (#757) Co-authored-by: Brandon Thomas --- apps/wordcraft/src/file_dialogs.rs | 45 +++++++++++++++++++++++++++++- crates/ui-egui/src/file_dialogs.rs | 29 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index c0c2c843c..0c2769fb9 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -51,7 +51,10 @@ impl Launcher { let dialog = rfd::AsyncFileDialog::new().set_parent(parent); let picking: Picking = match req { FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()), - FileDialogRequest::Save { name } => Box::pin(dialog.set_file_name(name).save_file()), + FileDialogRequest::Save { name } => { + let exts = save_exts(&name); + Box::pin(dialog.set_file_name(name).add_filter("Documents", &exts).save_file()) + } }; let ctx = self.ctx.clone(); let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || { @@ -82,3 +85,43 @@ fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog .add_filter("All files", &["*"]) } } + +/// The file types Save As offers: every format the engine saves, with the suggested name's own +/// format first. rfd makes a filter's first extension the dialog's default, which the Windows, +/// macOS and Linux panels append to a file name the user typed without one; with no filter at +/// all the name came back bare and the save failed, since the format is chosen by extension +/// (#689). +fn save_exts(name: &str) -> Vec { + let suggested = std::path::Path::new(name) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str())) + .unwrap_or_else(|| "docx".to_string()); + let mut exts: Vec = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len()); + exts.push(suggested.clone()); + exts.extend(wordcraft_engine::io::SAVE_EXTS.iter().filter(|e| **e != suggested).map(|e| (*e).to_string())); + exts +} + +#[cfg(test)] +mod tests { + use super::save_exts; + + /// #689: the Save dialog's default extension is the suggested name's own format, so a file + /// name the user types without one is saved in the document's format instead of coming back + /// bare and failing. + #[test] + fn save_dialog_defaults_to_the_suggested_format() { + assert_eq!(save_exts("novel.docx")[0], "docx"); + assert_eq!(save_exts("report.odt")[0], "odt"); + assert_eq!(save_exts("annual report.pdf")[0], "pdf"); + // A suggested name always has an extension; anything unreadable falls back to Word's. + assert_eq!(save_exts("no extension")[0], "docx"); + assert_eq!(save_exts("mystery.rtf2")[0], "docx"); + // Every savable format is still offered, once. + let exts = save_exts("novel.docx"); + assert!(wordcraft_engine::io::SAVE_EXTS.iter().all(|e| exts.iter().any(|x| x == e))); + assert_eq!(exts.len(), wordcraft_engine::io::SAVE_EXTS.len()); + } +} diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 939a8fb9a..1b9c9f356 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -321,6 +321,35 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #689: Save As honours the filename typed in the dialog, not just the folder: a document + /// opened as `a/one.docx` and saved as `b/two.docx` is written there and takes that path. + #[test] + fn save_as_with_a_new_filename_saves_under_it() { + let dir = scratch("rename"); + let opened = dir.join("one.docx"); + let picked = dir.join("sub").join("two.docx"); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("original")) + .run("file.save", &json!({"path": opened.to_string_lossy()})) + .unwrap(); + let (mut a, shown) = async_app(); + a.run("file.open", json!({"path": opened.to_string_lossy()})).unwrap(); + assert_eq!(a.session.path.as_deref(), Some(opened.as_path())); + a.save_as_dialog(); + answer(&shown, Some(&picked.to_string_lossy())); + let r = a.poll_file_dialog().unwrap().unwrap(); + assert_eq!(r, json!({"saved": true}), "the save-as succeeded"); + assert_eq!(a.session.path.as_deref(), Some(picked.as_path()), "the picked filename is the document's path"); + assert!(picked.is_file(), "the file is written under the picked name"); + assert!(saved_text(&picked).contains("original")); + // F12 (file.saveAs without a path) lands in the same dialog, suggesting the new name. + let r = a.run("file.saveAs", json!({})).unwrap(); + assert_eq!(r, json!({"saved": false})); + assert!(a.file_dialog_open(), "F12's saveAs request opens the dialog"); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "two.docx".into() }); + let _ = std::fs::remove_dir_all(&dir); + } + /// While a dialog is open, more Save As / Open / Export / Picture clicks don't open a second one. #[test] fn a_second_dialog_while_one_is_open_is_ignored() {