Save and export dialogs remember where each document was last saved (#1826) (#2421)

`pick_save` now records the folder of every path the user chooses in a save
or export dialog, per document, and starts that document's next bare-name
dialog (exports, an untitled Save As) there. Precedence: a caller's explicit
directory, then the remembered folder, then beside the document (#1866),
then the shell's default. Save As of a saved document still suggests its own
path. The memory is session-only, keyed by DocId, pruned of closed documents
at the next dialog, and not fed by the web's download-name answers, which
carry no folder.
This commit is contained in:
Jason
2026-10-10 13:14:46 -04:00
committed by GitHub
parent 7cd42907e5
commit 3bc6860e72
3 changed files with 99 additions and 4 deletions
+23 -4
View File
@@ -12,6 +12,7 @@
//!
//! One dialog at a time: asking while one is open is refused.
use std::path::Path;
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use photocraft_doc::DocId;
@@ -106,17 +107,35 @@ impl PhotocraftApp {
/// Ask where to save: `then` gets the chosen path, with a lowercase extension when
/// Preferences ▸ File Handling › Lowercase Extension is on (its default).
///
/// A bare file name (export dialogs, an untitled document) starts in the folder the active
/// document was last saved or exported to through a dialog, else beside the document
/// (#1826, #1866). A caller's explicit directory is kept. The chosen folder is remembered for
/// the document the dialog was asked for.
pub(crate) fn pick_save(&mut self, suggested: &str, then: impl FnOnce(&mut Self, String) -> Result<Value, String> + 'static) -> Result<Value, String> {
let mut suggested = std::path::PathBuf::from(suggested);
// Export dialogs usually provide only a file name. Start beside the source document,
// while preserving a caller's explicit directory and the untitled-document fallback.
let doc = self.session.active().map(|d| d.doc.id);
// Closed documents' folders are no longer needed.
let open: Vec<_> = self.session.documents().iter().map(|d| d.doc.id).collect();
self.save_dirs.retain(|id, _| open.contains(id));
if suggested.parent().is_some_and(|p| p.as_os_str().is_empty())
&& let Some(dir) = self.session.active().and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent())
&& let Some(dir) = doc
.and_then(|d| self.save_dirs.get(&d).cloned())
.or_else(|| self.session.active().and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(Path::to_path_buf))
{
suggested = dir.join(suggested);
}
self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string_lossy().into_owned() }, move |app, answer| match answer {
FileDialogAnswer::SaveTo(path) => then(app, Self::lowercased_extension(app, path)),
FileDialogAnswer::SaveTo(path) => {
let path = Self::lowercased_extension(app, path);
// The web answers a download name, which has no folder to remember.
if let Some(doc) = doc
&& let Some(dir) = Path::new(&path).parent().filter(|p| !p.as_os_str().is_empty())
{
app.save_dirs.insert(doc, dir.to_path_buf());
}
then(app, path)
}
_ => Err(UNEXPECTED.into()),
})
}
+72
View File
@@ -280,6 +280,78 @@ fn save_and_export_dialogs_start_beside_the_document() {
}
}
/// #1826: after the user navigates a save or export dialog elsewhere, the document's next dialog
/// starts there. Save As of a saved document still suggests its own path; other documents and
/// closed documents are unaffected.
#[test]
fn save_dialogs_remember_where_each_document_was_last_saved() {
let (mut app, open, _) = app();
let ctx = egui::Context::default();
let first = app.session.active().unwrap().doc.id;
app.session.active_mut().unwrap().path = Some("/proj/original.psd".into());
let suggested = |open: &Open| match &open.borrow()[0].0 {
FileDialogRequest::Save { suggested } => suggested.clone(),
other => panic!("save dialog expected, got {other:?}"),
};
// An export starts beside the document; the user saves it under /renders instead.
app.pick_save("result.png", |_, _| Ok(Value::Null)).unwrap();
app.poll_file_dialog(&ctx, None);
assert_eq!(suggested(&open), "/proj/result.png");
answer(&open, Some(FileDialogAnswer::SaveTo("/renders/result.png".into())));
app.poll_file_dialog(&ctx, None);
// The next export starts in /renders; Save As still offers the document's own path.
app.pick_save("again.png", |_, _| Ok(Value::Null)).unwrap();
app.poll_file_dialog(&ctx, None);
assert_eq!(suggested(&open), "/renders/again.png");
answer(&open, None);
app.poll_file_dialog(&ctx, None);
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
assert_eq!(suggested(&open), "/proj/original.psd");
answer(&open, None);
app.poll_file_dialog(&ctx, None);
// A cancelled dialog and an explicit directory change nothing.
app.pick_save("/elsewhere/x.png", |_, _| Ok(Value::Null)).unwrap();
app.poll_file_dialog(&ctx, None);
assert_eq!(suggested(&open), "/elsewhere/x.png");
answer(&open, None);
app.poll_file_dialog(&ctx, None);
app.pick_save("third.png", |_, _| Ok(Value::Null)).unwrap();
app.poll_file_dialog(&ctx, None);
assert_eq!(suggested(&open), "/renders/third.png");
answer(&open, None);
app.poll_file_dialog(&ctx, None);
// Another document has its own memory: untitled, it starts wherever the shell defaults.
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
let second = app.session.active().unwrap().doc.id;
assert_ne!(first, second);
app.pick_save("fresh.png", |_, _| Ok(Value::Null)).unwrap();
app.poll_file_dialog(&ctx, None);
assert_eq!(suggested(&open), "fresh.png");
answer(&open, Some(FileDialogAnswer::SaveTo("/other/fresh.png".into())));
app.poll_file_dialog(&ctx, None);
assert_eq!(app.save_dirs.get(&second).map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/other"));
assert_eq!(app.save_dirs.get(&first).map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/renders"));
// The folder is remembered for the document the dialog was asked for, not the active one.
app.session.set_active(0);
app.pick_save("swap.png", |_, _| Ok(Value::Null)).unwrap();
app.poll_file_dialog(&ctx, None);
assert_eq!(suggested(&open), "/renders/swap.png");
app.session.set_active(1);
answer(&open, Some(FileDialogAnswer::SaveTo("/moved/swap.png".into())));
app.poll_file_dialog(&ctx, None);
assert_eq!(app.save_dirs.get(&first).map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/moved"));
assert_eq!(app.save_dirs.get(&second).map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/other"));
// Closing a document forgets its folder at the next dialog.
app.run("file.close", json!({"document": 1})).unwrap();
app.pick_save("last.png", |_, _| Ok(Value::Null)).unwrap();
app.poll_file_dialog(&ctx, None);
assert_eq!(suggested(&open), "/moved/last.png");
assert!(!app.save_dirs.contains_key(&second));
answer(&open, None);
app.poll_file_dialog(&ctx, None);
}
#[test]
fn save_suggestions_preserve_explicit_directories_and_untitled_names() {
let (mut app, open, _) = app();
+4
View File
@@ -578,6 +578,9 @@ pub struct PhotocraftApp {
pub(crate) file_dialog: Option<file_dialog::Pending>,
/// A Save As to a layered TIFF parked behind the TIFF Options prompt (see `tiff_options_ui`).
pub(crate) tiff_options: Option<tiff_options_ui::Prompt>,
/// Where each document was last saved or exported to through a dialog (#1826): its next Save
/// As or export dialog starts there rather than beside the document. Session-only.
pub(crate) save_dirs: HashMap<photocraft_doc::DocId, std::path::PathBuf>,
/// Set once the user has agreed to quit, so the resulting close request goes through.
pub(crate) allow_close: bool,
/// Pen pressure/tilt from the platform (see `stylus`).
@@ -688,6 +691,7 @@ impl PhotocraftApp {
discard: None,
file_dialog: None,
tiff_options: None,
save_dirs: HashMap::new(),
allow_close: false,
stylus: Default::default(),
background_jobs: false,