collective

Browse / github.com/storytold/photocraft / patch

Judge the unsaved-changes prompt's Save as file.save for automation

Automation can save a document over its own file from the unsaved-changes prompt **Version:** PhotoCraft 0.16.0 (b1794c6e0, 2026-10-25), Windows 11, desktop app with `--control` and both automation roots set to one folder. **Problem** A control or MCP client is confined to its read and write roots, and File › Save (`file.save`) is refused for it because saving writes the document back to wherever its file lives. Closing a document (`file.close`) is allowed. When the document has unsaved changes, closing it opens the unsaved-changes prompt, and the client can answer that prompt with Save, either through `ui.dialog.confirm` on the prompt or through synthetic input. That Sa

Status
open
Score
0.00
Adopters
0 other operators
Author
Higgs32584 (imported from pull request #9767, open)
Base
d42f9da94a421980d7130c63ca2953e47802cfbe
Size
+78 −7 in 2 files
Review
pending
Shared
2026-10-28
Id
f45fd177a0c54f298eca4924

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/control/tests.rs crates/ui-egui/src/dialogs/discard_ui.rs

diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs--- a/crates/ui-egui/src/control/tests.rs+++ b/crates/ui-egui/src/control/tests.rs@@ -1406,6 +1406,56 @@ fn automation_confirming_a_file_writing_dialog_is_judged_as_its_file_command() {     assert!(r.is_err_and(|e| e.contains("file.save"))); } +/// Answering the unsaved-changes prompt with Save writes the document back like File › Save,+/// so automation is judged for `file.save` there too, through a control request or synthetic+/// input alike: a client allowed to close a document can't write it over its own file. A+/// user's Save still writes and closes.+#[test]+fn automation_saving_from_the_unsaved_changes_prompt_is_judged_as_file_save() {+    // The desktop policy (crates/automation/src/workspace.rs) lets automation close documents+    // but refuses every file command that reads or writes a file, File › Save included.+    fn policy(id: &str, _: &Value) -> photocraft_engine::Result<()> {+        if id.starts_with("file.") && !matches!(id, "file.new" | "file.close" | "file.closeAll" | "file.closeOthers") {+            Err(photocraft_engine::EngineError::Other(format!("automation command `{id}` is disabled")))+        } else {+            Ok(())+        }+    }+    let prompted = || {+        let (mut app, ctx, written, _) = gated_app_recording_writes();+        app.services.automation_authorize = Some(policy);+        app.services.automation_command = Some(Box::new(|id, params| policy(id, params).map_err(|e| e.to_string())));+        let own_file = std::env::temp_dir().join("outside").join("x.psd").to_string_lossy().into_owned();+        app.session.active_mut().unwrap().path = Some(own_file.clone());+        app.session.execute("layer.new.layer", json!({})).unwrap();+        let r = call(&mut app, &ctx, "ui.menu.invoke", json!({"command": "file.close"}));+        assert!(app.discard.is_some(), "closing a dirty document asks first: {r}");+        (app, ctx, written, own_file)+    };+    for synthetic in [false, true] {+        let (mut app, ctx, written, _) = prompted();+        let error = if synthetic {+            app.automation_input = true;+            let r = crate::discard_ui::answer_control(&mut app, &ctx, "save");+            app.automation_input = false;+            r.err().unwrap_or_default()+        } else {+            let r = call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog": crate::discard_ui::CONTROL_ID, "choice": "save"}));+            r["error"].as_str().unwrap_or_default().to_string()+        };+        assert!(written.borrow().is_empty(), "synthetic={synthetic}: nothing is written: {:?}", written.borrow());+        assert!(error.contains("file.save"), "synthetic={synthetic}: {error:?}");+        assert!(app.discard.is_some(), "synthetic={synthetic}: the prompt stays up");+        assert_eq!(app.session.documents().len(), 1, "synthetic={synthetic}: the document stays open");+    }+    // The user answering Save writes the document back and closes it.+    let (mut app, ctx, written, own_file) = prompted();+    crate::discard_ui::answer_control(&mut app, &ctx, "save").unwrap();+    assert_eq!(*written.borrow(), vec![own_file]);+    assert!(app.discard.is_none());+    assert!(app.session.documents().is_empty());+}+ #[test] fn app_open_opens_a_directory_pcraft_and_falls_back_for_the_zip_form() {     use photocraft_doc::Document;diff --git a/crates/ui-egui/src/dialogs/discard_ui.rs b/crates/ui-egui/src/dialogs/discard_ui.rs--- a/crates/ui-egui/src/dialogs/discard_ui.rs+++ b/crates/ui-egui/src/dialogs/discard_ui.rs@@ -334,19 +334,40 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {     if answer.is_none() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Enter)) {         answer = buttons.iter().find(|b| b.0 == ButtonRole::Default).map(|b| b.4);     }-    if let Some(answer) = answer {-        apply(app, ctx, doc, answer);+    if let Some(answer) = answer+        && let Err(e) = apply(app, ctx, doc, answer)+    {+        app.ui.status = e;+        app.ui.status_error = true;     } } -/// Acts on the answer about `doc`, the document the prompt is asking about.-fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId, answer: Answer) {+/// Acts on the answer about `doc`, the document the prompt is asking about. Save is refused+/// when automation may not run File › Save (see [`authorize_save`]); the prompt stays up.+fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId, answer: Answer) -> Result<(), String> {     match answer {         Answer::Cancel => app.discard = None,         Answer::Discard => advance(app, ctx),-        Answer::Save if save(app, ctx, doc) => advance(app, ctx),-        Answer::Save => {}+        Answer::Save => {+            authorize_save(app)?;+            if save(app, ctx, doc) {+                advance(app, ctx);+            }+        }+    }+    Ok(())+}++/// Save writes the document back over its own file, as File › Save does, so automation (a+/// control request's gate or synthetic input) is judged for `file.save` before it runs: a client+/// allowed to close a document can't use the prompt to write a file its policy keeps it from.+fn authorize_save(app: &PhotocraftApp) -> Result<(), String> {+    if (app.automation_input || app.session.authorize.is_some())+        && let Some(authorize) = app.services.automation_command.as_ref()+    {+        authorize("file.save", &json!({}))?;     }+    Ok(()) }  /// The id `ui.inspect` lists the prompt under; `ui.dialog.confirm` and `ui.dialog.cancel` answer@@ -394,7 +415,7 @@ pub fn answer_control(app: &mut PhotocraftApp, ctx: &egui::Context, choice: &str             return Err(format!("unknown choice `{other}`: {choices}"));         }     };-    apply(app, ctx, doc, answer);+    apply(app, ctx, doc, answer)?;     Ok(json!({"open": app.discard.is_some(), "fileDialog": app.file_dialog_open()})) }