From 9b1be2f52693dbdc432be96f97cfa9ac5c45eacb Mon Sep 17 00:00:00 2001 From: yoriswind Date: Sat, 31 Oct 2026 16:51:48 -0700 Subject: [PATCH] fix: load every selected Actions file --- crates/ui-egui/src/panels/actions.rs | 58 +++++++++++++++- crates/ui-egui/src/panels/actions/tests.rs | 79 ++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index c1ba436d22..2a1b1d6e4c 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -11,6 +11,7 @@ use serde_json::{Value, json}; use photocraft_engine::actions_cmds::{self, Action}; use crate::PhotocraftApp; +use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; use crate::icons; use crate::theme::Tokens; @@ -372,7 +373,12 @@ fn source(name: &str, bytes: &[u8]) -> Value { /// Load a `.atn` file's bytes into the Actions panel (the panel menu's Load Actions…). pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result { - let r = app.run("actions.load", source(name, bytes))?; + load_source(app, name, source(name, bytes)) +} + +/// Import one set and reveal its first action, for both native paths and browser contents. +fn load_source(app: &mut PhotocraftApp, name: &str, params: Value) -> Result { + let r = app.run("actions.load", params)?; let n = r["actions"].as_array().map_or(0, Vec::len) as u64; let unsupported = r["unsupported"].as_array().map_or(0, Vec::len); let shown = crate::file_open::display_name(name); @@ -407,8 +413,56 @@ pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result Result { - app.pick_file_bytes_filtered(&["atn"], |app, name, bytes| load_bytes(app, &name, &bytes)) + let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); + app.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: Some(vec!["atn".into()]) }, |app, answer| { + let files: Vec<_> = match answer { + FileDialogAnswer::Paths(paths) => paths.into_iter().map(|name| (name, None)).collect(), + FileDialogAnswer::Contents(name, bytes) => vec![(name, Some(bytes))], + FileDialogAnswer::ContentsMany(files) => files.into_iter().map(|(name, bytes)| (name, Some(bytes))).collect(), + _ => return Err("the file dialog gave an unexpected answer".into()), + }; + let count = files.len(); + if count == 0 { + return Err(crate::file_dialog::CANCELLED.into()); + } + let (mut loaded, mut actions, mut unsupported) = (0usize, 0usize, 0usize); + let mut errors = Vec::new(); + let mut last = Value::Null; + for (name, bytes) in files { + // Native paths go through the engine's size check before reading the file. + let params = match bytes { + Some(bytes) => source(&name, &bytes), + None => json!({"path": name}), + }; + match load_source(app, &name, params) { + Ok(r) => { + loaded += 1; + actions = actions.saturating_add(r["actions"].as_array().map_or(0, Vec::len)); + unsupported = unsupported.saturating_add(r["unsupported"].as_array().map_or(0, Vec::len)); + last = r; + } + Err(e) => errors.push(format!("{}: {e}", crate::file_open::display_name(&name))), + } + } + if count == 1 { + return match errors.pop() { + Some(e) => Err(e), + None => Ok(last), + }; + } + let template = "Action files loaded: {files}; actions: {actions}; unsupported steps: {unsupported}"; + let summary = + crate::i18n::fmt(tl!(template), &[("files", &loaded.to_string()), ("actions", &actions.to_string()), ("unsupported", &unsupported.to_string())]); + if !errors.is_empty() { + let template = "{summary}; files failed: {count}. {errors}"; + return Err(crate::i18n::fmt(tl!(template), &[("summary", &summary), ("count", &errors.len().to_string()), ("errors", &errors.join("; "))])); + } + app.ui.status = summary; + app.ui.status_error = false; + Ok(json!({"files": loaded, "actions": actions, "unsupported": unsupported})) + }) } /// Save Actions…: the selected action's set (every action of it), or every action when none diff --git a/crates/ui-egui/src/panels/actions/tests.rs b/crates/ui-egui/src/panels/actions/tests.rs index a2f7629513..5270662206 100644 --- a/crates/ui-egui/src/panels/actions/tests.rs +++ b/crates/ui-egui/src/panels/actions/tests.rs @@ -1,5 +1,84 @@ use super::*; +/// A small valid Photoshop set, generated without external action files. +fn action_set_bytes(set: &str, action: &str) -> Vec { + use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; + use photocraft_psd::descriptor::UnicodeString; + photocraft_psd::atn::write(&AtnFile { + name: UnicodeString::new_nul(set), + actions: vec![AtnAction { + name: UnicodeString::new_nul(action), + items: vec![AtnItem { event: AtnEvent::Code(*b"Invr"), dictionary: "Invert".into(), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }) + .unwrap() +} + +#[test] +/// Browser multi-selection must not be truncated to the first file. +fn bulk_load_imports_every_chosen_action_file() { + use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; + let files = vec![("first.atn".into(), action_set_bytes("First set", "First")), ("second.atn".into(), action_set_bytes("Second set", "Second"))]; + let (show, asked) = crate::file_dialog::fake(vec![Some(FileDialogAnswer::ContentsMany(files))]); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["First", "Second"]); + assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Open { multiple: true, extensions: Some(ext), .. }] if ext == &["atn"])); + assert!(!app.file_dialog_open()); + assert!(!app.ui.status_error, "{}", app.ui.status); +} + +#[test] +/// A corrupt middle file cannot hide warnings or prevent a later set replacement. +fn bulk_load_keeps_selection_order_and_earlier_warnings_and_errors() { + use crate::file_dialog::FileDialogAnswer; + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("z-first.atn"); + let broken = dir.path().join("broken.atn"); + let last = dir.path().join("a-last.atn"); + let mut unsupported = photocraft_psd::atn::parse(&action_set_bytes("Same set", "Old")).unwrap(); + unsupported.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx"); + std::fs::write(&first, photocraft_psd::atn::write(&unsupported).unwrap()).unwrap(); + std::fs::write(&broken, b"not an action file").unwrap(); + std::fs::write(&last, action_set_bytes("Same set", "Replacement")).unwrap(); + let paths = [first, broken, last].map(|p| p.to_string_lossy().into_owned()).to_vec(); + let mut app = disk_app(vec![Some(FileDialogAnswer::Paths(paths))]); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::>(), ["Replacement"]); + assert_eq!(app.ui.actions.selected, Some(0)); + assert!(app.ui.status_error, "a later success must not hide an earlier failure"); + for part in ["files loaded: 2", "unsupported steps: 1", "files failed: 1", "broken.atn"] { + assert!(app.ui.status.contains(part), "{part}: {}", app.ui.status); + } +} + +#[test] +/// Dismissing the picker is inert; one selected file keeps its existing status message. +fn bulk_load_keeps_cancel_empty_and_single_file_behavior() { + use crate::file_dialog::FileDialogAnswer; + for answer in [None, Some(FileDialogAnswer::Paths(Vec::new())), Some(FileDialogAnswer::ContentsMany(Vec::new()))] { + let mut app = disk_app(vec![answer]); + app.session.actions.list.push(Action::new("Existing", vec![])); + app.ui.actions.selected = Some(0); + app.ui.status = "Unchanged".into(); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list[0].name, "Existing"); + assert_eq!(app.ui.actions.selected, Some(0)); + assert_eq!(app.ui.status, "Unchanged"); + } + let mut app = disk_app(vec![Some(FileDialogAnswer::Contents("single.atn".into(), action_set_bytes("Single", "One")))]); + load_dialog(&mut app).unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + assert_eq!(app.session.actions.list[0].name, "One"); + assert!(app.ui.status.contains("single.atn")); + assert!(!app.ui.status_error); +} + #[test] fn panel_menu_lists_load_save_and_existing_action_commands() { use egui_kittest::{Harness, kittest::Queryable};