fix: load every selected Actions file

This commit is contained in:
yoriswind committed 2026-10-31 16:51:48 -07:00
1 parent 1f984bffb3
commit 9b1be2f526
2 files changed
+135 -2

No files matched your search

+56 -2
View File
@@ -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<Value, String> {
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<Value, String> {
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<V
Ok(r)
}
/// Load every chosen action file in order, retaining failures and unsupported-step warnings.
fn load_dialog(app: &mut PhotocraftApp) -> Result<Value, String> {
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
@@ -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<u8> {
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::<Vec<_>>(), ["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::<Vec<_>>(), ["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};