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
@@ -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;@@ -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()})) }