Fix #37: a name typed without an extension gets the chosen format's on Export too

Save, Save As and Save a Copy already added the save format's extension to a
name typed in the save panel, but every other write through it (Export,
Export Selection, Save as PDF, text and preset exports, Document Info) wrote
the name as typed, so "art" became an extensionless file.

The save panel's answer now goes through one rule (io::with_extension): kept
when it ends in the suggested name's extension or names a known format, else
that extension is added. The web already downloads under the suggested name
or adds the format's extension in its Save As dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-06 00:08:34 +07:00
1 parent 6992c843b4
commit 9c707b37fc
3 files changed
+92 -6

No files matched your search

+16 -6
View File
@@ -134,12 +134,25 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name:
}
/// Where a file goes when no path was given: the suggested name on the web (a download), else the
/// save panel's choice.
/// save panel's choice, given the suggested name's extension when it names no file type (a name
/// typed without one).
fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
if is_web(app) {
return Ok(pick.name.clone());
}
app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into())
let picked = app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or("cancelled")?;
Ok(with_extension(&picked, &fileio::extension(&pick.name), |_| true))
}
/// `path` as typed in a save panel: kept when it ends in `ext` or its extension names a format
/// `keeps` takes, else with `.ext` added.
fn with_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String {
let has = fileio::extension(path);
if ext.is_empty() || has.eq_ignore_ascii_case(ext) || fileio::format_for_name(path).is_some_and(keeps) {
path.to_string()
} else {
format!("{path}.{ext}")
}
}
/// The active document's file name with extension `ext`, and its folder.
@@ -172,10 +185,7 @@ pub fn remember_view(app: &mut VectorcraftApp) {
/// A path typed in a save panel: kept when its extension names a save format (the panel's file
/// type), else `f`'s extension is added.
fn with_save_extension(path: &str, f: &Format) -> String {
match fileio::format_for_name(path) {
Some(g) if SAVE_FORMATS.contains(&g.id) => path.to_string(),
_ => format!("{path}.{}", f.extensions[0]),
}
with_extension(path, f.extensions[0], |g| SAVE_FORMATS.contains(&g.id))
}
/// Write every file of an export (one per artboard, linked images) for the destination `path`.
+2
View File
@@ -74,6 +74,8 @@ mod tests_recovery;
#[cfg(test)]
mod tests_save;
#[cfg(test)]
mod tests_saveext;
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_svg;
+74
View File
@@ -0,0 +1,74 @@
//! A name typed in a save panel without an extension gets the chosen format's: Save, Save As,
//! Save a Copy, Export, Export Selection and other writes through a save panel.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_engine::Session;
use crate::{FilePick, Services, VectorcraftApp, io};
type Written = Rc<RefCell<Vec<String>>>;
/// An app with a document whose save panel answers `typed` and whose writer records the paths.
fn saving_app(typed: &'static str) -> (VectorcraftApp, Written) {
let written = Written::default();
let w = written.clone();
let services = Services {
write: Some(Box::new(move |p: &str, _: &[u8]| {
w.borrow_mut().push(p.to_string());
Ok(())
})),
pick_save: Some(Box::new(move |_: &FilePick| Some(typed.to_string()))),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
(app, written)
}
fn last(w: &Written) -> String {
w.borrow().last().cloned().unwrap_or_default()
}
#[test]
fn saves_add_the_format_extension_to_a_bare_name() {
let (mut app, w) = saving_app("/tmp/Untitled-1");
app.run("file.save", json!({})).unwrap();
assert_eq!(last(&w), "/tmp/Untitled-1.vectorcraft");
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/Untitled-1.vectorcraft"));
let (mut app, w) = saving_app("/tmp/copy");
app.run("file.saveCopy", json!({"format": "svg", "svg": {}})).unwrap();
assert_eq!(last(&w), "/tmp/copy.svg");
let (mut app, w) = saving_app("/tmp/as");
app.run("file.saveAs", json!({"format": "pdf", "options": {}})).unwrap();
assert_eq!(last(&w), "/tmp/as.pdf");
}
#[test]
fn exports_add_the_format_extension_to_a_bare_name() {
let (mut app, w) = saving_app("/tmp/art");
io::export(&mut app, Some("png"), None, &json!({})).unwrap();
assert_eq!(last(&w), "/tmp/art.png");
app.run("select.all", json!({})).unwrap();
app.run("document.exportSelection", json!({})).unwrap();
assert_eq!(last(&w), "/tmp/art.png", "Export Selection: its default format");
io::save_command_output(&mut app, "document.export", "txt", json!({"format": "txt"})).unwrap();
assert_eq!(last(&w), "/tmp/art.txt");
}
#[test]
fn typed_extensions_are_kept() {
let (mut app, w) = saving_app("/tmp/art.svg");
io::export(&mut app, Some("svg"), None, &json!({})).unwrap();
assert_eq!(last(&w), "/tmp/art.svg");
let (mut app, w) = saving_app("/tmp/My.Poster.SVG");
io::export(&mut app, Some("svg"), None, &json!({})).unwrap();
assert_eq!(last(&w), "/tmp/My.Poster.SVG");
// A dot in the name isn't an extension.
let (mut app, w) = saving_app("/tmp/v1.2");
io::export(&mut app, Some("png"), None, &json!({})).unwrap();
assert_eq!(last(&w), "/tmp/v1.2.png");
}