fix(ui): Save As saves under the filename typed in the dialog (#689) (#757)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
max
2026-10-12 01:18:16 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 65c9b2e5ec
commit d9b76d77f9
2 changed files with 73 additions and 1 deletions
+44 -1
View File
@@ -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<String> {
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<String> = 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());
}
}
+29
View File
@@ -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() {