Browse / github.com/storytold/photocraft / patch
Save: preserve an existing file's extension case
Save: preserve an existing file's extension case ## What was wrong On a case-sensitive volume (reported on NTFS mounted through Arch Linux), saving an opened `photo.JPG` did this: 1. The native save dialog correctly asked to replace `photo.JPG`. 2. PhotoCraft then applied **Lowercase Extension** and wrote `photo.jpg`. 3. The original remained, leaving two files instead of overwriting the selected one. `pick_save` normalized every accepted extension, including paths that already existed on disk. ## What changed - Keep the exact path spelling when the file selected in the save dialog already exists. - Continue applying **Lowe
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- AlanRockefeller (imported from pull request #9899, open)
- Base
52f338e2df44ac73c0bf231fa8d3f8cd496641ec- Size
- +62 −32 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
990c731292285ec986ccb2ce
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/dialogs/replace_prompt.rs crates/ui-egui/src/file_dialog.rs crates/ui-egui/src/file_dialog/tests.rs crates/ui-egui/src/services.rs docs/control-protocol.md
@@ -1,8 +1,9 @@ //! 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.+//! only about the name it answers, and the app can change a new name afterwards: it gives a name+//! without an extension the suggested file's (#5039), and lowercases a new file's extension when+//! Preferences ▸ File Handling › Lowercase Extension is on. An existing file keeps the exact+//! spelling the dialog confirmed. When a file exists under a 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@@ -138,10 +138,12 @@ 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). 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`).+ /// Ask where to save: `then` gets the chosen path, with a lowercase extension for a new file+ /// when Preferences ▸ File Handling › Lowercase Extension is on (its default). An existing+ /// file keeps the spelling the save dialog confirmed, so it is actually replaced on a file+ /// system where names differing only by case are distinct. When lowercasing a new name, or+ /// adding an extension 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@@ -206,9 +208,12 @@ impl PhotocraftApp { } } - /// `X.PSD` → `X.psd`, only the extension: the stem and the folders keep the user's spelling.+ /// `X.PSD` → `X.psd` for a new file, only the extension: the stem and the folders keep the+ /// user's spelling. A file the save dialog found already keeps its exact name: the dialog has+ /// just asked whether to replace that entry, and changing its case afterwards can target a+ /// different entry on a case-sensitive volume. fn lowercased_extension(app: &PhotocraftApp, path: String) -> String {- if !app.session.prefs().file_handling.lowercase_extension {+ if !app.session.prefs().file_handling.lowercase_extension || app.services.file_stamp.as_ref().is_some_and(|stamp| stamp(&path).is_some()) { return path; } let p = std::path::Path::new(&path);@@ -359,8 +364,9 @@ impl PhotocraftApp { } } -/// 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+/// The path a save writes to for the path the save dialog answered: a new file's extension is+/// lowercased when Preferences ▸ File Handling › Lowercase Extension is on, while an existing+/// file keeps the spelling the dialog confirmed. A name without an extension gets /// `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());@@ -489,7 +489,7 @@ fn the_app_keeps_running_while_a_dialog_is_open() { assert!(!app.file_dialog_open()); } -/// Preferences ▸ File Handling ▸ Lowercase Extension (default on): the chosen path's extension+/// Preferences ▸ File Handling ▸ Lowercase Extension (default on): a new chosen path's extension /// is lowercased on the way to the writer; unchecked, the user's spelling is kept. #[test] fn save_paths_get_a_lowercase_extension_when_the_preference_is_on() {@@ -591,18 +591,15 @@ fn a_save_asks_before_replacing_the_file_its_added_extension_names() { 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.+/// Lowercase Extension changes a new `CAT.PSD` to `CAT.psd`. If that lowercase path already+/// exists, the app asks before replacing it; an existing `CAT.PSD` itself keeps its spelling. #[test] fn a_save_asks_before_replacing_the_file_its_lowercased_extension_names() { {- // Two files, even with the same size and modification time.+ // Only the lowercase target exists: lowercasing the new name would replace it. 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);- }+ changed_by_another_app(&disk, "/pics/CAT.psd"); 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");@@ -610,15 +607,41 @@ fn a_save_asks_before_replacing_the_file_its_lowercased_extension_names() { 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);+ {+ // The save dialog selected an existing uppercase entry. Keep the path it confirmed,+ // rather than silently writing a second lowercase entry on a case-sensitive volume.+ let (mut app, open, written) = app();+ let disk = on_disk(&mut app);+ changed_by_another_app(&disk, "/pics/CAT.PSD");+ app.services.same_file = Some(Box::new(|a: &str, b: &str| a == b));+ save_as(&mut app, &open, "/pics/CAT.PSD");+ assert!(replace_prompt(&app).is_none(), "the dialog already asked about this file");+ assert_eq!(*written.borrow(), ["/pics/CAT.PSD"]); }- 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"]);+}++/// Opening an uppercase JPEG and confirming its own name in File › Save overwrites that exact+/// entry. Before this regression test, the native dialog confirmed `photo.JPG`, then PhotoCraft+/// wrote `photo.jpg`, leaving both files on a case-sensitive NTFS mount.+#[test]+fn plain_save_overwrites_an_existing_uppercase_extension_without_renaming_it() {+ let ctx = egui::Context::default();+ let path = "/pics/photo.JPG";+ let (mut app, open, written) = jpeg_app(None, false);+ let disk = on_disk(&mut app);+ changed_by_another_app(&disk, path);+ app.opened_from(path);++ menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap();+ app.poll_file_dialog(&ctx, None);+ answer(&open, Some(FileDialogAnswer::SaveTo(path.into())));+ app.poll_file_dialog(&ctx, None);++ assert_eq!(app.jpeg_options.as_ref().map(|p| p.path.as_str()), Some(path));+ crate::jpeg_options_ui::confirm(&mut app, &ctx);+ assert_eq!(*written.borrow(), [path]);+ assert_eq!(app.session.active().unwrap().path.as_deref(), Some(path));+ assert!(!disk.borrow().contains_key("/pics/photo.jpg")); } /// The save dialog asked about the file it answered: a bare name with no file under the added@@ -337,9 +337,9 @@ pub struct Services { /// writes back to a file. pub file_stamp: Option<FileStampFn>, /// Whether two paths name one entry in one folder (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 hard link is another entry. A save asks before replacing an entry it finds- /// different (`replace_prompt`).+ /// path the save writes to after adding an extension or lowercasing a new file's extension,+ /// on a file system that ignores letter case. A hard link is another entry. A save asks+ /// before replacing an entry it finds different (`replace_prompt`). pub same_file: Option<SameFileFn>, /// `write` only asks for the bytes to be kept (a browser download, which the user can /// cancel and which reports nothing back), so a save through it names the document but@@ -68,7 +68,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.+ - A save dialog's answer that PhotoCraft changes, by giving a name without an extension the suggested file's or by lowercasing a new file's 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. An existing file keeps the exact spelling the dialog confirmed, so overwriting `photo.JPG` cannot create a separate `photo.jpg` on a case-sensitive volume. - 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` - Gradient Overlay exposes `params.method` (`smooth`, `perceptual`, `linear`, `classic`). `custom` denotes an existing curve without a recorded method and preserves it. Choosing a method retains all recorded source colour stops. If no source record exists (including baked PSD imports), choosing a method rebuilds from the two end colours, matching legacy command behaviour; the old curve stays unchanged until that choice. Later endpoint edits rebuild the colour curve using the recorded method. The source stops, smoothness and method survive dialog reopening, undo/redo and native `.pcraft` saves. Saving a style preset through "New Style…" may lose the multi-stop source curve: it passes only params to `style.presets.new`, and `effect_from_params` rebuilds the effect from the default gradient. PSD export continues to write the baked appearance; reopening that PSD shows Custom because editable interpolation metadata is not exported yet. Opacity interpolation and the multi-stop editor are separate work. - 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.