diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 99e44f4bfc..389eb9f519 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -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 + 'static) -> Result { 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()), }) } diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 3be31dace7..d889583936 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -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(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 17fb5cfdd0..fd2c2b4e7a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -578,6 +578,9 @@ pub struct PhotocraftApp { pub(crate) file_dialog: Option, /// A Save As to a layered TIFF parked behind the TIFF Options prompt (see `tiff_options_ui`). pub(crate) tiff_options: Option, + /// 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, /// 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,