Save: ask before replacing a file that an added or lowercased extension names

The save dialog asks before replacing only the file it answers with.
After it closes, pick_save can change that path: it gives a name
without an extension the suggested file's extension (#5039), and
Lowercase Extension changes CAT.PSD to CAT.psd. The save then wrote to
the changed path at once, and a file there was replaced without a
question. On Linux this happens when the Swatches panel exports to a
folder that holds Swatches.aco (the one-type portal dialog shows and
asks about "Swatches"), when CAT.PSD is typed where CAT.psd exists, and
in rfd's zenity fallback dialog.

When the path written differs from the dialog's answer and a file
exists there, the save now asks "“{name}” already exists. Replace it?".
Replace writes the file, and ui.dialog.confirm returns the save's
result. Cancel writes nothing and ends the save like the dialog's
Cancel, and Save in the close prompt of an untitled document returns
to that prompt. PhotoCraft counts the file dialog as open until the
question is answered.

A name that reaches the same file, as CAT.PSD and CAT.psd do on
Windows and macOS by default, is not asked about again. The new
same_file service decides this: the desktop app compares device and
inode on Unix and the canonical paths on Windows. The web has no file
stamps and does not ask. The two new strings have rows in all 20
catalogs.
This commit is contained in:
dotCooCoo
2026-10-17 03:08:09 -07:00
parent 5f65579ecb
commit a78e9f05fc
30 changed files with 397 additions and 13 deletions
+37
View File
@@ -298,6 +298,26 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
}
/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path
/// on Windows (where it holds the name's letter case as stored). False when either can't be read.
fn same_file(a: &Path, b: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(a), std::fs::metadata(b)) {
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
_ => false,
}
}
#[cfg(not(unix))]
{
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
settings.io_options()
}
@@ -511,6 +531,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
let meta = std::fs::metadata(path).ok()?;
Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() })
})),
same_file: Some(Box::new(|a: &str, b: &str| same_file(Path::new(a), Path::new(b)))),
automation_read,
automation_write,
automation_open_pcraft,
@@ -888,6 +909,22 @@ mod tests {
assert_eq!(dialog_file_name("", true), None);
}
/// A name in another letter case reaches the same file where the file system ignores case
/// (Windows, and macOS by default), and only there. Two files with the same contents are two.
#[test]
fn same_file_follows_the_file_system() {
let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD"));
std::fs::write(&cat, b"same").unwrap();
std::fs::write(&dog, b"same").unwrap();
assert!(same_file(&cat, &cat));
assert!(!same_file(&cat, &dog));
assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored");
assert!(!same_file(&dir.join("missing.psd"), &cat));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn block_on_waits_for_a_wake_from_another_thread() {
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
+7 -1
View File
@@ -322,6 +322,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
DialogKind::Command if crate::shape_dialog::owns(&fields) => crate::shape_dialog::body(app, ui, &mut fields),
DialogKind::Command if crate::delete_layer_prompt::owns(&fields) => crate::delete_layer_prompt::body(ui, &mut fields),
DialogKind::Command if crate::disk_change_prompt::owns(&fields) => crate::disk_change_prompt::body(ui, &fields),
DialogKind::Command if crate::replace_prompt::owns(&fields) => crate::replace_prompt::body(ui, &fields),
DialogKind::Command if crate::actions::owns_insert_stop(&fields) => crate::actions::insert_stop_body(ui, &mut fields),
DialogKind::Command if crate::new_layer_ui::owns(&fields) => crate::new_layer_ui::body(ui, &mut fields),
DialogKind::Command if crate::mask_trash_prompt::owns(&fields) => crate::mask_trash_prompt::body(ui, &fields),
@@ -405,7 +406,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
tl!("Create")
} else if crate::delete_layer_prompt::owns(&d.fields) {
tl!("Delete")
} else if crate::disk_change_prompt::owns(&d.fields) {
} else if crate::disk_change_prompt::owns(&d.fields) || crate::replace_prompt::owns(&d.fields) {
tl!("Replace")
} else if d.fields.contains_key("__export") {
tl!("Export")
@@ -621,6 +622,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
DialogKind::Command if crate::shape_dialog::owns(&d.fields) => crate::shape_dialog::confirm(app, &d.fields),
DialogKind::Command if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields),
DialogKind::Command if crate::disk_change_prompt::owns(&d.fields) => crate::disk_change_prompt::confirm(app, &d.fields),
DialogKind::Command if crate::replace_prompt::owns(&d.fields) => crate::replace_prompt::confirm(app),
DialogKind::Command if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields),
DialogKind::Command if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields),
DialogKind::Command if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields),
@@ -679,6 +681,10 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
if crate::open_prompt::owns(&closed.fields) {
crate::open_prompt::answer(app, None)?;
}
// Cancel on the question whether to replace a file writes nothing.
if crate::replace_prompt::owns(&closed.fields) {
crate::replace_prompt::cancel(app)?;
}
app.ui.dialogs.retain(|d| !crate::layer_style::color_picker::child_of(&d.fields, id));
app.filter_preview = None;
app.color_range = None;
+47
View File
@@ -878,6 +878,53 @@ mod tests {
assert!(h.state().session.documents().is_empty());
}
/// Save on closing an untitled document, answered with `kept` while `kept.psd` exists: the
/// prompt waits for the question whether to replace it. Cancel brings the prompt back with
/// nothing written; Replace saves and closes the document.
#[test]
fn saving_from_the_prompt_waits_for_the_question_whether_to_replace() {
use crate::{FileDialogAnswer, FileDialogReply};
use egui_kittest::kittest::Queryable;
use std::{cell::RefCell, rc::Rc};
let written: Rc<RefCell<Vec<String>>> = Rc::default();
let (w, waiting): (_, Rc<RefCell<Option<FileDialogReply>>>) = (written.clone(), Rc::default());
let held = waiting.clone();
let mut app = app_with_docs(1);
make_dirty(&mut app, 0);
app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new()))));
app.services.write = Some(Box::new(move |p: &str, _: &[u8]| {
w.borrow_mut().push(p.to_string());
Ok(())
}));
app.services.file_stamp = Some(Box::new(|p: &str| (p == "/pics/kept.psd").then_some(crate::FileStamp { len: 1, modified: None })));
app.services.file_dialog = Some(Box::new(move |_, _, reply| *held.borrow_mut() = Some(reply)));
let mut h = prompt_for(app, "file.close", egui::os::OperatingSystem::Windows);
let save = |h: &mut Prompted| {
h.key_press(Key::Y);
h.run_steps(3);
waiting.borrow_mut().take().expect("the save dialog is open").send(Some(FileDialogAnswer::SaveTo("/pics/kept".into())));
h.run_steps(2);
h.state().ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id).expect("the save asks first")
};
let id = save(&mut h);
assert!(h.state().file_dialog_open());
assert!(h.query_by_label("(Y)es").is_none(), "the prompt waits behind the question");
assert!(written.borrow().is_empty());
crate::dialogs::cancel(h.state_mut(), id).unwrap();
h.run_steps(2);
assert!(written.borrow().is_empty(), "Cancel writes nothing");
assert_eq!(docs_left(&h), Some(1));
assert_eq!(focused(&h, &["(Y)es", "(N)o", "(C)ancel"]), Some("(Y)es"), "the prompt comes back");
let id = save(&mut h);
crate::dialogs::confirm(h.state_mut(), id).unwrap();
h.run_steps(2);
assert_eq!(*written.borrow(), ["/pics/kept.psd"]);
assert!(h.state().discard.is_none());
assert!(h.state().session.documents().is_empty());
}
#[test]
fn saving_from_the_prompt_waits_for_a_background_save_then_closes() {
use egui_kittest::kittest::Queryable;
+82 -12
View File
@@ -73,12 +73,20 @@ pub type FileDialogFn = Box<dyn FnMut(FileDialogRequest, Option<&eframe::Frame>,
/// What to do with the answer (`None`: cancelled).
type Then = Box<dyn FnOnce(&mut PhotocraftApp, Option<FileDialogAnswer>) -> Result<Value, String>>;
/// The path a save writes to for the path the save dialog answered (see [`saved_path`]).
type SavedPath = Box<dyn Fn(&PhotocraftApp, &str) -> String>;
/// The file dialog in progress.
pub(crate) struct Pending {
/// Not shown yet: it is shown at the end of the frame, which has the window to parent it to.
request: Option<FileDialogRequest>,
answer: Option<Receiver<Option<FileDialogAnswer>>>,
then: Then,
/// For a save dialog, the path the save writes to for the dialog's answer.
saved_path: Option<SavedPath>,
/// The save dialog's answer while the app asks whether to replace the file it writes to
/// (`replace_prompt`).
held: Option<FileDialogAnswer>,
}
/// The error of a cancelled dialog: not reported, since backing out is the user's choice.
@@ -115,7 +123,7 @@ impl PhotocraftApp {
Some(FileDialogAnswer::Unavailable(why)) => Err(why),
Some(a) => then(app, a),
});
self.file_dialog = Some(Pending { request: Some(request), answer: None, then });
self.file_dialog = Some(Pending { request: Some(request), answer: None, then, saved_path: None, held: None });
Ok(json!({ "fileDialog": kind }))
}
@@ -130,7 +138,9 @@ 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).
/// Preferences ▸ File Handling › Lowercase Extension is on (its default). When that, or the
/// extension added to a bare name, changes the path to one where a file exists, the app asks
/// whether to replace it before `then` runs (`replace_prompt`).
///
/// 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
@@ -153,13 +163,10 @@ impl PhotocraftApp {
// extensions are hidden, #5039) gets the suggested file's extension, as Photoshop's
// Save As appends the extension of the chosen file type.
let suggested_ext = suggested.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string_lossy().into_owned() }, move |app, answer| match answer {
let ext = suggested_ext.clone();
let asked = self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string_lossy().into_owned() }, move |app, answer| match answer {
FileDialogAnswer::SaveTo(path) => {
let path = Self::lowercased_extension(app, path);
let path = match &suggested_ext {
Some(ext) if Path::new(&path).extension().is_none() => Path::new(&path).with_extension(ext).to_string_lossy().into_owned(),
_ => path,
};
let path = saved_path(app, &path, suggested_ext.as_deref());
// 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())
@@ -169,7 +176,32 @@ impl PhotocraftApp {
then(app, path)
}
_ => Err(UNEXPECTED.into()),
})
})?;
if let Some(p) = self.file_dialog.as_mut() {
p.saved_path = Some(Box::new(move |app, path| saved_path(app, path, ext.as_deref())));
}
Ok(asked)
}
/// Whether a save to `path` replaces a file the save dialog didn't ask about: the dialog
/// answered `answered`, and [`saved_path`] changed it. A file at `path` that
/// [`Services::same_file`](crate::Services::same_file) finds is also the file at `answered` (the
/// same name in another letter case on a file system that ignores case, as Windows and macOS do
/// by default) counts as asked about. Never on the web, which has no file stamps.
fn replaces_unasked(&self, answered: &str, path: &str) -> bool {
let Some(stamp) = self.services.file_stamp.as_ref() else { return false };
answered != path && stamp(path).is_some() && !self.services.same_file.as_ref().is_some_and(|same| same(answered, path))
}
/// The question whether to replace a file is answered. Replace writes the save dialog's answer
/// as if the question had not been asked and returns what the save returns, its failure
/// included. Cancel ends the save like the dialog's Cancel.
pub(crate) fn answer_replace(&mut self, replace: bool) -> Result<Value, String> {
let held = self.file_dialog.as_mut().and_then(|p| p.held.take()).ok_or("no save is waiting to replace a file")?;
match self.finish_file_dialog(replace.then_some(held)) {
Err(e) if e == CANCELLED => Ok(json!({ "replaced": false })),
r => r,
}
}
/// `X.PSD` → `X.psd`, only the extension: the stem and the folders keep the user's spelling.
@@ -275,6 +307,14 @@ impl PhotocraftApp {
/// continues the action that asked once the answer is in. Its errors go to the status bar.
pub fn poll_file_dialog(&mut self, ctx: &egui::Context, parent: Option<&eframe::Frame>) {
let Some(p) = self.file_dialog.as_mut() else { return };
if p.held.is_some() {
// The question whether to replace answers through `answer_replace`. Closed without an
// answer, it counts as Cancel.
if !self.ui.dialogs.iter().any(|d| crate::replace_prompt::owns(&d.fields)) {
let _ = self.answer_replace(false);
}
return;
}
if let Some(request) = p.request.take() {
let (tx, rx) = mpsc::channel();
p.answer = Some(rx);
@@ -288,13 +328,43 @@ impl PhotocraftApp {
// The reply was dropped unanswered: the dialog couldn't be shown.
Some(Err(TryRecvError::Disconnected)) | None => None,
};
let Some(Pending { then, .. }) = self.file_dialog.take() else { return };
if let Err(e) = then(self, answer)
if let Some(FileDialogAnswer::SaveTo(answered)) = &answer
&& let Some(path) = self.file_dialog.as_ref().and_then(|p| p.saved_path.as_ref()).map(|written| written(self, answered))
&& self.replaces_unasked(answered, &path)
{
crate::replace_prompt::open(self, answered, &path);
if let Some(p) = self.file_dialog.as_mut() {
p.answer = None;
p.held = answer;
}
return;
}
let _ = self.finish_file_dialog(answer);
}
/// Ends the file dialog in progress: its action continues with `answer`, and its errors go to
/// the status bar. Returns the action's result.
fn finish_file_dialog(&mut self, answer: Option<FileDialogAnswer>) -> Result<Value, String> {
let Some(Pending { then, .. }) = self.file_dialog.take() else { return Err(CANCELLED.into()) };
let r = then(self, answer);
if let Err(e) = &r
&& e != CANCELLED
{
self.ui.status = e;
self.ui.status = e.clone();
self.ui.status_error = true;
}
r
}
}
/// The path a save writes to for the path the save dialog answered: its extension lowercased
/// when Preferences ▸ File Handling › Lowercase Extension is on, and a name without one given
/// `suggested_ext`, the suggested file's.
fn saved_path(app: &PhotocraftApp, answered: &str, suggested_ext: Option<&str>) -> String {
let path = PhotocraftApp::lowercased_extension(app, answered.to_string());
match suggested_ext {
Some(ext) if Path::new(&path).extension().is_none() => Path::new(&path).with_extension(ext).to_string_lossy().into_owned(),
_ => path,
}
}
+102
View File
@@ -433,6 +433,108 @@ fn save_paths_without_an_extension_get_the_suggested_one() {
assert_eq!(*written.borrow(), ["/pics/cat.psd", "/pics/dog.png"]);
}
/// The question whether to replace a file (`replace_prompt`), when one is open.
fn replace_prompt(app: &PhotocraftApp) -> Option<u64> {
app.ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id)
}
/// File › Save As, answered with `path`.
fn save_as(app: &mut PhotocraftApp, open: &Open, path: &str) {
let ctx = egui::Context::default();
menus::invoke(app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
answer(open, Some(FileDialogAnswer::SaveTo(path.into())));
app.poll_file_dialog(&ctx, None);
}
/// The save dialog asked only about `cat`. The extension added to it names a file that exists,
/// so the save asks before replacing that file: Cancel writes nothing, Replace writes it.
#[test]
fn a_save_asks_before_replacing_the_file_its_added_extension_names() {
let ctx = egui::Context::default();
let (mut app, open, written) = app();
let disk = on_disk(&mut app);
changed_by_another_app(&disk, "/pics/cat.psd");
save_as(&mut app, &open, "/pics/cat");
let id = replace_prompt(&app).expect("the save asks first");
let fields = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields;
assert!(fields["message"].as_str().unwrap().contains("“cat.psd”"), "{fields:?}");
assert!(fields["detail"].as_str().unwrap().contains("“cat”"), "{fields:?}");
assert!(written.borrow().is_empty(), "nothing is written before the answer");
app.poll_file_dialog(&ctx, None);
assert!(app.file_dialog_open(), "the save waits for the answer");
assert!(written.borrow().is_empty());
crate::dialogs::cancel(&mut app, id).unwrap();
assert!(written.borrow().is_empty(), "Cancel writes nothing");
assert!(!app.file_dialog_open() && !app.ui.status_error);
assert_eq!(app.session.active().unwrap().path, None);
// A question closed without an answer counts as Cancel.
save_as(&mut app, &open, "/pics/cat");
app.ui.close_dialog(replace_prompt(&app).expect("asked again"));
app.poll_file_dialog(&ctx, None);
assert!(written.borrow().is_empty() && !app.file_dialog_open());
// Replace answers with the save's failure.
save_as(&mut app, &open, "/pics/cat");
let write = app.services.write.replace(Box::new(|_: &str, _: &[u8]| Err("disk full".into())));
let id = replace_prompt(&app).expect("asked again");
let e = crate::dialogs::confirm(&mut app, id).unwrap_err();
assert!(e.contains("disk full") && app.ui.status_error, "{e}");
app.services.write = write;
save_as(&mut app, &open, "/pics/cat");
let id = replace_prompt(&app).expect("asked again");
crate::dialogs::confirm(&mut app, id).unwrap();
assert_eq!(*written.borrow(), ["/pics/cat.psd"]);
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/cat.psd"));
assert!(!app.file_dialog_open() && replace_prompt(&app).is_none());
}
/// Lowercase Extension changes `CAT.PSD` to `CAT.psd`. Where letter case tells two files apart
/// (Linux), the dialog asked only about `CAT.PSD`; where it doesn't (Windows and macOS by
/// default), both names reach the same file, which the dialog asked about.
#[test]
fn a_save_asks_before_replacing_the_file_its_lowercased_extension_names() {
{
// Two files, even with the same size and modification time.
let (mut app, open, written) = app();
let disk = on_disk(&mut app);
for name in ["/pics/CAT.psd", "/pics/CAT.PSD"] {
changed_by_another_app(&disk, name);
}
app.services.same_file = Some(Box::new(|a: &str, b: &str| a == b));
save_as(&mut app, &open, "/pics/CAT.PSD");
let id = replace_prompt(&app).expect("the save asks first");
assert!(written.borrow().is_empty());
crate::dialogs::confirm(&mut app, id).unwrap();
assert_eq!(*written.borrow(), ["/pics/CAT.psd"]);
}
let (mut app, open, written) = app();
let disk = on_disk(&mut app);
for name in ["/pics/CAT.psd", "/pics/CAT.PSD"] {
changed_by_another_app(&disk, name);
}
app.services.same_file = Some(Box::new(|a: &str, b: &str| a.eq_ignore_ascii_case(b)));
save_as(&mut app, &open, "/pics/CAT.PSD");
assert!(replace_prompt(&app).is_none(), "the dialog asked about this file");
assert_eq!(*written.borrow(), ["/pics/CAT.psd"]);
}
/// The save dialog asked about the file it answered: a bare name with no file under the added
/// extension, and a full name, are written at once.
#[test]
fn a_save_writes_at_once_when_the_dialog_asked_about_the_file() {
let (mut app, open, written) = app();
let disk = on_disk(&mut app);
changed_by_another_app(&disk, "/pics/dog.png");
save_as(&mut app, &open, "/pics/cat");
save_as(&mut app, &open, "/pics/dog.png");
assert!(replace_prompt(&app).is_none());
assert_eq!(*written.borrow(), ["/pics/cat.psd", "/pics/dog.png"]);
}
#[test]
fn save_and_export_dialogs_start_beside_the_document() {
let (mut app, open, _) = app();
+2
View File
@@ -3818,3 +3818,5 @@ navigator Zoom Zvětšení
GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky)
{name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen.
Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí.
“{name}” already exists. Replace it? „{name}“ již existuje. Nahradit?
The name in the save dialog was “{name}”. Název v dialogu ukládání byl „{name}“.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3212,3 +3212,5 @@ curves Smooth Glätten
GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch)
{name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde.
Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten.
“{name}” already exists. Replace it? „{name}“ ist bereits vorhanden. Ersetzen?
The name in the save dialog was “{name}”. Der Name im Speichern-Dialog war „{name}“.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3910,3 +3910,5 @@ curves Smooth Εξομάλυνση
GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο)
{name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ.
Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο.
“{name}” already exists. Replace it? Το «{name}» υπάρχει ήδη. Να αντικατασταθεί;
The name in the save dialog was “{name}”. Το όνομα στο παράθυρο αποθήκευσης ήταν «{name}».
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+2
View File
@@ -4172,3 +4172,5 @@ navigator Zoom Zoom
GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático)
{name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí.
Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos.
“{name}” already exists. Replace it? «{name}» ya existe. ¿Reemplazarlo?
The name in the save dialog was “{name}”. El nombre en el cuadro de diálogo Guardar era «{name}».
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -3216,3 +3216,5 @@ curves Smooth Lisser
Replace Remplacer
{name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici.
Replace it with this document, or cancel and use File › Save As to keep both. Remplacez-le par ce document, ou annulez et utilisez Fichier › Enregistrer sous pour garder les deux.
“{name}” already exists. Replace it? « {name} » existe déjà. Le remplacer ?
The name in the save dialog was “{name}”. Le nom dans la boîte de dialogue d'enregistrement était « {name} ».
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -4129,3 +4129,5 @@ cameraRaw Yellow Sárga
GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus)
{name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették.
Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt.
“{name}” already exists. Replace it? „{name}” már létezik. Lecseréli?
The name in the save dialog was “{name}”. A mentési párbeszédpanelen megadott név: „{name}”.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3203,3 +3203,5 @@ curves Smooth Haluskan
Replace Ganti
{name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini.
Replace it with this document, or cancel and use File › Save As to keep both. Ganti dengan dokumen ini, atau batalkan dan gunakan Berkas › Simpan Sebagai untuk menyimpan keduanya.
“{name}” already exists. Replace it? “{name}” sudah ada. Ganti?
The name in the save dialog was “{name}”. Nama di dialog simpan adalah “{name}”.
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -4331,3 +4331,5 @@ shape Appearance Aspetto
GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico)
{name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui.
Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi.
“{name}” already exists. Replace it? “{name}” esiste già. Sostituirlo?
The name in the save dialog was “{name}”. Il nome nella finestra di salvataggio era “{name}”.
Can't render this file because it contains an unexpected character in line 2653 and column 53.
+2
View File
@@ -3887,3 +3887,5 @@ curves Smooth スムーズ
Replace 置き換え
{name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。
Replace it with this document, or cancel and use File › Save As to keep both. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。
“{name}” already exists. Replace it? 「{name}」は既に存在します。置き換えますか?
The name in the save dialog was “{name}”. 保存ダイアログで指定した名前は「{name}」でした。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3389,3 +3389,5 @@ curves Smooth 매끄럽게
Replace 대치
{name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다.
Replace it with this document, or cancel and use File › Save As to keep both. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요.
“{name}” already exists. Replace it? “{name}”이(가) 이미 있습니다. 대치하시겠습니까?
The name in the save dialog was “{name}”. 저장 대화상자에 입력한 이름: “{name}”
Can't render this file because it contains an unexpected character in line 3165 and column 53.
+2
View File
@@ -3970,3 +3970,5 @@ curves Smooth Vloeiend maken
GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch)
{name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard.
Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden.
“{name}” already exists. Replace it? “{name}” bestaat al. Vervangen?
The name in the save dialog was “{name}”. De naam in het venster Opslaan was “{name}”.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -3809,3 +3809,5 @@ curves Smooth Wygładź
Replace Zastąp
{name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj.
Replace it with this document, or cancel and use File › Save As to keep both. Zastąp go tym dokumentem albo anuluj i użyj Plik › Zapisz jako, aby zachować oba.
“{name}” already exists. Replace it? „{name}” już istnieje. Zastąpić?
The name in the save dialog was “{name}”. Nazwa w oknie zapisywania: „{name}”.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -4000,3 +4000,5 @@ layerLabel No Color Sem cor
GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático)
{name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui.
Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois.
“{name}” already exists. Replace it? “{name}” já existe. Substituir?
The name in the save dialog was “{name}”. O nome na caixa de diálogo Salvar era “{name}”.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3181,3 +3181,5 @@ curves Smooth Сгладить
Replace Заменить
{name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь.
Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба.
“{name}” already exists. Replace it? «{name}» уже существует. Заменить?
The name in the save dialog was “{name}”. В окне сохранения было указано имя «{name}».
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -4283,3 +4283,5 @@ shape Appearance Izgled
GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski)
{name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana.
Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe.
“{name}” already exists. Replace it? „{name}“ već postoji. Zameniti?
The name in the save dialog was “{name}”. Ime u dijalogu za čuvanje je bilo „{name}“.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3940,3 +3940,5 @@ cameraData Lens Lens
GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik)
{name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi.
Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın.
“{name}” already exists. Replace it? '{name}' zaten var. Değiştirilsin mi?
The name in the save dialog was “{name}”. Kaydetme penceresindeki ad '{name}' idi.
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -3910,3 +3910,5 @@ curves Smooth Згладити
Replace Замінити
{name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли.
Replace it with this document, or cancel and use File › Save As to keep both. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва.
“{name}” already exists. Replace it? «{name}» уже існує. Замінити?
The name in the save dialog was “{name}”. У вікні збереження було вказано назву «{name}».
Can't render this file because it contains an unexpected character in line 1388 and column 18.
+2
View File
@@ -3697,3 +3697,5 @@ curves Smooth Làm mượt
Replace Thay thế
{name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây.
Replace it with this document, or cancel and use File › Save As to keep both. Thay thế bằng tài liệu này, hoặc hủy và dùng Tệp › Lưu thành để giữ cả hai.
“{name}” already exists. Replace it? “{name}” đã tồn tại. Thay thế?
The name in the save dialog was “{name}”. Tên trong hộp thoại lưu là “{name}”.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2827,3 +2827,5 @@ curves Smooth 平滑
Replace 替换
{name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。
Replace it with this document, or cancel and use File › Save As to keep both. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。
“{name}” already exists. Replace it? “{name}”已存在。要替换它吗?
The name in the save dialog was “{name}”. 存储对话框中的名称是“{name}”。
Can't render this file because it contains an unexpected character in line 2599 and column 53.
+2
View File
@@ -2812,3 +2812,5 @@ curves Smooth 平滑
Replace 取代
{name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。
Replace it with this document, or cancel and use File › Save As to keep both. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。
“{name}” already exists. Replace it? 「{name}」已存在。要取代它嗎?
The name in the save dialog was “{name}”. 儲存對話框中的名稱是「{name}」。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -173,6 +173,7 @@ pub mod proxy;
pub mod puppet_ui;
pub mod quick_pick;
pub mod rasterize_prompt;
pub mod replace_prompt;
pub mod retouch_ui;
mod rgb_histogram;
pub mod rotate_view;
@@ -360,6 +361,8 @@ pub struct FileStamp {
}
/// The [`FileStamp`] of the file at a path, `None` when it can't be read.
pub type FileStampFn = Box<dyn Fn(&str) -> Option<FileStamp>>;
/// Whether two paths reach the same file on disk; false when either can't be read.
pub type SameFileFn = Box<dyn Fn(&str, &str) -> bool>;
/// Encode a document and write it to a path, reporting the stage to the job; returns the export
/// warnings. Runs on a worker thread (see [`Services::save_file`]). It must call
/// [`jobs_ui::SaveCtl::commit`] right before replacing the file, and write nothing if that fails.
@@ -460,6 +463,10 @@ pub struct Services {
/// Size and modification time of a file (native); `None` on the web, where Save never
/// writes back to a file.
pub file_stamp: Option<FileStampFn>,
/// Whether two paths reach the same file (native): a save dialog's answer and the path the
/// save writes to after Lowercase Extension, on a file system that ignores letter case. A save
/// asks before replacing a file it finds different (`replace_prompt`).
pub same_file: Option<SameFileFn>,
/// Encode and write in one step on a worker thread, so a large save doesn't freeze the
/// window (#2017). With background jobs on, saves use it instead of `export` and `write`.
pub save_file: Option<SaveFileFn>,
+46
View File
@@ -0,0 +1,46 @@
//! A save asks before replacing a file that the save dialog did not ask about. The dialog asks
//! only about the name it answers, and the app can change that name afterwards: it gives a name
//! without an extension the suggested file's (#5039), and lowercases the extension when
//! Preferences ▸ File Handling › Lowercase Extension is on. When a file exists under the changed
//! name, Replace writes over it and Cancel ends the save like the save dialog's Cancel.
//!
//! The prompt is an ordinary dialog in the UI state: `ui.dialog.confirm` replaces the file and
//! `ui.dialog.cancel` keeps it. The file dialog stays open until it is answered
//! ([`PhotocraftApp::file_dialog_open`]).
use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::file_open::display_name;
use crate::state::DialogKind;
const MARK: &str = "__replaceSaved";
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(MARK)
}
/// Opens the prompt about `path`, the file a save writes to for `answered`, the save dialog's
/// answer.
pub(crate) fn open(app: &mut PhotocraftApp, answered: &str, path: &str) {
let message = crate::i18n::fmt(tl!("“{name}” already exists. Replace it?"), &[("name", display_name(path).as_str())]);
let detail = crate::i18n::fmt(tl!("The name in the save dialog was “{name}”."), &[("name", display_name(answered).as_str())]);
let fields = json!({MARK: true, "__label": "Save", "message": message, "detail": detail, "path": path});
app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default());
}
pub fn body(ui: &mut egui::Ui, fields: &Map<String, Value>) {
ui.label(fields.get("message").and_then(Value::as_str).unwrap_or_default());
ui.add_space(6.0);
ui.label(fields.get("detail").and_then(Value::as_str).unwrap_or_default());
}
/// Replace: the save the prompt held back.
pub fn confirm(app: &mut PhotocraftApp) -> Result<Value, String> {
app.answer_replace(true)
}
/// Cancel: nothing is written.
pub fn cancel(app: &mut PhotocraftApp) -> Result<Value, String> {
app.answer_replace(false)
}
+26
View File
@@ -442,6 +442,32 @@ mod tests {
assert!(out[0].1.starts_with(b"ASEF"));
}
/// The Linux save dialog shows the export's name without its extension, `Swatches`, and asks
/// only about that name. The export adds `.aco`, so with `Swatches.aco` in the folder it asks
/// before replacing it.
#[test]
fn export_asks_before_replacing_the_file_its_added_extension_names() {
use crate::file_dialog::FileDialogAnswer;
let written: std::rc::Rc<std::cell::RefCell<Vec<String>>> = Default::default();
let w = written.clone();
let services = crate::Services {
file_dialog: Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo("/swatches/Swatches".into()))]).0),
write: Some(Box::new(move |p: &str, _: &[u8]| {
w.borrow_mut().push(p.to_string());
Ok(())
})),
file_stamp: Some(Box::new(|p: &str| (p == "/swatches/Swatches.aco").then_some(crate::FileStamp { len: 1, modified: None }))),
..Default::default()
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
export_dialog(&mut app, "aco").unwrap();
app.poll_file_dialog(&egui::Context::default(), None);
let id = app.ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id).expect("the export asks first");
assert!(written.borrow().is_empty(), "nothing is written before the answer");
crate::dialogs::confirm(&mut app, id).unwrap();
assert_eq!(*written.borrow(), ["/swatches/Swatches.aco"]);
}
/// #3658: dragging a group header reorders the folders, and the new order sticks in the UI
/// state (UI state is serde, so it saves and reloads with the rest of the panel state).
#[test]
+1
View File
@@ -66,6 +66,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `ui.dialog.open {kind:"colorPicker", textStyle:{kind:"character"|"paragraph", id:u32}}` opens the shared Color Picker for an explicitly enabled Color attribute in Style Options. It opens the matching style panel/editor after validation; inherited colors must first be enabled through the existing style command. The picker reports its bound document/revision/style under `__textStyleColor` in `ui.inspect`. Changes preview in an isolated document; OK runs the existing style `set` command once, Cancel changes nothing. Document edits/switches or closed/changed Style Options discard the picker. `textStyle` cannot be combined with `parent`, `effect`, `field` or `target`.
- The shell's own dialogs (Window › Workspace › New Workspace / Delete Workspace, View › Pixel Aspect Ratio › Custom Pixel Aspect Ratio…, View › Show › Show Extras Options…, View › 32-bit Preview Options…) are listed in `ui.inspect`'s `dialogs` with the id `"shell"` (at most one is open). `ui.dialog.set`, `ui.dialog.confirm` and `ui.dialog.cancel` take `{"dialog": "shell"}`; OK runs the dialog's command and keeps the dialog open if it fails
- The unsaved-changes prompt (closing a document, Revert, quitting with unsaved work) is listed in `ui.inspect`'s `dialogs` with the id `"discard"` while it is up: `fields` gives the waiting `command` (`file.close`, `file.exit`…), the `document` it asks about, how many are `remaining`, and its `choices`. `ui.dialog.confirm` takes `{"dialog": "discard", "choice": "save" | "dontSave" | "cancel"}` (default `save`; Revert takes `dontSave` or `cancel`) and answers it as the window's buttons do, one document at a time; `ui.dialog.cancel` with `{"dialog": "discard"}` cancels it. Save of an untitled document opens the save dialog first, so the reply's `fileDialog` is true and the prompt stays open until that is answered.
- A save dialog's answer that PhotoCraft changes, by giving a name without an extension the suggested file's or by lowercasing the extension (Preferences › File Handling › Lowercase Extension), opens a replace prompt when a file exists under the changed name: a dialog with `__replaceSaved`, `message`, `detail` and `path` (the file the save would replace). `ui.dialog.confirm` writes the file; `ui.dialog.cancel` ends the save without writing, as Cancel in the save dialog does. The file dialog counts as open until the prompt is answered. A name that differs only in letter case and reaches the same file (Windows and macOS by default) does not open it.
- Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor`
- Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart.
- Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`).
+2
View File
@@ -50,6 +50,7 @@ Dispatch order matches `dialogs::show` in `dialogs.rs` (check earlier rows befor
| `__stroke` | `stroke_ui.rs` | Edit › Stroke… |
| `__createShape` | `shape_dialog.rs` | Click-with-shape-tool creation (rectangle, ellipse, …) |
| `__deleteLayer` | `delete_layer_prompt.rs` | Layers panel trash confirmation |
| `__replaceSaved` | `replace_prompt.rs` | Replace prompt when a save's added or lowercased extension names an existing file |
| `__newLayer` | `new_layer_ui.rs` | Layer › New › Layer… (⇧⌘N; ⌥⇧⌘N makes the layer without it) |
| `__rasterize` | `rasterize_prompt.rs` | Rasterize-before-paint prompt |
| `__resetAllTools` | `tool_reset.rs` | Reset All Tools confirmation |
@@ -101,6 +102,7 @@ actions with their marker already set.
| `__stroke` | `stroke_ui` |
| `__createShape` | `shape_dialog` |
| `__deleteLayer` | `delete_layer_prompt` |
| `__replaceSaved` | `replace_prompt` |
| `__newLayer` | `new_layer_ui` |
| `__rasterize` | `rasterize_prompt` |
| `__resetAllTools` | `tool_reset` |