collective

Browse / github.com/storytold/lightcraft / patch

LR-BEHAV-BG-TASKS: every other job says how it ended (stacked on #3150)

LR-BEHAV-BG-TASKS: every other job says how it ended (stacked on #3150) Stacked on #3150, which is stacked on #1711: waits for #1711, then #3150, then adds the app's other jobs. Own commit: `ae7fa0ffda` only. This part was ported across #3103's new module layout. ## Problem Synchronize Folder, Photo Merge, Lightroom catalog inspect and import, Find Missing Photos, the preview-only recheck and Reload from Disk leave nothing once their toast has gone: a Synchronize that could not do some files logs them and says "Failed 3"; which files and why was only in the log. Every other background task the app runs through `tasks::spawn` also left no record of how it went.

Status
open
Score
0.00
Adopters
0 other operators
Author
nledford (imported from pull request #3151, open)
Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0
Size
+1686 −222 in 67 files
Review
pending
Shared
2026-10-28
Id
6292ecbfe96d542236d0ceca

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/activity.rs crates/engine/src/cmd/activity.rs crates/engine/src/cmd/browse.rs crates/engine/src/cmd/geocode.rs crates/engine/src/cmd/previews.rs crates/engine/src/face_download.rs crates/engine/src/model_download.rs crates/engine/src/segment/download.rs crates/engine/src/tests.rs crates/engine/src/tests/folders.rs crates/ui-egui/locales/cs-formats.json crates/ui-egui/locales/cs.json crates/ui-egui/locales/de-formats.json crates/ui-egui/locales/de.json crates/ui-egui/locales/es-formats.json crates/ui-egui/locales/es.json crates/ui-egui/locales/fi-formats.json crates/ui-egui/locales/fi.json crates/ui-egui/locales/fr-formats.json crates/ui-egui/locales/fr.json crates/ui-egui/locales/id-formats.json crates/ui-egui/locales/id.json crates/ui-egui/locales/it-formats.json crates/ui-egui/locales/it.json crates/ui-egui/locales/ja-formats.json crates/ui-egui/locales/ja.json crates/ui-egui/locales/ka-formats.json crates/ui-egui/locales/ka.json crates/ui-egui/locales/pl-formats.json crates/ui-egui/locales/pl.json crates/ui-egui/locales/pt-br-formats.json crates/ui-egui/locales/pt-br.json crates/ui-egui/locales/ru-formats.json crates/ui-egui/locales/ru.json crates/ui-egui/locales/sv-formats.json crates/ui-egui/locales/sv.json crates/ui-egui/locales/uk-formats.json crates/ui-egui/locales/uk.json crates/ui-egui/locales/vi-formats.json crates/ui-egui/locales/vi.json crates/ui-egui/locales/zh-hans-formats.json crates/ui-egui/locales/zh-hans.json crates/ui-egui/locales/zh-hant-formats.json crates/ui-egui/locales/zh-hant.json crates/ui-egui/src/app/frame.rs crates/ui-egui/src/control.rs crates/ui-egui/src/dialogs/import.rs crates/ui-egui/src/dialogs/import/scan.rs crates/ui-egui/src/dialogs/import/task.rs crates/ui-egui/src/dialogs/merge.rs crates/ui-egui/src/dialogs/sam_model.rs crates/ui-egui/src/export_task.rs crates/ui-egui/src/lightroom_import.rs crates/ui-egui/src/menus/menubar/tests.rs crates/ui-egui/src/menus/run.rs crates/ui-egui/src/panels/library_problem.rs crates/ui-egui/src/recheck.rs crates/ui-egui/src/reload.rs crates/ui-egui/src/sync.rs crates/ui-egui/src/tasks.rs crates/ui-egui/src/tests/activity.rs crates/ui-egui/src/tests/print.rs crates/ui-egui/src/tests/recheck.rs crates/ui-egui/src/tests/sync.rs docs/background-tasks.md docs/control-protocol.md docs/parity-checklist.md

diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs--- a/crates/engine/src/activity.rs+++ b/crates/engine/src/activity.rs@@ -7,6 +7,11 @@ //! check between files or photos: the guard adopts it ([`Cancel::Flag`]), so the stack's ✕, `activity.cancel` and the //! job's own Cancel all set the same atomic. Numbers are worded by the frontend from `done`, `total` and [`Unit`]; a //! detail set here is a name (a file, a model), never a sentence, so the frontend can translate everything else.+//!+//! A job that says how it ended ([`TaskGuard::finish`]) leaves a row behind when there is something to come back+//! to: it ran long enough to walk away from, or it did not go well. That row stays until it is dismissed+//! (`activity.finished`, `activity.dismiss`), for this session. A guard dropped by a panic leaves "stopped+//! unexpectedly" instead of nothing.  use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError};@@ -15,6 +20,19 @@ use serde::Serialize;  /// The longest label or detail kept, in characters (a file name can be anything). const MAX_TEXT: usize = 200;+/// The longest sentence kept of how a task ended, or of why a file failed.+const MAX_SENTENCE: usize = 400;+/// A task that ended well leaves a row once it has run this long ([`Activity::keep_after`]); a shorter one was+/// watched to its end.+pub const KEEP_AFTER_MS: u64 = 10_000;+/// The most tasks that ended well kept: the oldest give way.+const MAX_DONE: usize = 50;+/// The most ended tasks kept in all. Those that did not go well are kept past [`MAX_DONE`], up to here.+const MAX_FINISHED: usize = 200;+/// The most files a task keeps the reason of ([`Finished::failed`] still counts them all).+pub const MAX_FAILURES: usize = 500;+/// The longest path kept of where a task's results are.+const MAX_PATH: usize = 4096;  /// The background tasks in flight. Cheap to clone (one `Arc`), so worker threads can hold it. #[derive(Clone, Default)]@@ -22,13 +40,118 @@ pub struct Activity {     inner: Arc<Inner>, } -#[derive(Default)] struct Inner {     /// Oldest first.     tasks: Mutex<Vec<Arc<Entry>>>,+    /// The tasks that ended and left a row, oldest first.+    finished: Mutex<Vec<Kept>>,+    keep_after_ms: AtomicU64,     next: AtomicU64, } +impl Default for Inner {+    fn default() -> Self {+        Inner { tasks: Mutex::default(), finished: Mutex::default(), keep_after_ms: AtomicU64::new(KEEP_AFTER_MS), next: AtomicU64::new(0) }+    }+}++/// How a task ended.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]+#[serde(rename_all = "camelCase")]+pub enum Ending {+    /// It did all it was asked to.+    #[default]+    Done,+    /// It ran to its end, but not for every file or photo.+    Problems,+    /// It could not do its work.+    Failed,+    /// It was stopped: ✕, `activity.cancel`, quitting.+    Stopped,+    /// Its worker died (a bug): the guard was dropped by a panic.+    Died,+}++impl Ending {+    /// Whether the person should be told: everything but a clean end.+    pub fn needs_attention(self) -> bool {+        self != Ending::Done+    }+}++/// A file or photo a task could not do, and why.+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]+pub struct Failure {+    pub name: String,+    pub reason: String,+}++/// What a job says of itself when it ends ([`TaskGuard::finish`]).+#[derive(Clone, Debug, Default)]+pub struct Outcome {+    pub ending: Ending,+    /// What happened, as the frontend shows it ("Exported 24 of 25 photos"). Frontend code passes it translated;+    /// engine code leaves it empty, and a frontend words the ending from the task's label.+    pub summary: String,+    /// What else needs reading: what was skipped, or the reason it failed.+    pub detail: String,+    /// The files or photos it could not do. The first [`MAX_FAILURES`] are kept.+    pub failures: Vec<Failure>,+    /// How many it could not do, for a job that counts them without naming them (`failures` counts when it holds+    /// more).+    pub failed: usize,+    /// One of the files it made (or the folder they are in), for showing where they are in the file manager.+    pub path: Option<String>,+}++impl Outcome {+    /// An end that says nothing more than how it went (the frontend words it from the task's name).+    pub fn ended(ending: Ending) -> Self {+        Outcome { ending, ..Default::default() }+    }++    /// The job could not do its work, and this is why.+    pub fn failed(why: impl Into<String>) -> Self {+        Outcome { ending: Ending::Failed, detail: why.into(), ..Default::default() }+    }+}++/// A task that ended, as the frontends see it (`activity.finished`), until it is dismissed.+#[derive(Clone, Debug, PartialEq, Serialize)]+#[serde(rename_all = "camelCase")]+pub struct Finished {+    /// The id it had while it ran.+    pub id: u64,+    pub kind: &'static str,+    /// English; the frontend translates it.+    pub label: String,+    pub ending: Ending,+    pub summary: String,+    pub detail: String,+    pub failures: Vec<Failure>,+    /// How many files or photos failed (`failures` may hold fewer, or none when the job only counted them).+    pub failed: usize,+    /// One of the files it made (or their folder), if it made any.+    pub path: Option<String>,+    /// How long it ran.+    pub ran_ms: u64,+    /// How long ago it ended.+    pub age_ms: u64,+}++struct Kept {+    row: Finished,+    ended: web_time::Instant,+}++fn millis(d: std::time::Duration) -> u64 {+    u64::try_from(d.as_millis()).unwrap_or(u64::MAX)+}++fn sentence(text: &str) -> String {+    text.chars().take(MAX_SENTENCE).collect()+}+ /// How a task can be cancelled. pub enum Cancel {     /// Not at all (no ✕): the face scan, AI Denoise.@@ -162,7 +285,7 @@ impl Entry {             detail: lock(&self.detail).clone(),             cancellable: self.is_cancellable(),             cancelling: self.is_cancelled(),-            age_ms: u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX),+            age_ms: millis(self.started.elapsed()),         }     } }@@ -191,7 +314,48 @@ impl Activity {             gate: Mutex::new(()),         });         lock(&self.inner.tasks).push(entry.clone());-        TaskGuard { activity: self.clone(), entry }+        TaskGuard { activity: self.clone(), entry, reported: false }+    }++    /// The tasks that ended and left a row, newest first.+    pub fn finished(&self) -> Vec<Finished> {+        lock(&self.inner.finished).iter().rev().map(|k| Finished { age_ms: millis(k.ended.elapsed()), ..k.row.clone() }).collect()+    }++    /// Let go of the row of the ended task `id`. An unknown id (it may have been dismissed already) is an error.+    pub fn dismiss(&self, id: u64) -> Result<(), String> {+        let mut finished = lock(&self.inner.finished);+        let at = finished.iter().position(|k| k.row.id == id).ok_or_else(|| format!("no finished task {id}"))?;+        finished.remove(at);+        Ok(())+    }++    /// Let go of every ended task's row; returns how many there were.+    pub fn dismiss_all(&self) -> usize {+        let mut finished = lock(&self.inner.finished);+        let n = finished.len();+        finished.clear();+        n+    }++    /// How long a task has to run for a clean end to leave a row ([`KEEP_AFTER_MS`] unless set).+    pub fn keep_after(&self, ms: u64) {+        self.inner.keep_after_ms.store(ms, Ordering::Relaxed);+    }++    /// Keep the row of a task that ended, and make room: of those that ended well the oldest give way first.+    fn keep(&self, row: Finished) {+        let mut finished = lock(&self.inner.finished);+        finished.push(Kept { row, ended: web_time::Instant::now() });+        while finished.iter().filter(|k| k.row.ending == Ending::Done).count() > MAX_DONE {+            let Some(at) = finished.iter().position(|k| k.row.ending == Ending::Done) else { break };+            finished.remove(at);+        }+        while finished.len() > MAX_FINISHED {+            // (a clean one goes before one that did not go well, as long as there is one)+            let at = finished.iter().position(|k| k.row.ending == Ending::Done).unwrap_or(0);+            finished.remove(at);+        }     }      /// Every task in flight, oldest first.@@ -223,6 +387,8 @@ impl Activity { pub struct TaskGuard {     activity: Activity,     entry: Arc<Entry>,+    /// The job said how it ended ([`TaskGuard::finish`]).+    reported: bool, }  impl TaskGuard {@@ -261,12 +427,61 @@ impl TaskGuard {     pub fn handle(&self) -> TaskHandle {         TaskHandle { entry: self.entry.clone() }     }++    /// The job ended, and this is how. Its row among the running tasks goes; a row among the finished ones takes+    /// its place when there is something to come back to: the task ran for [`KEEP_AFTER_MS`] or more, or it did+    /// not end well. Returns whether that row was kept. A frontend still says at once how the job went (a toast):+    /// the row is what stays, not the announcement.+    pub fn finish(mut self, outcome: Outcome) -> bool {+        self.report(outcome)+    }++    /// Say how the job ended, as [`TaskGuard::finish`] does, but keep its running row until the guard is dropped: for a+    /// job that marks itself finished in between, so that whoever sees the row go finds it finished, and whoever finds+    /// it finished finds the row. Only the first report counts.+    pub fn report(&mut self, outcome: Outcome) -> bool {+        if std::mem::replace(&mut self.reported, true) {+            return false;+        }+        self.leave(outcome)+    }++    fn leave(&self, outcome: Outcome) -> bool {+        let ran_ms = millis(self.entry.started.elapsed());+        if !outcome.ending.needs_attention() && ran_ms < self.activity.inner.keep_after_ms.load(Ordering::Relaxed) {+            return false;+        }+        let failed = outcome.failed.max(outcome.failures.len());+        self.activity.keep(Finished {+            id: self.entry.id,+            kind: self.entry.kind,+            label: self.entry.label.clone(),+            ending: outcome.ending,+            summary: sentence(&outcome.summary),+            detail: sentence(&outcome.detail),+            failures: outcome+                .failures+                .into_iter()+                .take(MAX_FAILURES)+                .map(|f| Failure { name: capped(&f.name), reason: sentence(&f.reason) })+                .collect(),+            failed,+            path: outcome.path.map(|path| path.chars().take(MAX_PATH).collect()),+            ran_ms,+            age_ms: 0,+        });+        true+    } }  impl Drop for TaskGuard {     fn drop(&mut self) {         let id = self.entry.id;         lock(&self.activity.inner.tasks).retain(|e| e.id != id);+        // a worker that died never said how it ended: say that it stopped, rather than nothing+        if !self.reported && std::thread::panicking() {+            self.leave(Outcome { ending: Ending::Died, ..Default::default() });+        }     } } @@ -309,6 +524,11 @@ mod tests {     use std::sync::Arc;     use std::sync::atomic::{AtomicBool, Ordering}; +    /// How many ended tasks did not end well.+    fn problems(a: &Activity) -> usize {+        a.finished().iter().filter(|f| f.ending.needs_attention()).count()+    }+     #[test]     fn guard_drop_removes_the_row() {         let a = Activity::default();@@ -318,6 +538,127 @@ mod tests {         assert!(a.list().is_empty());     } +    #[test]+    fn a_long_task_that_ends_well_leaves_a_row_and_a_short_one_does_not() {+        let a = Activity::default();+        // Given a task that ran for less than it takes to walk away+        let quick = a.start("export", "Exporting", Cancel::Yes);+        // When it ends well, Then nothing is kept: it was watched to its end+        assert!(!quick.finish(Outcome { summary: "Exported 2 photos".into(), ..Default::default() }));+        assert!(a.finished().is_empty() && a.list().is_empty());+        // Given a task that ran long enough (here: any time at all)+        a.keep_after(0);+        let long = a.start("export", "Exporting", Cancel::Yes);+        let id = long.id();+        assert!(long.finish(Outcome { summary: "Exported 5,000 photos".into(), path: Some("/out/a.jpg".into()), ..Default::default() }));+        // Then its row among the running tasks is gone, and one among the finished took its place+        assert!(a.list().is_empty());+        let rows = a.finished();+        assert_eq!(rows.len(), 1);+        assert_eq!((rows[0].id, rows[0].kind, rows[0].ending, rows[0].summary.as_str()), (id, "export", Ending::Done, "Exported 5,000 photos"));+        assert_eq!(rows[0].path.as_deref(), Some("/out/a.jpg"));+        assert_eq!(a.finished().first().map(|f| (f.id, f.summary.clone())), Some((id, "Exported 5,000 photos".to_string())));+        // And a clean end asks for nobody's attention+        assert_eq!(problems(&a), 0);+    }++    #[test]+    fn a_task_that_did_not_end_well_always_leaves_a_row_and_asks_for_attention_until_dismissed() {+        let a = Activity::default();+        for (ending, n) in [(Ending::Problems, 1), (Ending::Failed, 2), (Ending::Stopped, 3)] {+            let g = a.start("import", "Importing", Cancel::Yes);+            assert!(g.finish(Outcome { ending, ..Default::default() }), "{ending:?} is kept however short the task");+            assert_eq!(problems(&a), n);+        }+        // newest first+        let rows = a.finished();+        assert_eq!(rows.iter().map(|f| f.ending).collect::<Vec<_>>(), vec![Ending::Stopped, Ending::Failed, Ending::Problems]);+        // each asks until it is dismissed, not until it has been looked at+        a.dismiss(rows[1].id).unwrap();+        assert_eq!(problems(&a), 2);+        a.dismiss_all();+        assert_eq!(problems(&a), 0);+    }++    #[test]+    fn finished_rows_are_dismissed_one_by_one_or_all_at_once() {+        let a = Activity::default();+        let ids: Vec<u64> = (0..3)+            .map(|_| {+                let g = a.start("export", "Exporting", Cancel::Yes);+                let id = g.id();+                g.finish(Outcome { ending: Ending::Failed, ..Default::default() });+                id+            })+            .collect();+        a.dismiss(ids[1]).unwrap();+        assert_eq!(a.finished().iter().map(|f| f.id).collect::<Vec<_>>(), vec![ids[2], ids[0]]);+        assert!(a.dismiss(ids[1]).is_err(), "an error, not a panic, for a row already gone");+        assert_eq!(a.dismiss_all(), 2);+        assert!(a.finished().is_empty());+        assert_eq!(a.dismiss_all(), 0);+    }++    #[test]+    fn what_is_kept_of_an_ending_is_bounded() {+        let a = Activity::default();+        a.keep_after(0);+        // a job with more failures than are kept, long names and long reasons+        let g = a.start("export", "Exporting", Cancel::Yes);+        let failures: Vec<Failure> =+            (0..MAX_FAILURES + 40).map(|i| Failure { name: format!("{i}{}", "é".repeat(300)), reason: "x".repeat(1000) }).collect();+        g.finish(Outcome {+            ending: Ending::Problems,+            summary: "s".repeat(5000),+            detail: "d".repeat(5000),+            failures,+            path: Some("p".repeat(9000)),+            ..Default::default()+        });+        let row = &a.finished()[0];+        assert_eq!((row.failed, row.failures.len()), (MAX_FAILURES + 40, MAX_FAILURES), "all counted, the first kept");+        assert_eq!((row.summary.chars().count(), row.detail.chars().count()), (MAX_SENTENCE, MAX_SENTENCE));+        assert_eq!(row.path.as_ref().map(|p| p.chars().count()), Some(MAX_PATH));+        // a job that only counts what failed (an import) is believed+        let counted = a.start("import", "Importing", Cancel::Yes);+        counted.finish(Outcome { ending: Ending::Problems, failed: 3, ..Default::default() });+        assert_eq!((a.finished()[0].failed, a.finished()[0].failures.len()), (3, 0));+        a.dismiss(a.finished()[0].id).unwrap();+        assert!(row.failures.iter().all(|f| f.name.chars().count() == MAX_TEXT && f.reason.chars().count() == MAX_SENTENCE));+        // many clean endings: the oldest give way, the one that did not go well stays+        for i in 0..MAX_DONE + 10 {+            a.start("export", &format!("Task {i}"), Cancel::Yes).finish(Outcome::default());+        }+        let rows = a.finished();+        assert_eq!(rows.iter().filter(|f| f.ending == Ending::Done).count(), MAX_DONE);+        assert_eq!(rows.first().map(|f| f.label.as_str()), Some(format!("Task {}", MAX_DONE + 9).as_str()), "the newest is kept");+        assert!(rows.iter().any(|f| f.ending == Ending::Problems), "a row that needs looking at is not pushed out by clean ones");+        // and never without end: rows that did not go well are bounded too+        for _ in 0..MAX_FINISHED + 20 {+            a.start("import", "Importing", Cancel::Yes).finish(Outcome { ending: Ending::Failed, ..Default::default() });+        }+        assert_eq!(a.finished().len(), MAX_FINISHED);+    }++    #[test]+    fn a_worker_that_dies_leaves_a_row_saying_so() {+        let a = Activity::default();+        let a2 = a.clone();+        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {+            let _g = a2.start("previews", "Building previews", Cancel::Yes);+            panic!("synthetic worker panic");+        }));+        assert!(r.is_err());+        assert!(a.list().is_empty(), "no stuck bar");+        let rows = a.finished();+        assert_eq!(rows.len(), 1);+        assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].ending), ("previews", "Building previews", Ending::Died));+        assert_eq!(problems(&a), 1);+        // a guard that is simply dropped (a job that does not report yet) leaves nothing, as before+        drop(a.start("faces", "Finding faces", Cancel::No));+        assert_eq!(a.finished().len(), 1);+    }+     #[test]     fn guard_drop_on_panic_unwind_removes_the_row() {         let a = Activity::default();diff --git a/crates/engine/src/cmd/activity.rs b/crates/engine/src/cmd/activity.rs--- a/crates/engine/src/cmd/activity.rs+++ b/crates/engine/src/cmd/activity.rs@@ -1,4 +1,5 @@-//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]).+//! `activity.list` / `activity.cancel`: the background tasks in flight; `activity.finished` / `activity.dismiss`:+//! the ones that ended and left a row (see [`crate::activity`]).  use serde_json::{Value, json}; @@ -32,6 +33,32 @@ pub fn specs() -> Vec<CommandSpec> {                 Ok(json!({"cancelled": 1}))             }         ),+        cmd!(+            query "activity.finished",+            "Finished Background Tasks",+            [],+            None,+            "{} → {finished: [{id, kind, label, ending: done|problems|failed|stopped|died, summary, detail, failures: [{name, reason}], failed, path, ranMs, ageMs}]} — the tasks that ended and left a row, newest first: those that ran 10 s or more, and every one that did not end well (problems = not for every file, stopped = cancelled, died = its worker crashed). `failed` counts the files that failed; `failures` holds the first 500 with their reasons (none for a job that only counts them); `path` is one of the files it made. `summary` and `detail` are as the app shows them, in its language, and `summary` is empty for a job that says nothing of itself. At most 50 clean endings and 200 rows in all are kept. They stay until dismissed, for this session",+            always,+            |s, _| Ok(json!({"finished": s.activity.finished()}))+        ),+        cmd!(+            "activity.dismiss",+            "Dismiss Finished Task",+            [],+            None,+            "{id} | {all: true} → {dismissed: n} — let go of a finished task's row (or of all of them). An unknown id (it may have been dismissed already) is an error",+            always,+            |s, p| {+                const C: &str = "activity.dismiss";+                if p.get("all").and_then(Value::as_bool) == Some(true) {+                    return Ok(json!({"dismissed": s.activity.dismiss_all()}));+                }+                let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?;+                s.activity.dismiss(id).map_err(|e| bad(C, e))?;+                Ok(json!({"dismissed": 1}))+            }+        ),     ] } @@ -54,6 +81,30 @@ mod tests {         assert!(g.is_cancelled());     } +    #[test]+    fn finished_and_dismiss_through_execute() {+        use crate::activity::{Ending, Failure, Outcome};+        let mut s = Session::new();+        let g = s.activity.start("export", "Exporting", Cancel::Yes);+        let id = g.id();+        g.finish(Outcome {+            ending: Ending::Problems,+            summary: "Exported 1 of 2 photos".into(),+            failures: vec![Failure { name: "IMG_2.jpg".into(), reason: "The disk is full".into() }],+            ..Default::default()+        });+        let r = s.execute("activity.finished", &json!({})).unwrap();+        assert_eq!(r["finished"][0]["id"], id);+        assert_eq!(r["finished"][0]["ending"], "problems");+        assert_eq!(r["finished"][0]["failures"][0], json!({"name": "IMG_2.jpg", "reason": "The disk is full"}));+        assert_eq!(r["finished"][0]["failed"], 1);+        assert!(s.execute("activity.dismiss", &json!({})).is_err());+        assert!(s.execute("activity.dismiss", &json!({"id": 999})).is_err());+        assert_eq!(s.execute("activity.dismiss", &json!({"id": id})).unwrap()["dismissed"], 1);+        assert_eq!(s.execute("activity.dismiss", &json!({"all": true})).unwrap()["dismissed"], 0);+        assert_eq!(s.execute("activity.finished", &json!({})).unwrap()["finished"], json!([]));+    }+     #[test]     fn cancel_all_and_errors() {         let mut s = Session::new();diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs--- a/crates/engine/src/cmd/browse.rs+++ b/crates/engine/src/cmd/browse.rs@@ -471,9 +471,14 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {         return Ok(json!({"moved": 0, "folder": folder}));     }     let plural = |k: usize| if k == 1 { "" } else { "s" };-    let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);+    let task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);     let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string());-    s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?;+    let moved = s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)));+    task.finish(match &moved {+        Ok(_) => crate::activity::Outcome::default(),+        Err(e) => crate::activity::Outcome::failed(e.to_string()),+    });+    moved?;     Ok(json!({"moved": n, "folder": folder})) } diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs--- a/crates/engine/src/cmd/geocode.rs+++ b/crates/engine/src/cmd/geocode.rs@@ -149,6 +149,9 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {     for (n, (id, at)) in todo.iter().enumerate() {         if let Some(t) = &task {             if t.cancel_flag().is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) {+                if let Some(t) = task {+                    t.finish(crate::activity::Outcome::ended(crate::activity::Ending::Stopped));+                }                 return Err(bad(C, "cancelled"));             }             t.progress(n as u64, todo.len() as u64);@@ -172,11 +175,15 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {         }         found.push(entry);     }-    drop(task);     let changed = ops.len();-    if !bool_or(p, "dryRun", false) && changed > 0 {-        s.commit("Reverse Geocode", Op::Batch { ops })?;+    let committed = if !bool_or(p, "dryRun", false) && changed > 0 { s.commit("Reverse Geocode", Op::Batch { ops }) } else { Ok(()) };+    if let Some(t) = task {+        t.finish(match &committed {+            Ok(_) => crate::activity::Outcome::default(),+            Err(e) => crate::activity::Outcome::failed(e.to_string()),+        });     }+    committed?;     Ok(json!({"looked": todo.len(), "found": found.len(), "changed": changed, "photos": found, "attribution": GEONAMES_ATTRIBUTION})) } @@ -261,9 +268,9 @@ mod download {         let thread_dir = dir.clone();         let thread_shared = shared.clone();         let spawned = std::thread::Builder::new().name("place-names-download".into()).spawn(move || {-            // the activity row goes when the thread ends, however it ends-            let _task = task;+            // the activity row says how it ended when the thread ends, however it ends             let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle)));+            let panicked = outcome.is_err();             let outcome = match outcome {                 Ok(Ok(n)) => DownloadOutcome::Done(n),                 _ if cancel.load(std::sync::atomic::Ordering::Relaxed) => DownloadOutcome::Cancelled,@@ -273,10 +280,19 @@ mod download {             if let DownloadOutcome::Failed(e) = &outcome {                 log::warn!("{e}");             }+            let ended = match &outcome {+                _ if panicked => crate::activity::Outcome::ended(crate::activity::Ending::Died),+                DownloadOutcome::Done(_) => crate::activity::Outcome::default(),+                DownloadOutcome::Cancelled => crate::activity::Outcome::ended(crate::activity::Ending::Stopped),+                DownloadOutcome::Failed(why) => crate::activity::Outcome::failed(why.clone()),+            };             let mut sh = thread_shared.lock().unwrap_or_else(PoisonError::into_inner);             sh.running = false;             sh.last = Some(outcome.clone());             sh.unreported = Some(outcome);+            drop(sh);+            // last: whoever sees the row go finds the download over+            task.finish(ended);         });         if let Err(e) = spawned {             shared.lock().unwrap_or_else(PoisonError::into_inner).running = false;diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs--- a/crates/engine/src/cmd/previews.rs+++ b/crates/engine/src/cmd/previews.rs@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use serde_json::{Value, json};  use super::{CommandSpec, always, bad, cmd, str_param};-use crate::activity::{Cancel, TaskGuard};+use crate::activity::{Cancel, Ending, Outcome, TaskGuard}; use crate::media::{RenderJob, THUMB_SIZES}; use crate::{Result, Session}; @@ -43,7 +43,7 @@ impl PreviewBuild {             "total": self.total,             "done": self.done.load(Ordering::Relaxed),             "failed": self.failed.load(Ordering::Relaxed),-            "running": !self.finished.load(Ordering::Relaxed),+            "running": !self.finished.load(Ordering::Acquire),             "cancelled": self.cancel.load(Ordering::Relaxed),             "what": if self.what.is_empty() { "previews" } else { self.what },             "repaired": self.repaired.load(Ordering::Relaxed),@@ -54,6 +54,30 @@ impl PreviewBuild {     pub fn error(&self) -> Option<String> {         self.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()     }++    /// How the build ended, for its row: it failed as a whole, was stopped, or could not make some photos' previews.+    fn outcome(&self) -> Outcome {+        let failed = self.failed.load(Ordering::Relaxed);+        if let Some(why) = self.error() {+            return Outcome { failed, ..Outcome::failed(why) };+        }+        let ending = if self.cancel.load(Ordering::Relaxed) {+            Ending::Stopped+        } else if failed > 0 {+            Ending::Problems+        } else {+            Ending::Done+        };+        Outcome { ending, failed, ..Default::default() }+    }++    /// The build is over: the row of how it went is kept, the build reads as finished, and only then does its running+    /// row go (whoever waits for `finished` finds the row; whoever sees the running row go finds the build finished).+    fn end(&self, mut task: TaskGuard) {+        task.report(self.outcome());+        self.finished.store(true, Ordering::Release);+        drop(task);+    } }  /// The jobs that build `id`'s previews: the largest grid thumbnail and the view render at `edge`@@ -226,7 +250,7 @@ pub(crate) mod test_gate {     } } -fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, lanes: usize) {+fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: TaskGuard, lanes: usize) {     let total = jobs.len();     let queue = std::sync::Mutex::new(jobs.into_iter());     let work = || {@@ -269,7 +293,7 @@ fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, la         let _ = lanes;         work();     }-    state.finished.store(true, Ordering::Relaxed);+    state.end(task); }  fn build(s: &mut Session, p: &Value) -> Result<Value> {@@ -282,7 +306,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {         "full" | "1:1" => None,         other => return Err(bad(C, format!("unknown size `{other}` (standard|full)"))),     };-    if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {+    if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Acquire)) {         return Err(bad(C, "a preview build is already running"));     }     // explicit ids, else the selection, else everything in view@@ -297,7 +321,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {     let lanes = lanes(jobs.len(), edge.is_none());     let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32");     if wait {-        run_all(jobs, &state, &task, lanes);+        run_all(jobs, &state, task, lanes);         return Ok(state.json());     }     #[cfg(not(target_arch = "wasm32"))]@@ -306,7 +330,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {         let st = state.clone();         std::thread::Builder::new()             .name("lc-build-previews".into())-            .spawn(move || run_all(jobs, &st, &task, lanes))+            .spawn(move || run_all(jobs, &st, task, lanes))             .map_err(|e| bad(C, format!("could not start: {e}")))?;     }     Ok(state.json())@@ -471,7 +495,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {     let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?;     let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);     let background = p.get("background").and_then(Value::as_bool).unwrap_or(false);-    if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {+    if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Acquire)) {         return Err(bad(C, "a preview build is already running"));     }     let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };@@ -499,7 +523,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {                     log::warn!("{C}: {e}");                     *st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);                 }-                st.finished.store(true, Ordering::Relaxed);+                st.end(task);             })             .map_err(|e| bad(C, format!("could not start: {e}")))?;         return Ok(state.json());diff --git a/crates/engine/src/face_download.rs b/crates/engine/src/face_download.rs--- a/crates/engine/src/face_download.rs+++ b/crates/engine/src/face_download.rs@@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; -use crate::activity::{Activity, Cancel, TaskHandle, Unit};+use crate::activity::{Activity, Cancel, Ending, Outcome, TaskHandle, Unit};  use lightcraft_faces::known::Download; @@ -94,12 +94,12 @@ impl Downloads {         std::thread::Builder::new()             .name("face-model-download".into())             .spawn(move || {-                // the row goes when the thread ends, however it ends-                let _task = task;+                // the row says how it ended when the thread ends, however it ends                 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, spec.base(), &staging, &worker)));                 if outcome.is_err() {                     worker.set(State::Failed("the download stopped unexpectedly".into()));                 }+                task.finish(if outcome.is_err() { Outcome::ended(Ending::Died) } else { ended(&worker.get()) });             })             .map_err(|e| format!("could not start the download: {e}"))?;         self.jobs.insert(id, shared);@@ -222,6 +222,17 @@ fn fetch(_: &Download, _: &str, _: &Path, _: &Shared) -> Result<(), String> {     Err("the web build downloads no models".into()) } +/// How a download that ran ended, for its row.+fn ended(state: &State) -> Outcome {+    match state {+        State::Done { .. } | State::Installed => Outcome::default(),+        State::Failed(why) => Outcome::failed(why.clone()),+        State::Cancelled => Outcome::ended(Ending::Stopped),+        // (the thread ended without saying how)+        State::Running { .. } => Outcome::ended(Ending::Died),+    }+}+ /// The whole job: reuse a verified earlier copy, else fetch, check and stage. Always ends in a final [`State`]. fn run(spec: &Download, mirror: &str, staging: &Path, shared: &Shared) {     let target = staging.join(&spec.file_name);@@ -355,6 +366,23 @@ mod tests {         wait("the download never stopped", || !d.running());         assert_eq!(d.jobs["test-model"].get(), State::Cancelled);         wait("the row stayed", || activity.list().is_empty());+        assert_eq!(activity.finished().first().map(|f| f.ending), Some(crate::activity::Ending::Stopped), "a stopped download says so");+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn a_download_that_failed_leaves_a_row_with_the_reason() {+        use crate::activity::{Activity, Ending};+        let dir = scratch("failed-row");+        let activity = Activity::default();+        let mut d = Downloads::default();+        let mut spec = spec_for(b"abc");+        spec.url = format!("{}/{}", serve("404 Not Found", b"not found".to_vec()), spec.file_name);+        d.start(spec, &dir, &activity, "Test Model").unwrap();+        wait("the failed download's row never ended", || activity.list().is_empty() && !activity.finished().is_empty());+        let row = activity.finished().remove(0);+        assert_eq!((row.kind, row.ending), ("download", Ending::Failed));+        assert!(!row.detail.is_empty(), "the reason is kept");         let _ = std::fs::remove_dir_all(&dir);     } diff --git a/crates/engine/src/model_download.rs b/crates/engine/src/model_download.rs--- a/crates/engine/src/model_download.rs+++ b/crates/engine/src/model_download.rs@@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; -use crate::activity::{Activity, Cancel, TaskHandle, Unit};+use crate::activity::{Activity, Cancel, Ending, Outcome, TaskHandle, Unit};  use lightcraft_denoise::known::Download; @@ -95,12 +95,12 @@ impl Downloads {         std::thread::Builder::new()             .name("denoise-model-download".into())             .spawn(move || {-                // the row goes when the thread ends, however it ends-                let _task = task;+                // the row says how it ended when the thread ends, however it ends                 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, &spec.url, &staging, &worker)));                 if outcome.is_err() {                     worker.set(State::Failed("the download stopped unexpectedly".into()));                 }+                task.finish(if outcome.is_err() { Outcome::ended(Ending::Died) } else { ended(&worker.get()) });             })             .map_err(|e| format!("could not start the download: {e}"))?;         self.jobs.insert(id, shared);@@ -203,6 +203,17 @@ fn fetch(_: &Download, _: &str, _: &Path, _: &Shared) -> Result<(), String> {     Err("the web build downloads no models".into()) } +/// How a download that ran ended, for its row.+fn ended(state: &State) -> Outcome {+    match state {+        State::Done { .. } | State::Installing | State::Installed => Outcome::default(),+        State::Failed(why) => Outcome::failed(why.clone()),+        State::Cancelled => Outcome::ended(Ending::Stopped),+        // (the thread ended without saying how)+        State::Running { .. } => Outcome::ended(Ending::Died),+    }+}+ /// The whole job: reuse a verified earlier copy, else fetch, check and stage. Always ends in a final [`State`]. fn run(spec: &Download, mirror: &str, staging: &Path, shared: &Shared) {     let target = staging.join(&spec.file_name);@@ -323,6 +334,32 @@ mod tests {         wait("the download never stopped", || !d.running());         assert_eq!(d.jobs["test-model"].get(), State::Cancelled);         wait("the row stayed", || activity.list().is_empty());+        assert_eq!(activity.finished().first().map(|f| f.ending), Some(crate::activity::Ending::Stopped), "a stopped download says so");+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn a_download_that_ended_leaves_a_row_saying_how() {+        use crate::activity::{Activity, Ending};+        let dir = scratch("ended");+        let activity = Activity::default();+        activity.keep_after(0);+        let mut d = Downloads::default();+        // a server that refuses: the row keeps the reason+        let mut spec = spec_for(b"abc");+        spec.url = format!("{}/{}", serve("404 Not Found", b"not found".to_vec()), spec.file_name);+        d.start(spec, &dir, &activity, "Test Model").unwrap();+        wait("the failed download's row never ended", || activity.list().is_empty() && !activity.finished().is_empty());+        let failed = activity.finished().remove(0);+        assert_eq!((failed.kind, failed.ending), ("download", Ending::Failed));+        assert!(failed.detail.contains("internet connection"), "{}", failed.detail);+        // one that arrives+        let bytes: Vec<u8> = (0..20_000u32).map(|i| (i % 251) as u8).collect();+        let mut spec = spec_for(&bytes);+        spec.url = format!("{}/{}", serve("200 OK", bytes), spec.file_name);+        d.start(spec, &dir, &activity, "Test Model").unwrap();+        wait("the download's row never ended", || activity.finished().len() == 2);+        assert_eq!(activity.finished()[0].ending, Ending::Done);         let _ = std::fs::remove_dir_all(&dir);     } diff --git a/crates/engine/src/segment/download.rs b/crates/engine/src/segment/download.rs--- a/crates/engine/src/segment/download.rs+++ b/crates/engine/src/segment/download.rs@@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex};  use lightcraft_segment::fetch::{self, Options, Progress}; -use crate::activity::{Activity, Cancel, Unit};+use crate::activity::{Activity, Cancel, Ending, Outcome, Unit};  /// What the session sees of a download. #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]@@ -141,14 +141,15 @@ impl Downloader {             let r =                 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch::download(files, &mirrors, &dir, &opts, &s.cancel, &mut on_progress)));             let cancelled = matches!(r, Ok(Err(fetch::DownloadError::Cancelled)));-            let error = match r {+            let (error, outcome) = match r {                 Ok(Ok(())) => {                     s.finished.store(true, Ordering::SeqCst);                     log::info!("SAM 3 model downloaded to {}", dir.display());-                    None+                    (None, Outcome::default())                 }-                Ok(Err(e)) => Some(e.to_string()),-                Err(_) => Some("the download failed unexpectedly".to_string()),+                Ok(Err(e)) if s.cancel.load(Ordering::SeqCst) => (Some(e.to_string()), Outcome::ended(Ending::Stopped)),+                Ok(Err(e)) => (Some(e.to_string()), Outcome::failed(e.to_string())),+                Err(_) => (Some("the download failed unexpectedly".to_string()), Outcome::ended(Ending::Died)),             };             if let Some(e) = &error {                 log::warn!("SAM 3 download: {e}");@@ -158,6 +159,8 @@ impl Downloader {             text.error = error;             drop(text);             drop(guard);+            // last: whoever sees the row go finds the download over+            task.finish(outcome);         });         match spawned {             Ok(_) => Ok(true),diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -1224,6 +1224,8 @@ fn activity_cancel_stops_a_preview_build() {     assert_eq!(p["cancelled"], true, "{p}");     assert_eq!(p["done"], json!(0), "no photo rendered after the accepted cancel: {p}");     assert_eq!(p["running"], false, "{p}");+    let row = s.activity.finished().into_iter().next().expect("a stopped build leaves a row");+    assert_eq!((row.kind, row.ending), ("previews", crate::activity::Ending::Stopped)); }  /// The gated fixture is real work: without a cancel, both photos render and are counted.@@ -1247,6 +1249,31 @@ fn preview_build_at_the_test_gate_completes_without_cancel() {     assert_eq!(p["failed"], json!(0), "{p}"); } +/// A preview build that ran to its end leaves a row saying so (when it ran long enough to be worth one).+#[test]+fn a_finished_preview_build_leaves_a_row_saying_it_was_done() {+    let mut s = demo();+    s.activity.keep_after(0);+    let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();+    s.execute("library.buildPreviews", &json!({"ids": ids, "edge": 256, "wait": true})).unwrap();+    let row = s.activity.finished().into_iter().next().expect("a row for the build");+    assert_eq!((row.kind, row.label.as_str(), row.ending, row.failed), ("previews", "Building previews", crate::activity::Ending::Done, 0));+}++/// A photo whose preview can't be made (its file is gone) leaves the build's row marked, with how many failed.+#[test]+fn a_preview_build_that_could_not_render_a_photo_says_how_many() {+    use lightcraft_catalog::{Op, Photo, Source};+    let mut s = Session::new();+    let id = s.catalog.alloc_photo_id();+    let gone = std::env::temp_dir().join(format!("lc-no-such-photo-{}.jpg", std::process::id()));+    let p = Photo::new(id, Source::File { path: gone.to_string_lossy().to_string() }, "gone.jpg", "JPEG", 600, 400, "2026-01-01T10:00:00");+    s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();+    s.execute("library.buildPreviews", &json!({"ids": [id.0], "edge": 256, "wait": true})).unwrap();+    let row = s.activity.finished().into_iter().next().expect("a build that did not go well always leaves a row");+    assert_eq!((row.ending, row.failed), (crate::activity::Ending::Problems, 1));+}+ /// Colour range: a click samples the colour under it, so the mask selects that colour (white /// in the mask view) and not a different one; ⇧ adds samples, up to five. #[test]diff --git a/crates/engine/src/tests/folders.rs b/crates/engine/src/tests/folders.rs--- a/crates/engine/src/tests/folders.rs+++ b/crates/engine/src/tests/folders.rs@@ -650,6 +650,21 @@ fn photos_move_to_another_folder_with_their_sidecars_and_back_on_undo() {     assert_eq!(file_of(&s, b), dir.path("out/b.jpg")); } +/// A move is a task: when it ran long enough to be worth a row, the row says it was done.+#[test]+fn a_move_leaves_a_row_saying_it_was_done() {+    let dir = Scratch::new("move-row");+    std::fs::create_dir_all(dir.0.join("in")).unwrap();+    std::fs::create_dir_all(dir.0.join("out")).unwrap();+    let mut s = Session::new();+    s.activity.keep_after(0);+    let a = add_file(&mut s, &dir.path("in/a.jpg"), "A");+    let b = add_file(&mut s, &dir.path("in/b.jpg"), "B");+    s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap();+    let row = s.activity.finished().into_iter().next().expect("a row for the move");+    assert_eq!((row.kind, row.label.as_str(), row.ending), ("move", "Moving 2 photos", crate::activity::Ending::Done));+}+ #[test] fn a_name_taken_in_the_destination_refuses_the_move_and_overwrites_nothing() {     let dir = Scratch::new("move-clash");diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json--- a/crates/ui-egui/locales/cs-formats.json+++ b/crates/ui-egui/locales/cs-formats.json@@ -65,6 +65,8 @@   "Exported {n} keyword{}": "Exportovaná klíčová slova: {n}{:.0}",   "Exported {n} photo{}": "Exportováno fotografií: {n}{:.0}",   "Exported {ok} of {total} photo{}": "Exportováno fotografií: {ok} z {total}{:.0}",+  "Exported contact sheet: {photos} photos, {pages} pages": "Exportován přehled snímků (fotografií: {photos}, stran: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Vytištěno do PDF (fotografií: {photos}, stran: {pages})",   "Exported {} point curve preset{}": "Exportovaná přednastavení bodové křivky: {}{:.0}",   "Exported {} preset{}": "Exportovaná přednastavení: {}{:.0}",   "Exporting {} of {total}": "Exportování {} z {total}",@@ -237,6 +239,7 @@   "{} photos · exposures {} EV": "Fotografií: {} · expozice {} EV",   "{} photos · {} · {:.0}° × {:.0}°": "Fotografií: {} · {} · {:.0}° × {:.0}°",   "{} photo{} not readable": "Nečitelné fotografie: {}{:.0}",+  "{} import warning{}": "Varování importu: {}{:.0}",   "{} sample{}": "Vzorků: {}{:.0}",   "{} sec": "{} s",   "{} spot(s) on this photo": "Retušované oblasti na této fotografii: {}",diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json--- a/crates/ui-egui/locales/cs.json+++ b/crates/ui-egui/locales/cs.json@@ -2147,6 +2147,7 @@   "none (CPU rendering)": "žádné (vykreslování na CPU)",   "none of": "žádné z",   "not added to Apple Photos (cancelled)": "nepřidáno do Apple Photos (zrušeno)",+  "cancelled": "zrušeno",   "not available here": "zde nedostupné",   "of the following rules:": "z následujících pravidel:",   "or": "nebo",diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json--- a/crates/ui-egui/locales/de-formats.json+++ b/crates/ui-egui/locales/de-formats.json@@ -51,6 +51,8 @@   "Example: {example}": "Beispiel: {example}",   "Exported {n} photo{}": "{n} Foto{} exportiert",   "Exported {ok} of {total} photo{}": "{ok} von {total} Foto{} exportiert",+  "Exported contact sheet: {photos} photos, {pages} pages": "Kontaktabzug exportiert (Fotos: {photos}, Seiten: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Als PDF gedruckt (Fotos: {photos}, Seiten: {pages})",   "Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}",   "Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}",   "Exporting {} of {total}": "{} von {total} wird exportiert",@@ -185,6 +187,7 @@   "{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV",   "{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°",   "{} photo{} not readable": "{} Foto{} nicht lesbar",+  "{} import warning{}": "Importwarnungen: {}{:.0}",   "{} sample{}": "Farbproben: {}{:.0}",   "{} sec": "{} s",   "{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto",diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json--- a/crates/ui-egui/locales/de.json+++ b/crates/ui-egui/locales/de.json@@ -2159,6 +2159,7 @@   "Add to Apple Photos": "Zu Apple Fotos hinzufügen",   "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…",   "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)",+  "cancelled": "abgebrochen",   "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.",   "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt",   "Add Face Model…": "Gesichtsmodell hinzufügen…",diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json--- a/crates/ui-egui/locales/es-formats.json+++ b/crates/ui-egui/locales/es-formats.json@@ -51,6 +51,8 @@   "Example: {example}": "Ejemplo: {example}",   "Exported {n} photo{}": "{n} foto{0} exportada{0}",   "Exported {ok} of {total} photo{}": "Exportadas {ok} de {total} foto{}",+  "Exported contact sheet: {photos} photos, {pages} pages": "Hoja de contactos exportada (fotos: {photos}, páginas: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Impreso en PDF (fotos: {photos}, páginas: {pages})",   "Exported {} point curve preset{}": "{0} ajuste{1} preestablecido{1} de curva de puntos exportado{1}",   "Exported {} preset{}": "{0} ajuste{1} preestablecido{1} exportado{1}",   "Exporting {} of {total}": "Exportando {} de {total}",@@ -185,6 +187,7 @@   "{} photos · exposures {} EV": "{} fotos · exposiciones {} EV",   "{} photos · {} · {:.0}° × {:.0}°": "{} fotos · {} · {:.0}° × {:.0}°",   "{} photo{} not readable": "{0} foto{1} ilegible{1}",+  "{} import warning{}": "{0} advertencia{1} de importación",   "{} sample{}": "{} muestra{}",   "{} sec": "{} s",   "{} spot(s) on this photo": "{} mancha(s) en esta foto",diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json--- a/crates/ui-egui/locales/es.json+++ b/crates/ui-egui/locales/es.json@@ -2184,6 +2184,7 @@   "Add to Apple Photos": "Añadir a Fotos de Apple",   "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…",   "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)",+  "cancelled": "cancelado",   "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.",   "Adding to Apple Photos": "Añadiendo a Fotos de Apple",   "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "«guardado en memoria pero no escrito en el disco», la barra superior muestra un aviso y LightCraft lo sigue intentando hasta que el",diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json--- a/crates/ui-egui/locales/fi-formats.json+++ b/crates/ui-egui/locales/fi-formats.json@@ -305,5 +305,8 @@   "Drop to import {n} preset{}": "Pudota tuodaksesi esiasetuksia: {n}{:.0}",   "Drop to review {n} item{} for import": "Pudota tarkistaaksesi ennen tuontia (kohteita: {n}){:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Minne Lightroom-luettelo ”{catalog}” tuodaan?",-  "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})"+  "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})",+  "Exported contact sheet: {photos} photos, {pages} pages": "Kontaktivedos viety (kuvia: {photos}, sivuja: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Tulostettu PDF-tiedostoon (kuvia: {photos}, sivuja: {pages})",+  "{} import warning{}": "Tuontivaroituksia: {}{:.0}" }diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json--- a/crates/ui-egui/locales/fi.json+++ b/crates/ui-egui/locales/fi.json@@ -2327,5 +2327,6 @@   "Drop to open the face model": "Pudota avataksesi kasvomallin",   "Dropped Files": "Pudotetut tiedostot",   "Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä",-  "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta"+  "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta",+  "cancelled": "peruttu" }diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json--- a/crates/ui-egui/locales/fr-formats.json+++ b/crates/ui-egui/locales/fr-formats.json@@ -52,6 +52,8 @@   "Exported {n} photo{}": "{n} photo{} exportée(s)",   "Exported {ok} of {total} photo{}": "{ok} photo{} exportée(s) sur {total}",   "Exported {} point curve preset{}": "{} préréglage{} de courbe à points exporté(s)",+  "Exported contact sheet: {photos} photos, {pages} pages": "Planche-contact exportée (photos : {photos}, pages : {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Imprimé en PDF (photos : {photos}, pages : {pages})",   "Exported {} preset{}": "{} préréglage{} exporté(s)",   "Exporting {} of {total}": "Exportation : {} sur {total}",   "Filter bar — {active} active filter{}": "Barre de filtres — {active} filtre{} actif(s)",@@ -185,6 +187,7 @@   "{} photos · exposures {} EV": "{} photos · expositions {} EV",   "{} photos · {} · {:.0}° × {:.0}°": "{} photos · {} · {:.0}° × {:.0}°",   "{} photo{} not readable": "{} photo{} illisible(s)",+  "{} import warning{}": "{} avertissement{} d’importation",   "{} sample{}": "{} échantillon{}",   "{} sec": "{} s",   "{} spot(s) on this photo": "{} tache(s) sur cette photo",diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json--- a/crates/ui-egui/locales/fr.json+++ b/crates/ui-egui/locales/fr.json@@ -2000,6 +2000,7 @@   "Add to Apple Photos": "Ajouter à Apple Photos",   "Adding to Apple Photos…": "Ajout à Apple Photos…",   "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)",+  "cancelled": "annulé",   "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.",   "Adding to Apple Photos": "Ajout à Apple Photos",   "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "« enregistré en mémoire mais pas écrit sur le disque », la barre supérieure affiche un avertissement et LightCraft réessaie jusqu'à ce que",diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json--- a/crates/ui-egui/locales/id-formats.json+++ b/crates/ui-egui/locales/id-formats.json@@ -305,5 +305,8 @@   "Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}",   "Inside: {names}": "Di dalam: {names}",   "Where should the Lightroom catalog “{catalog}” go?": "Ke mana katalog Lightroom “{catalog}” diimpor?",-  "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})"+  "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})",+  "Exported contact sheet: {photos} photos, {pages} pages": "Lembar kontak diekspor (foto: {photos}, halaman: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Dicetak ke PDF (foto: {photos}, halaman: {pages})",+  "{} import warning{}": "Peringatan impor: {}{:.0}" }diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json--- a/crates/ui-egui/locales/id.json+++ b/crates/ui-egui/locales/id.json@@ -2339,5 +2339,6 @@   "Drop to open the face model": "Jatuhkan untuk membuka model wajah",   "Dropped Files": "File yang dijatuhkan",   "Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto",-  "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan"+  "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan",+  "cancelled": "dibatalkan" }diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json--- a/crates/ui-egui/locales/it-formats.json+++ b/crates/ui-egui/locales/it-formats.json@@ -50,6 +50,8 @@   "Example: {example}": "Esempio: {example}",   "Exported {n} photo{}": "Foto esportate: {n}{:.0}",   "Exported {ok} of {total} photo{}": "Foto esportate: {ok} di {total}{:.0}",+  "Exported contact sheet: {photos} photos, {pages} pages": "Foglio provini esportato (foto: {photos}, pagine: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Stampato in PDF (foto: {photos}, pagine: {pages})",   "Exported {} point curve preset{}": "Preset di curva a punti esportati: {}{:.0}",   "Exported {} preset{}": "Preset esportati: {}{:.0}",   "Exporting {} of {total}": "Esportazione {} di {total}",@@ -182,6 +184,7 @@   "{} photos · exposures {} EV": "{} foto · esposizioni {} EV",   "{} photos · {} · {:.0}° × {:.0}°": "{} foto · {} · {:.0}° × {:.0}°",   "{} photo{} not readable": "Foto illeggibili: {}{:.0}",+  "{} import warning{}": "Avvisi di importazione: {}{:.0}",   "{} sample{}": "Campioni: {}{:.0}",   "{} sec": "{} s",   "{} spot(s) on this photo": "Macchie in questa foto: {}",diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json--- a/crates/ui-egui/locales/it.json+++ b/crates/ui-egui/locales/it.json@@ -2112,6 +2112,7 @@   "Add to Apple Photos": "Aggiungi a Foto di Apple",   "Adding to Apple Photos…": "Aggiunta a Foto di Apple…",   "not added to Apple Photos (cancelled)": "non aggiunte a Foto di Apple (annullato)",+  "cancelled": "annullato",   "The first time, macOS asks whether LightCraft may control Photos.": "La prima volta, macOS chiede se LightCraft può controllare Foto.",   "Adding to Apple Photos": "Aggiunta a Foto di Apple",   "Add Face Model…": "Aggiungi modello dei volti…",diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json--- a/crates/ui-egui/locales/ja-formats.json+++ b/crates/ui-egui/locales/ja-formats.json@@ -28,6 +28,8 @@   "Example: {example}": "例:{example}",   "Exported {n} photo{}": "{n}枚を書き出しました{:.0}",   "Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました{:.0}",+  "Exported contact sheet: {photos} photos, {pages} pages": "コンタクトシートを書き出しました:{photos}枚、{pages}ページ",+  "Printed to PDF: {photos} photos, {pages} pages": "PDFに印刷しました:{photos}枚、{pages}ページ",   "Exported {} point curve preset{}": "{}件のポイントカーブを書き出しました{:.0}",   "Exported {} preset{}": "{}件のプリセットを書き出しました{:.0}",   "Exporting {} of {total}": "書き出し中:{total}枚中{}枚",@@ -110,6 +112,7 @@   "{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{}本:垂直または水平にする線に沿ってドラッグ",   "{} photos": "{}枚",   "{} photo{} not readable": "{}枚を読み込めません{:.0}",+  "{} import warning{}": "読み込みの警告: {}件{:.0}",   "{} spot(s) on this photo": "この写真に{}か所のスポット",   "· {dups} already in the library": "・{dups}枚は読み込み済み",   "· {n} photo{}": "・{n}枚{:.0}",diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json--- a/crates/ui-egui/locales/ja.json+++ b/crates/ui-egui/locales/ja.json@@ -2004,6 +2004,7 @@   "Add to Apple Photos": "Appleの写真に追加",   "Adding to Apple Photos…": "Appleの写真に追加中…",   "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)",+  "cancelled": "キャンセル",   "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。",   "Adding to Apple Photos": "Appleの写真に追加中",   "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "「メモリーには保存されたがディスクに書き込まれていない」と報告し、上部バーに警告を表示して、LightCraft は",diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json--- a/crates/ui-egui/locales/ka-formats.json+++ b/crates/ui-egui/locales/ka-formats.json@@ -65,6 +65,8 @@   "Exported {n} keyword{}": "ექსპორტირებულია საკვანძო სიტყვა: {n}{:.0}",   "Exported {n} photo{}": "ექსპორტირებულია ფოტო: {n}{:.0}",   "Exported {ok} of {total} photo{}": "ექსპორტირებულია ფოტო: {ok} / {total}{:.0}",+  "Exported contact sheet: {photos} photos, {pages} pages": "საკონტაქტო ფურცელი ექსპორტირებულია: {photos} ფოტო, {pages} გვერდი",+  "Printed to PDF: {photos} photos, {pages} pages": "დაიბეჭდა PDF-ში: {photos} ფოტო, {pages} გვერდი",   "Exported {} point curve preset{}": "ექსპორტირებულია წერტილოვანი მრუდის პრესეტი: {}{:.0}",   "Exported {} preset{}": "ექსპორტირებულია პრესეტი: {}{:.0}",   "Exporting {} of {total}": "ექსპორტი: {} / {total}",@@ -236,6 +238,7 @@   "{} photos · exposures {} EV": "ფოტო: {} · ექსპოზიციები {} EV",   "{} photos · {} · {:.0}° × {:.0}°": "ფოტო: {} · {} · {:.0}° × {:.0}°",   "{} photo{} not readable": "ვერ იკითხება ფოტო: {}{:.0}",+  "{} import warning{}": "იმპორტის გაფრთხილებები: {}{:.0}",   "{} sample{}": "ნიმუში: {}{:.0}",   "{} sec": "{} წმ",   "{} spot(s) on this photo": "ლაქა ამ ფოტოზე: {}",diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json--- a/crates/ui-egui/locales/ka.json+++ b/crates/ui-egui/locales/ka.json@@ -2566,6 +2566,7 @@   "none (CPU rendering)": "არცერთი (CPU რენდერირება)",   "none of": "არცერთი",   "not added to Apple Photos (cancelled)": "Apple Photos-ში არ დაემატა (გაუქმდა)",+  "cancelled": "გაუქმდა",   "not available here": "აქ ხელმისაწვდომი არ არის",   "noticeably duller.": "შესამჩნევად მკრთალი ჩანდა.",   "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "ახლა მუშავდება RAW მონაცემებიდან კამერის ჩაშენებული JPEG-ის ნაცვლად, ასე რომ შავ-თეთრი ან სხვა გამოსახულების სტილი, რომელიც დაყენებულია",diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json--- a/crates/ui-egui/locales/pl-formats.json+++ b/crates/ui-egui/locales/pl-formats.json@@ -50,6 +50,8 @@   "Example: {example}": "Przykład: {example}",   "Exported {n} photo{}": "Wyeksportowane zdjęcia: {n}{:.0}",   "Exported {ok} of {total} photo{}": "Wyeksportowane zdjęcia: {ok} z {total}{:.0}",+  "Exported contact sheet: {photos} photos, {pages} pages": "Wyeksportowano arkusz stykowy (zdjęcia: {photos}, strony: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Wydrukowano do PDF (zdjęcia: {photos}, strony: {pages})",   "Exported {} point curve preset{}": "Wyeksportowane ustawienia krzywej punktowej: {}{:.0}",   "Exported {} preset{}": "Wyeksportowane ustawienia wstępne: {}{:.0}",   "Exporting {} of {total}": "Eksportowanie {} z {total}",@@ -174,6 +176,7 @@   "{} photos · exposures {} EV": "Zdjęcia: {} · ekspozycje {} EV",   "{} photos · {} · {:.0}° × {:.0}°": "Zdjęcia: {} · {} · {:.0}° × {:.0}°",   "{} photo{} not readable": "Nieczytelne zdjęcia: {}{:.0}",+  "{} import warning{}": "Ostrzeżenia importu: {}{:.0}",   "{} sample{}": "Próbki: {}{:.0}",   "{} sec": "{} s",   "{} spot(s) on this photo": "Plamki na tym zdjęciu: {}",diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json--- a/crates/ui-egui/locales/pl.json+++ b/crates/ui-egui/locales/pl.json@@ -1610,6 +1610,7 @@   "none": "brak",   "none (CPU rendering)": "Brak (renderowanie przez CPU)",   "none of": "Żaden z",+  "cancelled": "anulowano",   "not available here": "Niedostępne tutaj",   "of the following rules:": "Następujących reguł:",   "or": "lub",diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json--- a/crates/ui-egui/locales/pt-br-formats.json+++ b/crates/ui-egui/locales/pt-br-formats.json@@ -37,6 +37,8 @@   "Example: {example}": "Exemplo: {example}",   "Exported {n} photo{}": "{n} foto{0} exportada{0}",   "Exported {ok} of {total} photo{}": "Fotos exportadas: {ok} de {total}{:.0}",+  "Exported contact sheet: {photos} photos, {pages} pages": "Folha de contato exportada (fotos: {photos}, páginas: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Impresso em PDF (fotos: {photos}, páginas: {pages})",   "Exported {} point curve preset{}": "{} predefinição(ões) de curva de pontos exportada(s){:.0}",   "Exported {} preset{}": "{} predefinição(ões) exportada(s){:.0}",   "Exporting {} of {total}": "Exportando {} de {total}",@@ -138,6 +140,7 @@   "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} de 4 guias — arraste ao longo das linhas que devem ficar verticais ou horizontais.",   "{} photos": "{} fotos",   "{} photo{} not readable": "{} foto(s) não legível(is){:.0}",+  "{} import warning{}": "{} aviso{} de importação",   "{} spot(s) on this photo": "{} ponto(s) nesta foto",   "· {dups} already in the library": "· {dups} já na biblioteca",   "· {n} photo{}": "· {n} foto{}",diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json--- a/crates/ui-egui/locales/pt-br.json+++ b/crates/ui-egui/locales/pt-br.json@@ -2002,6 +2002,7 @@   "Add to Apple Photos": "Adicionar ao Fotos da Apple",   "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…",   "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)",+  "cancelled": "cancelado",   "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.",   "Adding to Apple Photos": "Adicionando ao Fotos da Apple",   "Sort Albums A–Z": "Ordenar álbuns de A a Z",diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json--- a/crates/ui-egui/locales/ru-formats.json+++ b/crates/ui-egui/locales/ru-formats.json@@ -53,6 +53,8 @@   "Example: {example}": "Пример: {example}",   "Exported {n} photo{}": "Экспортировано фото: {n}{:.0}",   "Exported {ok} of {total} photo{}": "Экспортировано фото: {ok} из {total}{:.0}",+  "Exported contact sheet: {photos} photos, {pages} pages": "Контактный лист экспортирован (фото: {photos}, страниц: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Напечатано в PDF (фото: {photos}, страниц: {pages})",   "Exported {} point curve preset{}": "Экспортировано пресетов точечной кривой: {}{:.0}",   "Exported {} preset{}": "Экспортировано пресетов: {}{:.0}",   "Exporting {} of {total}": "Экспорт: {} из {total}",@@ -192,6 +194,7 @@   "{} photos · exposures {} EV": "Фото: {} · экспозиции {} EV",   "{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°",   "{} photo{} not readable": "Не читается фото: {}{:.0}",+  "{} import warning{}": "Предупреждений при импорте: {}{:.0}",   "{} sample{}": "Образцов: {}{:.0}",   "{} sec": "{} с",   "{} spot(s) on this photo": "Пятен на этом фото: {}",diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json--- a/crates/ui-egui/locales/ru.json+++ b/crates/ui-egui/locales/ru.json@@ -2194,6 +2194,7 @@   "Add to Apple Photos": "Добавить в Apple Фото",   "Adding to Apple Photos…": "Добавление в Apple Фото…",   "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)",+  "cancelled": "отменено",   "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».",   "Adding to Apple Photos": "Добавление в Apple Фото",   "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.",diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json--- a/crates/ui-egui/locales/sv-formats.json+++ b/crates/ui-egui/locales/sv-formats.json@@ -305,5 +305,8 @@   "Drop to review {n} item{} for import": "Släpp för att granska {n} objekt före import{:.0}",   "Inside: {names}": "Inuti: {names}",   "Where should the Lightroom catalog “{catalog}” go?": "Vart ska Lightroom-katalogen ”{catalog}” importeras?",-  "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})"+  "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})",+  "Exported contact sheet: {photos} photos, {pages} pages": "Kontaktark exporterat (foton: {photos}, sidor: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Utskrivet till PDF (foton: {photos}, sidor: {pages})",+  "{} import warning{}": "Importvarningar: {}{:.0}" }diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json--- a/crates/ui-egui/locales/sv.json+++ b/crates/ui-egui/locales/sv.json@@ -2429,5 +2429,6 @@   "Drop to open the face model": "Släpp för att öppna ansiktsmodellen",   "Dropped Files": "Släppta filer",   "Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton",-  "Import dropped files without review": "Importera släppta filer utan granskning"+  "Import dropped files without review": "Importera släppta filer utan granskning",+  "cancelled": "avbruten" }diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json--- a/crates/ui-egui/locales/uk-formats.json+++ b/crates/ui-egui/locales/uk-formats.json@@ -51,6 +51,8 @@   "Example: {example}": "Приклад: {example}",   "Exported {n} photo{}": "Експортовано фото: {n}{:.0}",   "Exported {ok} of {total} photo{}": "Експортовано фото: {ok} із {total}{:.0}",+  "Exported contact sheet: {photos} photos, {pages} pages": "Аркуш мініатюр експортовано (фото: {photos}, сторінок: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Надруковано в PDF (фото: {photos}, сторінок: {pages})",   "Exported {} point curve preset{}": "Експортовано наборів налаштувань точкової кривої: {}{:.0}",   "Exported {} preset{}": "Експортовано наборів налаштувань: {}{:.0}",   "Exporting {} of {total}": "Експорт: {} із {total}",@@ -185,6 +187,7 @@   "{} photos · exposures {} EV": "Фото: {} · експозиції {} EV",   "{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°",   "{} photo{} not readable": "Не читається фото: {}{:.0}",+  "{} import warning{}": "Попереджень під час імпорту: {}{:.0}",   "{} sample{}": "Зразків: {}{:.0}",   "{} sec": "{} с",   "{} spot(s) on this photo": "Ділянок на цьому фото: {}",diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json--- a/crates/ui-egui/locales/uk.json+++ b/crates/ui-egui/locales/uk.json@@ -2194,6 +2194,7 @@   "Add to Apple Photos": "Додати до Apple Фото",   "Adding to Apple Photos…": "Додавання до Apple Фото…",   "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)",+  "cancelled": "скасовано",   "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».",   "Adding to Apple Photos": "Додавання до Apple Фото",   "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Файли Canon EOS R5 Mark II C-RAW, які відкривалися «лише як перегляд» (більше половини, залежно від",diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json--- a/crates/ui-egui/locales/vi-formats.json+++ b/crates/ui-egui/locales/vi-formats.json@@ -305,5 +305,8 @@   "Drop to import {n} preset{}": "Thả để nhập {n} thiết lập sẵn{:.0}",   "Drop to review {n} item{} for import": "Thả để xem lại {n} mục trước khi nhập{:.0}",   "Where should the Lightroom catalog “{catalog}” go?": "Nhập danh mục Lightroom «{catalog}» vào đâu?",-  "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})"+  "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})",+  "Exported contact sheet: {photos} photos, {pages} pages": "Đã xuất tờ tiếp xúc (ảnh: {photos}, trang: {pages})",+  "Printed to PDF: {photos} photos, {pages} pages": "Đã in ra PDF (ảnh: {photos}, trang: {pages})",+  "{} import warning{}": "Cảnh báo khi nhập: {}{:.0}" }diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json--- a/crates/ui-egui/locales/vi.json+++ b/crates/ui-egui/locales/vi.json@@ -2235,5 +2235,6 @@   "Drop to open the face model": "Thả để mở mô hình khuôn mặt",   "Dropped Files": "Tệp đã thả",   "Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh",-  "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại"+  "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại",+  "cancelled": "đã hủy" }diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json--- a/crates/ui-egui/locales/zh-hans-formats.json+++ b/crates/ui-egui/locales/zh-hans-formats.json@@ -28,6 +28,8 @@   "Example: {example}": "示例:{example}",   "Exported {n} photo{}": "已导出 {n} 张照片{0:.0}",   "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片{0:.0}",+  "Exported contact sheet: {photos} photos, {pages} pages": "已导出联系表:{photos} 张照片,{pages} 页",+  "Printed to PDF: {photos} photos, {pages} pages": "已打印为 PDF:{photos} 张照片,{pages} 页",   "Exported {} point curve preset{}": "已导出 {0} 个点曲线预设{1:.0}",   "Exported {} preset{}": "已导出 {0} 个预设{1:.0}",   "Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张",@@ -110,6 +112,7 @@   "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {0} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。",   "{} photos": "{0} 张照片",   "{} photo{} not readable": "{0} 张照片无法读取{1:.0}",+  "{} import warning{}": "{0} 条导入警告{1:.0}",   "{} spot(s) on this photo": "此照片上有 {0} 处污点",   "· {dups} already in the library": "· {dups} 张已在图库中",   "· {n} photo{}": "· {n} 张照片{0:.0}",diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json--- a/crates/ui-egui/locales/zh-hans.json+++ b/crates/ui-egui/locales/zh-hans.json@@ -2034,6 +2034,7 @@   "Add to Apple Photos": "添加到 Apple 照片",   "Adding to Apple Photos…": "正在添加到 Apple 照片…",   "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)",+  "cancelled": "已取消",   "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。",   "Adding to Apple Photos": "正在添加到 Apple 照片",   "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。",diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json--- a/crates/ui-egui/locales/zh-hant-formats.json+++ b/crates/ui-egui/locales/zh-hant-formats.json@@ -34,6 +34,8 @@   "Example: {example}": "範例:{example}",   "Exported {n} photo{}": "已匯出 {n} 張照片{:.0}",   "Exported {ok} of {total} photo{}": "已匯出 {ok}/{total} 張照片{:.0}",+  "Exported contact sheet: {photos} photos, {pages} pages": "已匯出縮圖目錄:{photos} 張照片,{pages} 頁",+  "Printed to PDF: {photos} photos, {pages} pages": "已列印為 PDF:{photos} 張照片,{pages} 頁",   "Exported {} point curve preset{}": "已匯出 {} 個點曲線預設集{:.0}",   "Exported {} preset{}": "已匯出 {} 個預設集{:.0}",   "Exporting {} of {total}": "正在匯出第 {}/{total} 張",@@ -134,6 +136,7 @@   "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已繪製 {}/4 條參考線 — 沿應為垂直或水平的線條拖曳。",   "{} photos": "{} 張照片",   "{} photo{} not readable": "{} 張照片無法讀取{:.0}",+  "{} import warning{}": "{0} 則匯入警告{1:.0}",   "{} spot(s) on this photo": "此照片有 {} 個斑點",   "· {dups} already in the library": "· {dups} 張已在照片庫中",   "· {n} photo{}": "· {n} 張照片{:.0}",diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json--- a/crates/ui-egui/locales/zh-hant.json+++ b/crates/ui-egui/locales/zh-hant.json@@ -2002,6 +2002,7 @@   "Add to Apple Photos": "加入 Apple 照片",   "Adding to Apple Photos…": "正在加入 Apple 照片…",   "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)",+  "cancelled": "已取消",   "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。",   "Adding to Apple Photos": "正在加入 Apple 照片",   "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。",diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs--- a/crates/ui-egui/src/app/frame.rs+++ b/crates/ui-egui/src/app/frame.rs@@ -88,8 +88,9 @@ impl LightcraftApp {                 && !self.tasks.is_running(LABEL)             {                 self.ui.auto_import_at = now;+                let watched = folder.clone();                 let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder);-                let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {+                let done = move |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {                     let listing = match listing {                         Ok(l) => l,                         Err(e) => return log::debug!("auto import: {e}"),@@ -103,9 +104,13 @@ impl LightcraftApp {                         o.remove("paths");                     }                     let undo0 = app.session.undo.len();-                    app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto());+                    app.import = Some(import::ImportTask::new(paths, params, undo0, false).named_below(vec![watched]).auto());+                };+                // listed every few seconds: quiet, no row in the activity stack (so nothing to say of how it ended)+                let done = move |app: &mut LightcraftApp, ctx: &egui::Context, listing| {+                    done(app, ctx, listing);+                    lightcraft_engine::activity::Outcome::default()                 };-                // listed every few seconds: quiet, no row in the activity stack                 if let Err(e) = tasks::spawn(self, LABEL, None, work, done) {                     log::warn!("{e}");                 }diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs--- a/crates/ui-egui/src/control.rs+++ b/crates/ui-egui/src/control.rs@@ -150,6 +150,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {         "tasks": app.tasks.labels(),         // every long-running task in flight, as the activity stack shows them (`activity.list`)         "activity": app.session.activity.list(),+        // the tasks that ended and left a row (`activity.finished`)+        "activityFinished": app.session.activity.finished(),         // commands waiting on a native file dialog shown off the UI thread (`pick`)         "fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::<Vec<_>>(),         "memory": memory(app),diff --git a/crates/ui-egui/src/dialogs/import.rs b/crates/ui-egui/src/dialogs/import.rs--- a/crates/ui-egui/src/dialogs/import.rs+++ b/crates/ui-egui/src/dialogs/import.rs@@ -39,6 +39,7 @@ pub use scan::{ScanTask, browse, open, poll_scan, running}; pub(crate) use scan::{stall_scan, stall_scan_of}; pub(crate) use tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; pub use task::{ImportTask, start, start_paths, tick};+pub(crate) use task::{failure_name, not_readable_text};  #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")]diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs--- a/crates/ui-egui/src/dialogs/import/scan.rs+++ b/crates/ui-egui/src/dialogs/import/scan.rs@@ -1,4 +1,4 @@-use super::task::ImportTask;+use super::task::{ImportTask, not_readable_text}; use super::*;  /// A folder scan running on a worker thread (a network share can take minutes to read; the@@ -145,6 +145,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) ->  /// Collect a finished scan and open the review (called every frame). pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {+    use lightcraft_engine::activity::{Ending, Failure, Outcome};     show_pending_review(app, ctx);     let Some(task) = app.scan.as_ref() else { return };     ctx.request_repaint_after(std::time::Duration::from_millis(100));@@ -153,7 +154,9 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {         Err(std::sync::mpsc::TryRecvError::Empty) => {             if task.guard.is_cancelled() {                 // ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while)-                app.scan = None;+                if let Some(task) = app.scan.take() {+                    task.guard.finish(Outcome::ended(Ending::Stopped));+                }                 return;             }             let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed));@@ -163,15 +166,52 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {             return;         }         Err(_) => {-            app.scan = None;+            // the worker died (a panic, logged)+            if let Some(task) = app.scan.take() {+                task.guard.finish(Outcome::ended(Ending::Died));+            }             app.toast(ctx, crate::i18n::tr("Scan failed"));             return;         }     };     let Some(task) = app.scan.take() else { return };+    // (a scan another one replaced was let go without a word; one here that was cancelled was stopped by its ✕)     if task.progress.cancel.load(Ordering::Relaxed) {+        task.guard.finish(Outcome::ended(Ending::Stopped));         return;     }+    // the Local view's row says which folder; a folder opened again replaces its last row instead of adding one+    let path = task.browse.then(|| app.session.browse.as_ref().map(|b| b.path.clone())).flatten();+    // files that could not be read are a problem the row says, by name (in the Local view, by where they are in its+    // folder: two subfolders can hold the same name): the Local view leaves them out, so nothing later would+    let named = |c: &ImportCandidate| {+        path.as_ref()+            .and_then(|root| std::path::Path::new(&c.path).strip_prefix(root).ok())+            .map_or_else(|| c.name.clone(), |rel| rel.to_string_lossy().into_owned())+    };+    let failed = out.candidates.iter().filter(|c| c.error.is_some()).count();+    let failures: Vec<Failure> = out+        .candidates+        .iter()+        .filter_map(|c| c.error.as_ref().map(|e| Failure { name: named(c), reason: e.clone() }))+        .take(lightcraft_engine::activity::MAX_FAILURES)+        .collect();+    let found_none = out.candidates.is_empty() && !task.browse;+    if let Some(path) = &path {+        for old in app.session.activity.finished() {+            if old.kind == "scan" && old.path.as_ref() == Some(path) {+                let _ = app.session.activity.dismiss(old.id);+            }+        }+    }+    task.guard.finish(Outcome {+        ending: if failed > 0 { Ending::Problems } else { Ending::Done },+        detail: if found_none { crate::i18n::tr("No photos found").to_string() } else { not_readable_text(failed) },+        failed,+        failures,+        path,+        ..Default::default()+    });     app.session.import_probes = out.probes;     if task.browse {         // the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so@@ -193,7 +233,8 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {             let undo0 = app.session.undo.len();             let total = queue.len();             let _ = total;-            app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true));+            let root = app.session.browse.as_ref().map(|b| b.path.clone());+            app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true).named_below(root.into_iter().collect()));         }         return;     }diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs--- a/crates/ui-egui/src/dialogs/import/task.rs+++ b/crates/ui-egui/src/dialogs/import/task.rs@@ -6,6 +6,42 @@ use super::*; #[cfg(target_arch = "wasm32")] const BATCH: usize = 8; +/// The worker's loop: ready `files` batch by batch with `prepare` and send each to `tx`. `ended` is set when the loop+/// ends by itself (every file, a stop, or an import that was dropped); a panic in `prepare` (logged) stops the loop+/// and leaves it unset, so the receiving side can tell a worker that died from one that finished.+#[cfg(not(target_arch = "wasm32"))]+fn feed_batches(+    files: Vec<String>,+    batch: usize,+    cancel: &std::sync::atomic::AtomicBool,+    tx: &std::sync::mpsc::Sender<lightcraft_engine::import::Prepared>,+    ctx: &egui::Context,+    ended: &std::sync::atomic::AtomicBool,+    mut prepare: impl FnMut(Vec<String>) -> lightcraft_engine::import::Prepared,+) {+    for chunk in files.chunks(batch.max(1)) {+        if cancel.load(Ordering::Relaxed) {+            break;+        }+        let prepared = match lightcraft_engine::guard::catch("import", || prepare(chunk.to_vec())) {+            Ok(p) => p,+            Err(_) => {+                // logged; the files readied so far are added, and the import ends as one that died+                ctx.request_repaint();+                return;+            }+        };+        if let Err(unsent) = tx.send(prepared) {+            // the import was dropped: nothing will add these, so take back what a Move placed+            unsent.0.rollback();+            break;+        }+        ctx.request_repaint();+    }+    ended.store(true, Ordering::Release);+    ctx.request_repaint();+}+ /// A running import (see the module docs). The file-system work — expanding folders, probing, /// reading sidecars, copying or placing files — runs on a worker thread /// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the@@ -14,6 +50,8 @@ const BATCH: usize = 8; pub struct ImportTask {     /// Files and folders to import (handed to the worker when the task starts).     queue: Vec<String>,+    /// The files and folders the import was asked for: what the names of unreadable files are relative to.+    roots: Vec<String>,     pub total: usize,     pub done: usize,     params: Value,@@ -24,6 +62,9 @@ pub struct ImportTask {     /// The library photos the skipped duplicates match (shown when nothing new was added).     existing: Vec<u64>,     pub failed: usize,+    /// The files that could not be read, as (path, reason) the engine reported them: the first+    /// [`lightcraft_engine::activity::MAX_FAILURES`] (`failed` counts them all).+    failures: Vec<(String, String)>,     /// Move: originals moved, and sources left in place (reported by the engine with a reason).     pub moved: usize,     pub kept: usize,@@ -36,6 +77,8 @@ pub struct ImportTask {     pub(super) browse: bool,     /// Cancel was pressed: no further files are started; batches already readied are added.     pub cancelled: bool,+    /// The worker that readies the files died (a panic, logged) before it got to the last one.+    died: bool,     /// Auto Import (the watched folder): the selection stays as it is, a short toast when done.     auto: bool,     /// Auto Import: the selection to keep.@@ -56,6 +99,13 @@ impl ImportTask {         ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() }     } +    /// Name the files this import cannot read by where they are below `folders` (the sources the files were+    /// found in, or the files and folders it was given), rather than by their file name alone.+    pub fn named_below(mut self, folders: Vec<String>) -> Self {+        self.roots = folders;+        self+    }+     /// An Auto Import (see [`ImportTask::auto`]).     pub fn auto(mut self) -> Self {         self.auto = true;@@ -71,12 +121,27 @@ impl ImportTask { /// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no /// slow drives) inline, one batch per frame. enum Runner {+    /// The batches the worker readies, and whether it came to its end by itself (a stop, a dropped import or the+    /// last file) rather than by a panic: a channel that closes without it was a worker that died.     #[cfg(not(target_arch = "wasm32"))]-    Thread(std::sync::mpsc::Receiver<lightcraft_engine::import::Prepared>),+    Thread(std::sync::mpsc::Receiver<lightcraft_engine::import::Prepared>, std::sync::Arc<std::sync::atomic::AtomicBool>),     #[cfg(target_arch = "wasm32")]     Inline { job: Box<lightcraft_engine::import::ImportJob>, queue: Vec<String>, files: Option<std::collections::VecDeque<String>> }, } +impl Runner {+    /// The worker stopped without coming to its end: a panic (logged), so the files it did not get to were not+    /// read. Only meaningful once [`ImportRun::next_batches`] has said it is done.+    fn died(&self) -> bool {+        match self {+            #[cfg(not(target_arch = "wasm32"))]+            Runner::Thread(_, ended) => !ended.load(Ordering::Acquire),+            #[cfg(target_arch = "wasm32")]+            Runner::Inline { .. } => false,+        }+    }+}+ struct ImportRun {     runner: Runner,     cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,@@ -124,27 +189,20 @@ impl ImportRun {         ctx: &egui::Context,     ) -> Result<Runner, String> {         let (tx, rx) = std::sync::mpsc::channel();+        let ended = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));+        let worker_ended = ended.clone();         let ctx = ctx.clone();         let work = move || {             let files = job.expand(&queue);-            for chunk in files.chunks(lightcraft_engine::import::batch_size()) {-                if cancel.load(Ordering::Relaxed) {-                    break;-                }-                let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) {-                    Ok(p) => p,-                    Err(_) => break, // logged; the files readied so far are added-                };-                if let Err(unsent) = tx.send(prepared) {-                    // the import was dropped: nothing will add these, so take back what a Move placed-                    unsent.0.rollback();-                    break;-                }-                ctx.request_repaint();-            }-            ctx.request_repaint();+            feed_batches(files, lightcraft_engine::import::batch_size(), &cancel, &tx, &ctx, &worker_ended, |chunk| {+                job.prepare_files(chunk, &cancel)+            });         };-        std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}"))+        std::thread::Builder::new()+            .name("lc-import".into())+            .spawn(work)+            .map(|_| Runner::Thread(rx, ended))+            .map_err(|e| format!("could not start: {e}"))     }      #[cfg(target_arch = "wasm32")]@@ -161,7 +219,7 @@ impl ImportRun {     fn next_batches(&mut self) -> Option<Vec<lightcraft_engine::import::Prepared>> {         match &mut self.runner {             #[cfg(not(target_arch = "wasm32"))]-            Runner::Thread(rx) => {+            Runner::Thread(rx, _) => {                 let mut out = Vec::new();                 while out.len() < 4 {                     match rx.try_recv() {@@ -248,7 +306,7 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>         }     }     let total = queue.len();-    app.import = Some(ImportTask::new(queue, params, undo0, false));+    app.import = Some(ImportTask::new(queue, params, undo0, false).named_below(d.sources.clone()));     app.renderer.forget_imports();     Ok(json!({"importing": total})) }@@ -261,7 +319,7 @@ pub fn start_paths(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value,     }     let undo0 = app.session.undo.len();     let total = paths.len();-    app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false));+    app.import = Some(ImportTask::new(paths.clone(), json!({"mode": "add"}), undo0, false).named_below(paths));     Ok(json!({"importing": total})) } @@ -296,6 +354,9 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {     let Some(run) = task.run.as_mut() else { return };     let batches = run.next_batches();     let finished = batches.is_none();+    if finished {+        task.died = run.runner.died();+    }     if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) {         task.moved_on = true;     }@@ -350,6 +411,15 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc             task.duplicates += len("duplicates");             task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64()));             task.failed += len("failed");+            let room = lightcraft_engine::activity::MAX_FAILURES.saturating_sub(task.failures.len());+            task.failures.extend(+                v["failed"]+                    .as_array()+                    .into_iter()+                    .flatten()+                    .take(room)+                    .filter_map(|f| Some((f.get(0)?.as_str()?.to_string(), f.get(1)?.as_str()?.to_string()))),+            );             task.moved += len("moved");             task.kept += len("kept");             task.warnings.extend(v["warnings"].as_array().into_iter().flatten().filter_map(|warning| serde_json::from_value(warning.clone()).ok()));@@ -424,7 +494,7 @@ fn xmp_warnings_text(warnings: &[ImportWarning]) -> String {  /// The import is done (or cancelled): one undo step, show and select the first photo (unless the /// user chose another meanwhile), say what happened.-fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {+fn finish(app: &mut LightcraftApp, ctx: &egui::Context, mut task: ImportTask) {     let steps = app.session.undo.len().saturating_sub(task.undo0);     let plural = |n: usize| if n == 1 { "" } else { "s" };     let label = if task.imported == 0 && task.restored > 0 {@@ -436,6 +506,9 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {     };     app.session.merge_undo(steps, &label);     if task.auto {+        let summary = crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, plural(task.imported));+        let detail = with_warnings(not_readable_text(task.failed), &task);+        report(&mut task, summary, detail);         if !task.warnings.is_empty() {             app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);         } else if task.imported > 0 {@@ -444,6 +517,9 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {         return;     }     if task.browse {+        // (no summary: the row is worded from its name, "Reading folder", and says how many files could not be read)+        let detail = with_warnings(not_readable_text(task.failed), &task);+        report(&mut task, String::new(), detail);         if !task.warnings.is_empty() {             app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);         } else if task.failed > 0 {@@ -462,15 +538,13 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {     let plural = |n: usize| if n == 1 { "" } else { "s" };     // nothing new, only files the library already has: show where they are (Recently Deleted is     // easy to miss, and the side panel that lists it starts collapsed)-    if task.imported == 0+    // (still reported to the activity stack below: the toast is the only part this takes the place of)+    let existing_shown = task.imported == 0         && task.restored == 0         && task.failed == 0         && task.warnings.is_empty()         && !task.cancelled-        && show_existing(app, ctx, &task.existing)-    {-        return;-    }+        && show_existing(app, ctx, &task.existing);     let mut msg = if task.cancelled {         crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported))     } else if task.imported == 0 && task.restored > 0 {@@ -478,6 +552,8 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {     } else {         crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported))     };+    // what was done is the first line of the row the import leaves; the rest is what needs reading under it+    let summary = msg.clone();     if task.restored > 0 && (task.imported > 0 || task.cancelled) {         msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored));     }@@ -497,6 +573,12 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {     if task.failed > 0 {         msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed));     }+    let detail = msg.get(summary.len()..).unwrap_or_default().trim_start_matches(" · ").to_string();+    let detail = with_warnings(detail, &task);+    report(&mut task, summary, detail);+    if existing_shown {+        return;+    }     if task.warnings.is_empty() {         app.toast(ctx, msg);     } else {@@ -506,6 +588,63 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {     } } +/// How an import ended: stopped, failed to read every file, with problems, or well.+fn ending_of(task: &ImportTask) -> lightcraft_engine::activity::Ending {+    use lightcraft_engine::activity::Ending;+    if task.cancelled {+        Ending::Stopped+    } else if task.died {+        Ending::Died+    } else if task.failed > 0 && task.imported == 0 && task.restored == 0 {+        Ending::Failed+    } else if task.failed > 0 || !task.warnings.is_empty() {+        Ending::Problems+    } else {+        Ending::Done+    }+}++/// The unreadable files, as the second line of an import's row (nothing when all could be read).+pub(crate) fn not_readable_text(failed: usize) -> String {+    if failed == 0 { String::new() } else { crate::i18n::tr_format!("{} photo{} not readable", failed, if failed == 1 { "" } else { "s" }) }+}++/// `detail` with how many XMP warnings the import met (the toast that spells them out passes; the row stays).+fn with_warnings(mut detail: String, task: &ImportTask) -> String {+    let n = task.warnings.len();+    if n > 0 {+        if !detail.is_empty() {+            detail.push_str(" · ");+        }+        detail.push_str(&crate::i18n::tr_format!("{} import warning{}", n, if n == 1 { "" } else { "s" }));+    }+    detail+}++/// How a failed file is named in the import's row: by where it is below the folder it was found in (two subfolders+/// can hold the same name), else by its own name.+pub(crate) fn failure_name(path: &str, roots: &[String]) -> String {+    let p = std::path::Path::new(path);+    roots+        .iter()+        .filter_map(|root| p.strip_prefix(root).ok())+        .find(|rel| !rel.as_os_str().is_empty())+        .or_else(|| p.file_name().map(std::path::Path::new))+        .map_or_else(|| path.to_string(), |rel| rel.to_string_lossy().into_owned())+}++/// The import says how it ended to the activity stack, on every way out of [`finish`] (a task dropped without+/// saying is no row at all): one that ran long, was stopped or could not read every file stays in+/// `activity.finished`, naming the files it could not read and why.+fn report(task: &mut ImportTask, summary: String, detail: String) {+    use lightcraft_engine::activity::{Failure, Outcome};+    let ending = ending_of(task);+    let failures = task.failures.iter().map(|(path, reason)| Failure { name: failure_name(path, &task.roots), reason: reason.clone() }).collect();+    if let Some(guard) = task.guard.take() {+        guard.finish(Outcome { ending, summary, detail, failed: task.failed, failures, ..Default::default() });+    }+}+ /// The photos among `ids` that are in Recently Deleted (each once). fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec<u64> {     let mut v: Vec<u64> =@@ -542,3 +681,105 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64])     app.toast_for(ctx, msg, 6.0);     true }++#[cfg(test)]+mod tests {+    use super::*;++    /// The worker's end is told from its death: a channel that closes after every batch was sent is a finished+    /// import, one that closes after a panic in readying a batch is an import that died (and ends as `Died`).+    #[cfg(not(target_arch = "wasm32"))]+    #[test]+    fn a_worker_that_panics_is_told_from_one_that_finished() {+        let ctx = egui::Context::default();+        let cancel = std::sync::atomic::AtomicBool::new(false);+        for (panics, died) in [(false, false), (true, true)] {+            let (tx, rx) = std::sync::mpsc::channel();+            let ended = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));+            let files: Vec<String> = (0..3).map(|i| format!("f{i}")).collect();+            let mut seen = 0;+            feed_batches(files, 1, &cancel, &tx, &ctx, &ended, |_| {+                seen += 1;+                if panics && seen == 2 {+                    panic!("a bug in readying a batch");+                }+                lightcraft_engine::import::Prepared::default()+            });+            drop(tx);+            let sent = rx.try_iter().count();+            assert_eq!(sent, if panics { 1 } else { 3 }, "batches readied before the end");+            assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)), "the channel closed");+            let runner = Runner::Thread(rx, ended);+            assert_eq!(runner.died(), died, "panics: {panics}");+        }+    }++    #[test]+    fn an_import_whose_worker_died_ends_as_died() {+        use lightcraft_engine::activity::Ending;+        let mut task = ImportTask::new(vec!["a".into()], json!({}), 0, false);+        task.imported = 2;+        assert_eq!(ending_of(&task), Ending::Done);+        task.died = true;+        assert_eq!(ending_of(&task), Ending::Died);+        task.cancelled = true;+        assert_eq!(ending_of(&task), Ending::Stopped, "a stop the person asked for stays a stop");+    }++    /// Every way out of `finish` tells the activity stack how the import ended (a guard dropped without+    /// saying leaves no row): an Auto Import and a folder read that met unreadable files, and an import that+    /// only found duplicates it showed.+    #[test]+    fn every_way_an_import_ends_leaves_its_row_when_it_needs_one() {+        use lightcraft_engine::activity::{Cancel, Ending};+        let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });+        let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0);+        h.app.session.activity.keep_after(0);+        let ctx = egui::Context::default();+        let started = |h: &mut crate::headless::Headless| h.app.session.activity.start("import", "Importing", Cancel::Yes);+        // an Auto Import that could not read a file, though it was short+        let mut auto = ImportTask::new(vec![], json!({}), 0, false).auto();+        auto.failed = 1;+        auto.imported = 2;+        auto.guard = Some(started(&mut h));+        finish(&mut h.app, &ctx, auto);+        // a folder read for the Local view that could read none+        let mut browse = ImportTask::new(vec![], json!({}), 0, true);+        browse.failed = 3;+        browse.guard = Some(started(&mut h));+        finish(&mut h.app, &ctx, browse);+        // one that found only photos the library has, which it shows (and says so in a toast, as ever)+        let existing: Vec<u64> = h.app.session.catalog.photos().take(2).map(|p| p.id.0).collect();+        let mut quiet = ImportTask::new(vec![], json!({}), 0, false);+        quiet.existing = existing;+        quiet.guard = Some(started(&mut h));+        finish(&mut h.app, &ctx, quiet);+        let rows = h.app.session.activity.finished();+        assert!(h.app.session.activity.list().is_empty());+        let endings: Vec<(Ending, usize)> = rows.iter().rev().map(|r| (r.ending, r.failed)).collect();+        assert_eq!(endings, vec![(Ending::Problems, 1), (Ending::Failed, 3), (Ending::Done, 0)], "{rows:?}");+    }++    /// An import that met XMP warnings says so in its row, not only in the toast that passes.+    #[test]+    fn an_import_with_xmp_warnings_says_so_in_its_row() {+        use lightcraft_engine::activity::Cancel;+        let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });+        let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0);+        h.app.session.activity.keep_after(0);+        let ctx = egui::Context::default();+        let warning =+            || ImportWarning { path: "a.jpg".into(), code: ImportWarningCode::UnmappedXmp, profile: None, mode: None, fields: vec!["x".into()] };+        for (auto, browse) in [(true, false), (false, true), (false, false)] {+            let mut task = ImportTask::new(vec![], json!({}), 0, browse);+            task.auto = auto;+            task.imported = 1;+            task.warnings = vec![warning()];+            task.guard = Some(h.app.session.activity.start("import", "Importing", Cancel::Yes));+            finish(&mut h.app, &ctx, task);+        }+        let rows = h.app.session.activity.finished();+        assert_eq!(rows.len(), 3, "{rows:?}");+        assert!(rows.iter().all(|r| r.detail.contains("1 import warning")), "{rows:?}");+    }+}diff --git a/crates/ui-egui/src/dialogs/merge.rs b/crates/ui-egui/src/dialogs/merge.rs--- a/crates/ui-egui/src/dialogs/merge.rs+++ b/crates/ui-egui/src/dialogs/merge.rs@@ -12,7 +12,7 @@ use std::sync::mpsc::{Receiver, channel}; use std::sync::{Arc, Mutex};  use lightcraft_catalog::PhotoId;-use lightcraft_engine::activity::{Cancel, TaskGuard, Unit};+use lightcraft_engine::activity::{Cancel, Ending, Outcome, TaskGuard, Unit}; use lightcraft_engine::merge::{MergeJob, MergeOutput}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json};@@ -307,22 +307,26 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {                 } else {                     "HDR"                 });-                match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) {+                let (ending, said) = match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) {                     Ok(v) => {                         let message = added_message(what, &v);                         app.merge.last_result = Some(v);-                        app.toast(ctx, message);+                        (Ending::Done, message)                     }                     Err(_) if t.cancel.load(Ordering::Relaxed) => {                         // cancelling one merge of a batch stops the batch                         app.merge.batch.clear();-                        app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what))+                        (Ending::Stopped, crate::i18n::tr_format!("{what} merge cancelled", what = what))                     }                     Err(e) => {                         app.ui.status = e.to_string();-                        app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what));+                        (Ending::Failed, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what))                     }+                };+                if let Some(guard) = t.guard {+                    guard.finish(Outcome { ending, summary: said.clone(), ..Default::default() });                 }+                app.toast(ctx, said);                 // a failed set doesn't stop the others                 while !app.merge.batch.is_empty() {                     match next_in_batch(app) {diff --git a/crates/ui-egui/src/dialogs/sam_model.rs b/crates/ui-egui/src/dialogs/sam_model.rs--- a/crates/ui-egui/src/dialogs/sam_model.rs+++ b/crates/ui-egui/src/dialogs/sam_model.rs@@ -25,9 +25,11 @@ pub(crate) fn show_model_folder(app: &mut LightcraftApp) -> Result<serde_json::V             "Show in File Manager",             Some("openModelFolder"),             move || open_folder(&folder),-            |app, ctx, result| {-                if let Err(e) = result {-                    app.toast_error(ctx, e);+            |app, ctx, result| match result {+                Ok(()) => lightcraft_engine::activity::Outcome::default(),+                Err(e) => {+                    app.toast_error(ctx, e.clone());+                    lightcraft_engine::activity::Outcome::failed(e)                 }             },         )?;diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs--- a/crates/ui-egui/src/export_task.rs+++ b/crates/ui-egui/src/export_task.rs@@ -11,8 +11,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, channel}; use std::sync::{Arc, Mutex}; -use lightcraft_engine::activity::{Cancel, TaskGuard};-use lightcraft_engine::export::{AfterExport, Destination, ExportOptions, PreparedExport, run_batch};+use lightcraft_engine::activity::{Cancel, Ending, Failure, Outcome, TaskGuard};+use lightcraft_engine::export::{AfterExport, Destination, DestinationMode, ExportOptions, PreparedExport, run_batch}; use serde_json::{Value, json};  use crate::LightcraftApp;@@ -27,6 +27,8 @@ pub struct ExportTask {     rx: Receiver<Result<(Vec<Value>, Option<Value>, bool), String>>,     /// The export's row in the activity stack (`activity.cancel` sets `cancel`).     guard: TaskGuard,+    /// The folder the files go to, for the row the export leaves when it ends.+    folder: Option<String>, }  impl ExportTask {@@ -37,6 +39,22 @@ impl ExportTask {     } } +/// The folder a file is in, for the row of the contact sheet or print that writes it (none for a bare file name).+fn folder_of(path: &str) -> Option<String> {+    std::path::Path::new(path).parent().map(|d| d.to_string_lossy().into_owned()).filter(|d| !d.is_empty())+}++/// The folder the files of an export go to, for the row it leaves: the one an exact file path is in (the engine+/// uses it for a single photo only), none when each photo goes to its own folder (`dir` is not used then), else `dir`.+fn files_folder(to: &Destination, mode: DestinationMode, photos: usize) -> Option<String> {+    match (to.exact.as_ref().filter(|_| photos == 1), mode) {+        (Some(path), _) => folder_of(path),+        (None, DestinationMode::SameAsOriginal) => None,+        (None, _) => Some(to.dir.clone()),+    }+    .filter(|dir| !dir.is_empty())+}+ /// Start exporting `items` in the background, then do `after` with the files written (Add to Apple /// Photos). Errors per photo are collected, not fatal. pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOptions, to: Destination, after: AfterExport) -> Result<Value, String> {@@ -49,6 +67,7 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp     let cancel = Arc::new(AtomicBool::new(false));     let (tx, rx) = channel();     let (p, c) = (progress.clone(), cancel.clone());+    let folder = files_folder(&to, opts.destination_mode, total);     let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));     guard.progress(0, total as u64);     let row = guard.handle();@@ -81,7 +100,7 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp     std::thread::Builder::new().name("export".into()).spawn(work).map_err(|e| e.to_string())?;     #[cfg(target_arch = "wasm32")]     work();-    app.export = Some(ExportTask { total, progress, cancel, rx, guard });+    app.export = Some(ExportTask { total, progress, cancel, rx, guard, folder });     Ok(json!({"background": true, "total": total})) } @@ -100,6 +119,8 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va     let cancel = Arc::new(AtomicBool::new(false));     let (p, c) = (progress.clone(), cancel.clone());     let (tx, rx) = channel();+    // (where the PDF goes, for the row the sheet leaves when it ends)+    let folder = folder_of(&path);     let work = move || {         let result = prepared             .run(&mut |done, name| {@@ -128,7 +149,7 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va     work();     let row = app.session.activity.start("export", "Exporting contact sheet", Cancel::Flag(cancel.clone()));     row.progress(0, total as u64);-    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });+    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row, folder });     Ok(json!({"background": true, "total": total})) } @@ -153,6 +174,8 @@ pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, Str     let (p, c) = (progress.clone(), cancel.clone());     let (tx, rx) = channel();     let photos = ids.len();+    // (where the PDF went, for the row the print leaves when it ends)+    let folder = folder_of(&path);     let work = move || {         let result = prepared             .run(&mut |done, name| {@@ -181,7 +204,7 @@ pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, Str     work();     let row = app.session.activity.start("export", "Printing to PDF", Cancel::Flag(cancel.clone()));     row.progress(0, total as u64);-    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });+    app.export = Some(ExportTask { total, progress, cancel, rx, guard: row, folder });     Ok(json!({"background": true, "total": total})) } @@ -232,63 +255,113 @@ pub fn poll_photos(app: &mut LightcraftApp, ctx: &egui::Context) { #[cfg(target_arch = "wasm32")] pub fn poll_photos(_: &mut LightcraftApp, _: &egui::Context) {} -/// Per frame: keep the activity row up to date; when the batch finishes, report it.+/// How an export that ran ended, for the activity stack. Photos taking only some of the files is a warning, and+/// Photos not asked at all (`skipped`: the export wrote no files, an earlier request still running…) is not an error+/// either: not all went well in both cases.+fn export_ending(cancelled: bool, ok: usize, failed: usize, photos: Option<&Value>) -> Ending {+    let photos_short = photos.is_some_and(|p| ["error", "warning", "skipped"].iter().any(|k| p.get(k).is_some()));+    if cancelled {+        Ending::Stopped+    } else if failed > 0 && ok == 0 {+        Ending::Failed+    } else if failed > 0 || photos_short {+        Ending::Problems+    } else {+        Ending::Done+    }+}++/// An export that came back as an error. The error is the row's detail: its headline stays empty, so the row reads+/// "Exporting: failed" (or "Printing to PDF: failed") under the task's own name.+fn failed_outcome(error: String, stopped: bool) -> Outcome {+    Outcome { ending: if stopped { Ending::Stopped } else { Ending::Failed }, detail: error, ..Default::default() }+}++/// Per frame: keep the activity row up to date; when the batch finishes, report it: in a toast, as ever, and to+/// the activity stack, where an export that ran long or did not go well stays in `activity.finished` with its+/// failed files and where its results are. pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {     let Some(task) = &app.export else { return };-    match task.rx.try_recv() {-        Ok(r) => {-            let cancelled = matches!(&r, Ok((_, _, true)));-            let total = task.total;-            app.export = None;-            // Photos refused (permission, not installed…): leave the way out on screen longer-            let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some());-            let msg = match r {-                Ok((files, photos, _)) => {-                    let sheet = files.first().filter(|f| f["contactSheet"] == true);-                    let ok = files.iter().filter(|f| f.get("path").is_some()).count();-                    let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();-                    let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count();-                    let mut m =-                        crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total);-                    if let Some(sheet) = sheet {-                        m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]);-                    }-                    if let Some(pdf) = files.first().filter(|f| f["print"] == true) {-                        m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]);-                    }-                    if skipped > 0 {-                        m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped);-                    }-                    if let Some(first) = failed.first() {-                        m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error"));-                    }-                    if cancelled {-                        m += " · cancelled";-                    }-                    if let Some(photos) = &photos {-                        m += " · ";-                        m += &apple_photos_note(photos);-                    }-                    app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos}));-                    m-                }-                Err(e) => e,-            };-            if photos_failed {-                app.toast_for(ctx, msg, 15.0);-            } else {-                app.toast(ctx, msg);-            }-        }+    let r = match task.rx.try_recv() {+        Ok(r) => Some(r),         Err(std::sync::mpsc::TryRecvError::Empty) => {             let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default();             task.guard.progress(done as u64, task.total as u64);             task.guard.detail(&current);+            return;         }-        Err(std::sync::mpsc::TryRecvError::Disconnected) => {-            app.export = None;-            app.toast(ctx, crate::i18n::tr("Export stopped unexpectedly"));+        Err(std::sync::mpsc::TryRecvError::Disconnected) => None,+    };+    let Some(task) = app.export.take() else { return };+    let total = task.total;+    let folder = task.folder.clone();+    // (a contact sheet that was stopped comes back as an error: it was stopped all the same)+    let stopped = task.cancel.load(Ordering::Relaxed);+    let Some(r) = r else {+        let msg = crate::i18n::tr("Export stopped unexpectedly");+        task.guard.finish(Outcome { ending: Ending::Died, summary: msg.to_string(), path: folder, ..Default::default() });+        app.toast(ctx, msg);+        return;+    };+    let cancelled = matches!(&r, Ok((_, _, true)));+    // Photos refused (permission, not installed…): leave the way out on screen longer+    let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some());+    let (msg, outcome) = match r {+        Ok((files, photos, _)) => {+            let sheet = files.first().filter(|f| f["contactSheet"] == true);+            let ok = files.iter().filter(|f| f.get("path").is_some()).count();+            let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();+            let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count();+            let mut m = crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total);+            // (the finished row keeps this headline: it is translated like the toast's)+            if let Some(sheet) = sheet {+                m = crate::i18n::tr_format!(+                    "Exported contact sheet: {photos} photos, {pages} pages",+                    photos = sheet["photos"],+                    pages = sheet["pages"]+                );+            }+            if let Some(pdf) = files.first().filter(|f| f["print"] == true) {+                m = crate::i18n::tr_format!("Printed to PDF: {photos} photos, {pages} pages", photos = pdf["photos"], pages = pdf["pages"]);+            }+            // what was done is the row's first line; the rest is what needs reading under it+            let summary = m.clone();+            if skipped > 0 {+                m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped);+            }+            if let Some(first) = failed.first() {+                m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error"));+            }+            if cancelled {+                m += " · ";+                m += crate::i18n::tr("cancelled");+            }+            if let Some(photos) = &photos {+                m += " · ";+                m += &apple_photos_note(photos);+            }+            let ending = export_ending(cancelled, ok, failed.len(), photos.as_ref());+            // one of the files written shows where they all are (a subfolder of the folder asked for, maybe)+            let path = files.iter().find_map(|f| f["path"].as_str()).map(str::to_string).or(folder);+            let failures = failed+                .iter()+                .map(|f| Failure {+                    name: f["file"].as_str().unwrap_or_default().to_string(),+                    reason: f["error"].as_str().unwrap_or("error").to_string(),+                })+                .collect();+            let detail = m.get(summary.len()..).unwrap_or_default().trim_start_matches(" · ").to_string();+            app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos}));+            (m, Outcome { ending, summary, detail, failures, path, ..Default::default() })         }+        // (the folder stays on the row, as for a worker that died: something may have been written there)+        Err(e) => (e.clone(), Outcome { path: folder, ..failed_outcome(e, stopped) }),+    };+    task.guard.finish(outcome);+    if photos_failed {+        app.toast_for(ctx, msg, 15.0);+    } else {+        app.toast(ctx, msg);     } } @@ -396,6 +469,48 @@ mod contact_sheet_tests {         let task = app.export.take().unwrap();         assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err());     }++    /// A contact sheet stopped by its ✕ comes back as an error, but it is a stopped task, not a failed one.+    #[test]+    fn a_stopped_contact_sheet_leaves_a_stopped_row_that_knows_its_folder() {+        let mut app = LightcraftApp::new(+            lightcraft_engine::Session::with_demo(),+            crate::Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() },+        );+        let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect();+        let path = std::env::temp_dir().join("lc-stopped-sheet.pdf");+        start_contact_sheet(&mut app, &json!({"path": path.to_string_lossy(), "ids": ids})).unwrap();+        let id = app.session.activity.list()[0].id;+        app.session.activity.cancel(id).unwrap();+        let ctx = egui::Context::default();+        let deadline = Instant::now() + Duration::from_secs(60);+        while app.export.is_some() {+            poll(&mut app, &ctx);+            assert!(Instant::now() < deadline, "the stopped sheet did not end");+            std::thread::sleep(Duration::from_millis(10));+        }+        let row = app.session.activity.finished().into_iter().next().expect("a stopped task always leaves a row");+        assert_eq!((row.kind, row.ending), ("export", Ending::Stopped), "{row:?}");+        // (the row keeps the folder the sheet was to go to: something may have been written there)+        assert_eq!(row.path.as_deref(), path.parent().and_then(|d| d.to_str()), "{row:?}");+    }++    /// A worker that is gone without a word leaves a row saying the export stopped unexpectedly.+    #[test]+    fn an_export_whose_worker_is_gone_leaves_a_died_row() {+        let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default());+        let cancel = Arc::new(AtomicBool::new(false));+        let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));+        let (tx, rx) = channel();+        drop(tx);+        app.export =+            Some(ExportTask { total: 3, progress: Arc::new(Mutex::new((0, String::new()))), cancel, rx, guard, folder: Some("/tmp/out".into()) });+        poll(&mut app, &egui::Context::default());+        assert!(app.export.is_none());+        let row = app.session.activity.finished().into_iter().next().expect("a worker that died always leaves a row");+        assert_eq!((row.kind, row.ending, row.path.as_deref()), ("export", Ending::Died, Some("/tmp/out")), "{row:?}");+        assert_eq!(row.summary, "Export stopped unexpectedly");+    } }  #[cfg(test)]@@ -424,4 +539,36 @@ mod tests {             "not added to Apple Photos: an earlier, cancelled request is still running"         );     }++    /// An Apple Photos step that did not add the files (error, warning, or skipped without a cancel) is a problem.+    #[test]+    fn export_ending_counts_an_uncancelled_photos_skip_as_a_problem() {+        let skipped = json!({"skipped": "the export wrote no files"});+        assert_eq!(export_ending(false, 3, 0, Some(&skipped)), Ending::Problems);+        assert_eq!(export_ending(false, 3, 0, Some(&json!({"warning": "added 1 of 3"}))), Ending::Problems);+        assert_eq!(export_ending(false, 3, 0, Some(&json!({"imported": 3}))), Ending::Done);+        assert_eq!(export_ending(false, 3, 0, None), Ending::Done);+        assert_eq!(export_ending(false, 0, 2, None), Ending::Failed);+        assert_eq!(export_ending(true, 3, 0, Some(&json!({"skipped": "cancelled", "cancelled": true}))), Ending::Stopped);+    }++    /// The row of an export points at the folder its files go to, not at one it does not use.+    #[test]+    fn an_exports_row_names_the_folder_the_files_go_to() {+        let to = |dir: &str, exact: Option<&str>| Destination { dir: dir.into(), exact: exact.map(str::to_string) };+        assert_eq!(files_folder(&to("/out", None), DestinationMode::SpecificFolder, 3).as_deref(), Some("/out"));+        assert_eq!(files_folder(&to("/out", Some("/pics/one.jpg")), DestinationMode::SpecificFolder, 1).as_deref(), Some("/pics"));+        // (an exact file is for a single photo: with more, the files go to the folder)+        assert_eq!(files_folder(&to("/out", Some("/pics/one.jpg")), DestinationMode::SpecificFolder, 2).as_deref(), Some("/out"));+        assert_eq!(files_folder(&to("/out", None), DestinationMode::SameAsOriginal, 3), None);+        assert_eq!(files_folder(&to("", None), DestinationMode::SpecificFolder, 1), None);+    }++    /// A failed export keeps its name in the row's headline; the error goes under it.+    #[test]+    fn a_failed_export_says_why_under_its_name() {+        let o = failed_outcome("The disk is full".into(), false);+        assert_eq!((o.ending, o.summary.as_str(), o.detail.as_str()), (Ending::Failed, "", "The disk is full"));+        assert_eq!(failed_outcome("x".into(), true).ending, Ending::Stopped);+    } }diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs--- a/crates/ui-egui/src/lightroom_import.rs+++ b/crates/ui-egui/src/lightroom_import.rs@@ -8,7 +8,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, mpsc}; -use lightcraft_engine::activity::{Cancel, TaskGuard};+use lightcraft_engine::activity::{Cancel, Ending, Failure, Outcome, TaskGuard}; use lightcraft_engine::lightroom_job::{LightroomProgress, LightroomStep}; use serde_json::{Value, json}; @@ -42,7 +42,8 @@ pub struct LightroomTask {     rx: mpsc::Receiver<Message>,     phase: Phase,     cancelled: bool,-    guard: TaskGuard,+    /// Its row in the activity stack; taken at the end to say how the task went.+    guard: Option<TaskGuard>, }  impl LightroomTask {@@ -60,7 +61,7 @@ impl LightroomTask {             Kind::Import => "Importing Lightroom catalog",         };         let guard = s.activity.start("lightroom", label, Cancel::Flag(cancel.clone()));-        let task = Self { kind, path, cancel, progress, rx, phase: Phase::Reading, cancelled: false, guard };+        let task = Self { kind, path, cancel, progress, rx, phase: Phase::Reading, cancelled: false, guard: Some(guard) };         task.refresh();         task     }@@ -68,7 +69,9 @@ impl LightroomTask {     /// Move to `phase`; only reading the catalog can stop, adding the photos runs to the end.     fn enter(&mut self, phase: Phase) {         self.phase = phase;-        self.guard.set_cancellable(phase == Phase::Reading);+        if let Some(guard) = &self.guard {+            guard.set_cancellable(phase == Phase::Reading);+        }         self.refresh();     } @@ -76,6 +79,7 @@ impl LightroomTask {     /// (reading the photos takes nearly all of an import, so it is counted, not waited out);     /// then what is being done.     fn refresh(&self) {+        let Some(guard) = &self.guard else { return };         let (step, done, total) = self.progress.step();         let detail = match (self.phase, self.kind, step) {             (Phase::Reading, Kind::Inspect, _) => "",@@ -86,11 +90,11 @@ impl LightroomTask {             (Phase::Finalizing, ..) => crate::i18n::tr("Saving Lightroom import index…"),         };         if self.phase == Phase::Reading {-            self.guard.progress(done as u64, total as u64);+            guard.progress(done as u64, total as u64);         } else {-            self.guard.progress(0, 0);+            guard.progress(0, 0);         }-        self.guard.detail(detail);+        guard.detail(detail);     }      #[cfg(not(target_arch = "wasm32"))]@@ -258,7 +262,7 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> }  /// The task is over: keep its result (with the catalog's `path`) for `status` and say how it went.-fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, mut value: Value) {+fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &mut LightroomTask, mut value: Value) {     let kind = task.kind;     if let Some(o) = value.as_object_mut() {         o.entry("path").or_insert_with(|| json!(task.path));@@ -306,11 +310,33 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask,         app.lightroom_result = Some(result_dialog(&value, &message));         show_result(app);     }+    let ending = if value.get("error").is_some() {+        Ending::Failed+    } else if value.get("cancelled").and_then(Value::as_bool).unwrap_or(false) {+        Ending::Stopped+    } else if value.get("indexWarning").is_some() || value.get("report").unwrap_or(&value)["failed"].as_array().is_some_and(|f| !f.is_empty()) {+        Ending::Problems+    } else {+        Ending::Done+    };+    if let Some(guard) = task.guard.take() {+        guard.finish(Outcome { ending, summary: message.clone(), failures: failed_files(&value), ..Default::default() });+    }     app.lightroom_last = Some(value);     app.lightroom = None;     app.toast_for(ctx, message, 6.0); } +/// The files an import could not do, each with its reason, for the row it leaves (`report.failed` is a list of+/// `[path, reason]`).+fn failed_files(value: &Value) -> Vec<Failure> {+    let failed = value.get("report").unwrap_or(value)["failed"].as_array().map(Vec::as_slice).unwrap_or_default();+    failed+        .iter()+        .map(|f| Failure { name: f[0].as_str().unwrap_or_default().to_string(), reason: f[1].as_str().unwrap_or_default().to_string() })+        .collect()+}+ fn show_result(app: &mut LightcraftApp) {     if app.ui.dialog.is_none() {         app.ui.dialog = app.lightroom_result.take();@@ -441,7 +467,11 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {             return;         }         Err(mpsc::TryRecvError::Disconnected) => {-            terminal(app, ctx, &task, json!({"error": "Lightroom worker stopped unexpectedly"}));+            // the worker died (a panic): its row says so, rather than that the catalog failed+            if let Some(guard) = task.guard.take() {+                guard.finish(Outcome::ended(Ending::Died));+            }+            terminal(app, ctx, &mut task, json!({"error": "Lightroom worker stopped unexpectedly"}));             return;         }     };@@ -449,35 +479,31 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {     // error's words: a real failure that mentions "cancelled" is still reported as a failure.     match message {         Message::Inspected(result) => match result {-            Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &task, report),-            Ok(_) => terminal(app, ctx, &task, json!({"cancelled": true})),-            Err(_) if task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &task, json!({"cancelled": true})),-            Err(e) => terminal(app, ctx, &task, json!({"error": e})),+            Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &mut task, report),+            Ok(_) => terminal(app, ctx, &mut task, json!({"cancelled": true})),+            Err(_) if task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &mut task, json!({"cancelled": true})),+            Err(e) => terminal(app, ctx, &mut task, json!({"error": e})),         },         Message::Prepared(result) => {             let prepared = match result {                 Ok(p) => p,                 Err(_) if task.cancel.load(Ordering::Relaxed) => {-                    terminal(app, ctx, &task, json!({"cancelled": true}));+                    terminal(app, ctx, &mut task, json!({"cancelled": true}));                     return;                 }                 Err(e) => {-                    terminal(app, ctx, &task, json!({"error": e}));+                    terminal(app, ctx, &mut task, json!({"error": e}));                     return;                 }             };             if task.cancel.load(Ordering::Relaxed) || prepared.is_cancelled() || !prepared.token().matches(&app.session) {                 prepared.rollback();-                terminal(-                    app,-                    ctx,-                    &task,-                    if task.cancel.load(Ordering::Relaxed) {-                        json!({"cancelled": true})-                    } else {-                        json!({"error": "Lightroom import belongs to a different open library"})-                    },-                );+                let value = if task.cancel.load(Ordering::Relaxed) {+                    json!({"cancelled": true})+                } else {+                    json!({"error": "Lightroom import belongs to a different open library"})+                };+                terminal(app, ctx, &mut task, value);                 return;             }             task.enter(Phase::Committing);@@ -494,7 +520,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {             let (tx, rx) = mpsc::channel();             let commit_error = committed.as_ref().err().map(ToString::to_string);             let Some(finalization) = finalization else {-                terminal(app, ctx, &task, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())}));+                terminal(app, ctx, &mut task, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())}));                 return;             };             if let Err(e) = spawn_finalize(finalization, tx, ctx.clone()) {@@ -502,7 +528,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {                 if let Some(error) = commit_error {                     result["error"] = json!(error);                 }-                terminal(app, ctx, &task, result);+                terminal(app, ctx, &mut task, result);                 return;             }             task.rx = rx;@@ -518,14 +544,14 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {             let pending = app.lightroom_last.take().unwrap_or(Value::Null);             let report = pending.get("report").cloned().unwrap_or(Value::Null);             match result {-                Ok(()) if pending.get("error").is_none() => terminal(app, ctx, &task, report),-                Ok(()) => terminal(app, ctx, &task, json!({"report": report, "error": pending["error"].clone()})),+                Ok(()) if pending.get("error").is_none() => terminal(app, ctx, &mut task, report),+                Ok(()) => terminal(app, ctx, &mut task, json!({"report": report, "error": pending["error"].clone()})),                 Err(e) => {                     let mut value = json!({"report": report, "indexWarning": e});                     if let Some(error) = pending.get("error") {                         value["error"] = error.clone();                     }-                    terminal(app, ctx, &task, value);+                    terminal(app, ctx, &mut task, value);                 }             }         }@@ -573,6 +599,17 @@ mod tests {         h     } +    /// An import that could not do some files names them in its row, whether the result is the report or wraps it.+    #[test]+    fn the_row_of_an_import_names_the_files_it_could_not_do() {+        let report = serde_json::json!({"failed": [["/photos/a.jpg", "no such file"], ["/photos/b.jpg", "unreadable"]], "photos": 2});+        let names = |v: &Value| failed_files(v).into_iter().map(|f| (f.name, f.reason)).collect::<Vec<_>>();+        let expected = vec![("/photos/a.jpg".to_string(), "no such file".to_string()), ("/photos/b.jpg".to_string(), "unreadable".to_string())];+        assert_eq!(names(&serde_json::json!({"report": report.clone()})), expected);+        assert_eq!(names(&report), expected);+        assert!(failed_files(&serde_json::json!({"imported": 3})).is_empty());+    }+     #[test]     fn lightroom_row_is_not_cancellable_while_committing() {         let mut h = demo();@@ -649,9 +686,9 @@ mod tests {             mode: lightcraft_catalog::rules::Match::All,             rules: vec![lightcraft_catalog::rules::Rule::Field { field: "rating".into(), op: "is".into(), value: json!(5) }],         });-        let (task, _tx, _, _) = fake_import(&h);+        let (mut task, _tx, _, _) = fake_import(&h);         let value = json!({"photos": 1, "imported": 1, "collections": 0, "missing": ["D:/Photos/a.NEF"], "mapping": {"1": id}, "library": "C:/Pictures/LightCraft Library"});-        terminal(&mut h.app, &h.view.ctx, &task, value);+        terminal(&mut h.app, &h.view.ctx, &mut task, value);         assert!(h.app.ui.toast.as_ref().unwrap().0.contains("1 photos added"));         h.step();         h.step();@@ -676,8 +713,8 @@ mod tests {     #[test]     fn lightroom_failure_stays_readable_without_offering_a_success_action() {         let mut h = demo();-        let (task, _tx, _, _) = fake_import(&h);-        terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog or WAL exceeds 1 GiB import limit"}));+        let (mut task, _tx, _, _) = fake_import(&h);+        terminal(&mut h.app, &h.view.ctx, &mut task, json!({"error": "catalog or WAL exceeds 1 GiB import limit"}));         h.step();         h.step();         assert!(h.painted_text().join(" ").contains("exceeds 1 GiB import limit"));@@ -689,8 +726,8 @@ mod tests {     fn lightroom_result_waits_for_an_existing_dialog() {         let mut h = demo();         h.app.ui.dialog = Some(crate::state::Dialog::About);-        let (task, _tx, _, _) = fake_import(&h);-        terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog could not be read"}));+        let (mut task, _tx, _, _) = fake_import(&h);+        terminal(&mut h.app, &h.view.ctx, &mut task, json!({"error": "catalog could not be read"}));         assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::About)));         assert!(h.app.lightroom_result.is_some());         h.app.ui.dialog = None;diff --git a/crates/ui-egui/src/menus/menubar/tests.rs b/crates/ui-egui/src/menus/menubar/tests.rs--- a/crates/ui-egui/src/menus/menubar/tests.rs+++ b/crates/ui-egui/src/menus/menubar/tests.rs@@ -622,7 +622,7 @@ fn find_missing_waited_completes_while_unrelated_task_remains_active() {         move || {             let _ = rx_unblock.recv();         },-        |_app, _ctx, _| {},+        |_app, _ctx, _| lightcraft_engine::activity::Outcome::default(),     )     .unwrap();     assert!(app.tasks.is_running("Unrelated Long Background Task"));diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs--- a/crates/ui-egui/src/menus/run.rs+++ b/crates/ui-egui/src/menus/run.rs@@ -1499,22 +1499,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re                         } else {                             String::new()                         };-                        app.toast(-                            ctx,-                            crate::i18n::tr_format!(-                                "Found {n} missing photo{}; {left} still missing{unsure}",-                                if n == 1 { "" } else { "s" },-                                left = left,-                                n = n,-                                unsure = unsure-                            ),+                        let said = crate::i18n::tr_format!(+                            "Found {n} missing photo{}; {left} still missing{unsure}",+                            if n == 1 { "" } else { "s" },+                            left = left,+                            n = n,+                            unsure = unsure                         );+                        app.toast(ctx, said.clone());                         app.ui.last_find_missing = Some(v.clone());                         app.ui.last_find_missing_result = Some(Ok(v));+                        lightcraft_engine::activity::Outcome { summary: said, ..Default::default() }                     }                     Err(e) => {                         app.toast(ctx, e.clone());-                        app.ui.last_find_missing_result = Some(Err(e));+                        app.ui.last_find_missing_result = Some(Err(e.clone()));+                        lightcraft_engine::activity::Outcome::failed(e)                     }                 }             };diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs--- a/crates/ui-egui/src/panels/library_problem.rs+++ b/crates/ui-egui/src/panels/library_problem.rs@@ -616,6 +616,7 @@ fn start_checks(app: &mut LightcraftApp, paths: Vec<String>) {                 app.recent_checks.known.insert(path, Answer::from_check(why, delivered_frame));                 apply_answers(app);                 ctx.request_repaint();+                lightcraft_engine::activity::Outcome::default()             }         });         match started {diff --git a/crates/ui-egui/src/recheck.rs b/crates/ui-egui/src/recheck.rs--- a/crates/ui-egui/src/recheck.rs+++ b/crates/ui-egui/src/recheck.rs@@ -3,6 +3,7 @@ //! `lightcraft_engine::cmd::recheck`). The probe runs on a worker thread, so opening never waits //! for it; the result is applied back here, unless another library was opened meanwhile. +use lightcraft_engine::activity::{Ending, Outcome}; use lightcraft_engine::cmd::recheck;  use crate::LightcraftApp;@@ -26,7 +27,8 @@ pub fn tick(app: &mut LightcraftApp) {     let work = move || recheck::probe(&probe, stale);     let done = move |app: &mut LightcraftApp, ctx: &egui::Context, results| {         if app.session.library_generation() != library {-            return;+            // the library was left: the user did not stop this, and there is nothing to look at+            return Outcome::ended(Ending::Done);         }         match recheck::apply(&mut app.session, results) {             Ok(r) => {@@ -36,8 +38,12 @@ pub fn tick(app: &mut LightcraftApp) {                 } else if n > 1 {                     app.toast(ctx, crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n));                 }+                Outcome::ended(Ending::Done)+            }+            Err(e) => {+                log::warn!("recheck of preview-only raws: {e}");+                Outcome::failed(e.to_string())             }-            Err(e) => log::warn!("recheck of preview-only raws: {e}"),         }     };     if let Err(e) = crate::tasks::spawn(app, LABEL, Some("recheck"), work, done) {diff --git a/crates/ui-egui/src/reload.rs b/crates/ui-egui/src/reload.rs--- a/crates/ui-egui/src/reload.rs+++ b/crates/ui-egui/src/reload.rs@@ -9,6 +9,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool;  use lightcraft_engine::EngineError;+use lightcraft_engine::activity::{Ending, Failure, Outcome}; use lightcraft_engine::cmd::reload; use serde_json::{Value, json}; @@ -48,30 +49,48 @@ pub fn command(app: &mut LightcraftApp, p: &Value) -> Option<Result<Value, Strin         let total = items.len() as u64;         task.progress(0, total);         let results = reload::probe(probe.as_ref(), &items, &flag, &|done| task.progress(done as u64, total));-        (items, results)+        // the files that could not be read (a stop leaves the ones it never reached as errors: those are not failures)+        let unread: Vec<Failure> = if flag.load(std::sync::atomic::Ordering::Relaxed) {+            Vec::new()+        } else {+            items.iter().zip(&results).filter_map(|(i, r)| r.as_ref().err().map(|e| Failure { name: i.path.clone(), reason: e.clone() })).collect()+        };+        (items, results, unread)     };-    let done = move |app: &mut LightcraftApp, ctx: &egui::Context, (items, results)| {+    let done = move |app: &mut LightcraftApp, ctx: &egui::Context, (items, results, unread): (_, _, Vec<Failure>)| {+        let was_stopped = stopped.load(std::sync::atomic::Ordering::Relaxed);+        let ending = if was_stopped {+            Ending::Stopped+        } else if unread.is_empty() {+            Ending::Done+        } else {+            Ending::Problems+        };+        let outcome = Outcome { ending, failures: unread, ..Default::default() };         if app.session.library_generation() != library {-            return;+            // the library was left: there is nothing to look at (a ✕ pressed before that still counts)+            return outcome;         }         // as the command: one undo step, the durable save and the automatic XMP sidecar write         if let Err(e) = app.session.execute_fn("photo.reload", |s| reload::apply(s, items, results).map(|ids| reload::to_json(&ids))) {-            return app.toast_error(ctx, e.to_string());+            app.toast_error(ctx, e.to_string());+            return Outcome::failed(e.to_string());         }         let cleared = preview_only.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|ph| ph.preview_only.is_none())).count();         // stopped before the files were read: they weren't tried again, so say nothing about them         // (a raw that did decode before the stop is still reported)-        if stopped.load(std::sync::atomic::Ordering::Relaxed) && cleared == 0 {-            return;+        if was_stopped && cleared == 0 {+            return outcome;         }         let message = match (count, preview_only.len(), cleared) {             (1, 1, 1) => crate::i18n::tr("This raw is now developed from its raw data").to_string(),             (1, 1, _) => crate::i18n::tr("This raw variant still can't be decoded").to_string(),-            (_, _, 0) => return,+            (_, _, 0) => return outcome,             (_, _, 1) => crate::i18n::tr("1 raw can now be developed from its raw data").to_string(),             (_, _, n) => crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n),         };         app.toast(ctx, message);+        outcome     };     if let Err(e) = crate::tasks::spawn_tracked(app, LABEL, "reload", cancel, work, done) {         return Some(Err(e));diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs--- a/crates/ui-egui/src/sync.rs+++ b/crates/ui-egui/src/sync.rs@@ -7,7 +7,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use lightcraft_engine::activity::{Cancel, TaskGuard};+use lightcraft_engine::activity::{Cancel, Ending, Failure, Outcome, TaskGuard}; use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncCommit, SyncInput, SyncJob, SyncProgress, SyncStep, scan_with}; use serde_json::{Value, json}; @@ -106,6 +106,10 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {     }     if task.guard.is_cancelled() {         // ✕ on its row: a scan stopped part-way has nothing to show+        if let Some(task) = app.sync.take() {+            task.progress.files.cancel.store(true, Ordering::Relaxed);+            task.guard.finish(Outcome::ended(Ending::Stopped));+        }         cancel(app);         app.ui.dialog = None;         return;@@ -118,13 +122,24 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {         Ok(c) => c,         Err(std::sync::mpsc::TryRecvError::Empty) => return,         Err(_) => {-            app.sync = None;+            // the worker died (a panic, logged)+            if let Some(task) = app.sync.take() {+                task.guard.finish(Outcome::ended(Ending::Died));+            }             app.ui.dialog = None;             app.toast(ctx, crate::i18n::tr("Scan failed"));             return;         }     };-    app.sync = None;+    if let Some(task) = app.sync.take() {+        task.guard.finish(if changes.offline {+            Outcome::failed(crate::i18n::tr("This folder isn't there: it was moved or renamed, or its disk isn't connected."))+        } else if !changes.unreadable.is_empty() {+            unreadable_outcome(&changes)+        } else {+            Outcome::default()+        });+    }     let c = SyncCounts {         offline: changes.offline,         new: changes.new.len(),@@ -141,6 +156,20 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {     } } +/// The scan's end when files in the folder could not be read: the row names each one by where it is below the+/// scanned folder, with why.+fn unreadable_outcome(changes: &FolderChanges) -> Outcome {+    let roots = [changes.path.clone()];+    let failures = changes+        .unreadable+        .iter()+        .map(|c| Failure { name: crate::import::failure_name(&c.path, &roots), reason: c.error.clone().unwrap_or_default() })+        .take(lightcraft_engine::activity::MAX_FAILURES)+        .collect();+    let failed = changes.unreadable.len();+    Outcome { ending: Ending::Problems, detail: crate::import::not_readable_text(failed), failed, failures, ..Default::default() }+}+ /// How far the scan is: the folders and files listed while it lists the folder, else the files /// and photos checked (done, of how many). enum ScanStage {@@ -275,7 +304,8 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {     job();     let guard = app.session.activity.start("sync", "Synchronizing folder", Cancel::Flag(progress.files.cancel.clone()));     guard.detail(name);-    app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new(), guard });+    app.sync_run =+        Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new(), guard: Some(guard) });     Ok(json!({"started": true})) } @@ -292,8 +322,8 @@ pub struct SyncRun {     commit: Option<SyncCommit>,     /// Cancel was pressed: nothing new is started; what was readied is still committed.     stopping: bool,-    /// Its row in the activity stack (its ✕ is Cancel).-    guard: TaskGuard,+    /// Its row in the activity stack (its ✕ is Cancel); taken at the end to say how the run went.+    guard: Option<TaskGuard>, }  impl SyncRun {@@ -332,7 +362,7 @@ pub(crate) fn stall_run(app: &mut LightcraftApp, name: &str) -> Box<dyn std::any         commit: None,         stopping: false,         not_saved: Vec::new(),-        guard,+        guard: Some(guard),     });     Box::new(tx) }@@ -342,11 +372,13 @@ pub(crate) fn stall_run(app: &mut LightcraftApp, name: &str) -> Box<dyn std::any pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) {     let Some(run) = app.sync_run.as_mut() else { return };     ctx.request_repaint_after(std::time::Duration::from_millis(100));-    if run.guard.is_cancelled() {+    if run.guard.as_ref().is_some_and(TaskGuard::is_cancelled) {         run.stopping = true;     }     let (done, total) = run.counts();-    run.guard.progress(done as u64, total as u64);+    if let Some(guard) = &run.guard {+        guard.progress(done as u64, total as u64);+    }     let mut steps = Vec::new();     let finished = loop {         match run.rx.try_recv() {@@ -372,8 +404,8 @@ pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) {         run.commit = Some(commit);         return;     }-    let (name, stopped, crashed, mut not_saved) =-        (run.name.clone(), run.stopping, run.crashed.load(Ordering::Relaxed), std::mem::take(&mut run.not_saved));+    let (name, stopped, crashed, mut not_saved, guard) =+        (run.name.clone(), run.stopping, run.crashed.load(Ordering::Relaxed), std::mem::take(&mut run.not_saved), run.guard.take());     app.sync_run = None;     let mut report = None;     if let Err(e) = app.session.execute_fn("folder.synchronize", |s| {@@ -405,6 +437,19 @@ pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) {         parts.push(format!("{}: {e}", crate::i18n::tr("Not saved")));     }     let text = if parts.is_empty() { crate::i18n::tr("The library is up to date with this folder.").to_string() } else { parts.join(" · ") };+    if let Some(guard) = guard {+        let ending = if crashed {+            Ending::Died+        } else if stopped {+            Ending::Stopped+        } else if !r.failed.is_empty() || !not_saved.is_empty() {+            Ending::Problems+        } else {+            Ending::Done+        };+        let failures = r.failed.iter().map(|(path, e)| Failure { name: path.clone(), reason: e.clone() }).collect();+        guard.finish(Outcome { ending, summary: format!("{name}: {text}"), failures, ..Default::default() });+    }     if not_saved.is_empty() && !crashed {         app.toast(ctx, format!("{name}: {text}"));     } else {diff --git a/crates/ui-egui/src/tasks.rs b/crates/ui-egui/src/tasks.rs--- a/crates/ui-egui/src/tasks.rs+++ b/crates/ui-egui/src/tasks.rs@@ -6,16 +6,18 @@  use std::sync::mpsc::{Receiver, TryRecvError, channel}; +use lightcraft_engine::activity::{Ending, Outcome, TaskGuard};+ use crate::LightcraftApp; -/// Applies a task's result to the app (on the UI thread).-type Finish = Box<dyn FnOnce(&mut LightcraftApp, &egui::Context) + Send>;+/// Applies a task's result to the app (on the UI thread), and says how the task ended.+type Finish = Box<dyn FnOnce(&mut LightcraftApp, &egui::Context) -> Outcome + Send>;  struct Task {     label: String,     rx: Receiver<Finish>,-    /// Its row in the activity stack (none for quiet tasks); goes with the task.-    _guard: Option<lightcraft_engine::activity::TaskGuard>,+    /// Its row in the activity stack (none for quiet tasks); it says how the task ended.+    guard: Option<TaskGuard>, }  /// The background tasks in flight.@@ -43,13 +45,14 @@ impl Tasks {  /// Run `work` on a worker thread, then `done(app, ctx, result)` on the UI thread (in the browser /// build, which has no threads, `work` runs at once and `done` on the next frame). With `kind`, the-/// activity stack shows `label` meanwhile, without a count and without ✕ (the work can't be stopped).+/// activity stack shows `label` meanwhile, without a count and without ✕ (the work can't be stopped);+/// what `done` returns is how its row says the task ended. pub fn spawn<T: Send + 'static>(     app: &mut LightcraftApp,     label: &str,     kind: Option<&'static str>,     work: impl FnOnce() -> T + Send + 'static,-    done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,+    done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) -> Outcome + Send + 'static, ) -> Result<(), String> {     let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No));     start(app, label, guard, work, done)@@ -57,14 +60,14 @@ pub fn spawn<T: Send + 'static>(  /// [`spawn`] for a task with progress that can stop: its row (`kind`, `label`) adopts `cancel`, /// the flag `work` checks, so the row's ✕ and `activity.cancel` stop it; `work` reports progress-/// through the handle it is given.+/// through the handle it is given; what `done` returns is how its row says the task ended. pub fn spawn_tracked<T: Send + 'static>(     app: &mut LightcraftApp,     label: &str,     kind: &'static str,     cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,     work: impl FnOnce(lightcraft_engine::activity::TaskHandle) -> T + Send + 'static,-    done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,+    done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) -> Outcome + Send + 'static, ) -> Result<(), String> {     let guard = app.session.activity.start(kind, label, lightcraft_engine::activity::Cancel::Flag(cancel));     let handle = guard.handle();@@ -75,15 +78,15 @@ pub fn spawn_tracked<T: Send + 'static>( fn start<T: Send + 'static>(     app: &mut LightcraftApp,     label: &str,-    guard: Option<lightcraft_engine::activity::TaskGuard>,+    guard: Option<TaskGuard>,     work: impl FnOnce() -> T + Send + 'static,-    done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,+    done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) -> Outcome + Send + 'static, ) -> Result<(), String> {     let (tx, rx) = channel::<Finish>();     let repaint = app.tasks.repaint.clone();     let job = move || {         let t = work();-        let _ = tx.send(Box::new(move |app: &mut LightcraftApp, ctx: &egui::Context| done(app, ctx, t)));+        let _ = tx.send(Box::new(move |app: &mut LightcraftApp, ctx: &egui::Context| done(app, ctx, t)) as Finish);         if let Some(c) = repaint {             c.request_repaint();         }@@ -92,7 +95,7 @@ fn start<T: Send + 'static>(     std::thread::Builder::new().name(format!("lc-task-{label}")).spawn(job).map_err(|e| format!("{label}: could not start: {e}"))?;     #[cfg(target_arch = "wasm32")]     job();-    app.tasks.running.push(Task { label: label.to_string(), rx, _guard: guard });+    app.tasks.running.push(Task { label: label.to_string(), rx, guard });     Ok(()) } @@ -103,14 +106,20 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {     while i < app.tasks.running.len() {         match app.tasks.running[i].rx.try_recv() {             Ok(finish) => {-                app.tasks.running.remove(i);-                finish(app, ctx);+                let t = app.tasks.running.remove(i);+                let outcome = finish(app, ctx);+                if let Some(guard) = t.guard {+                    guard.finish(outcome);+                }             }             Err(TryRecvError::Empty) => i += 1,             Err(TryRecvError::Disconnected) => {                 // the worker died (a panic, already logged)                 let t = app.tasks.running.remove(i);                 app.toast(ctx, crate::i18n::tr_format!("{} failed", t.label));+                if let Some(guard) = t.guard {+                    guard.finish(Outcome::ended(Ending::Died));+                }             }         }     }diff --git a/crates/ui-egui/src/tests/activity.rs b/crates/ui-egui/src/tests/activity.rs--- a/crates/ui-egui/src/tests/activity.rs+++ b/crates/ui-egui/src/tests/activity.rs@@ -2,7 +2,7 @@  use std::time::Duration; -use lightcraft_engine::activity::{Cancel, TaskInfo, Unit};+use lightcraft_engine::activity::{Cancel, Ending, TaskInfo, Unit}; use serde_json::json;  use crate::headless::Headless;@@ -336,6 +336,8 @@ fn scan_shows_a_row_and_cancel_closes_it() {         h.step();     }     assert!(h.app.ui.dialog.is_none(), "no review opened");+    let row = h.app.session.activity.finished().into_iter().next().expect("a stopped scan leaves a row");+    assert_eq!((row.label.as_str(), row.ending), ("Scanning folder", Ending::Stopped));     let _ = std::fs::remove_dir_all(&dir); } @@ -378,6 +380,8 @@ fn final_merge_shows_a_row_and_cancels() {     assert!(h.app.session.activity.list().is_empty());     let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();     assert_eq!(toast, "HDR merge cancelled");+    let row = h.app.session.activity.finished().into_iter().next().expect("a stopped merge leaves a row");+    assert_eq!((row.ending, row.summary.as_str()), (Ending::Stopped, "HDR merge cancelled"));     let _ = std::fs::remove_dir_all(dir); } @@ -467,6 +471,7 @@ fn batch_merge_runs_each_set_in_turn() { #[test] fn find_missing_shows_an_indeterminate_row() {     let mut h = demo();+    h.app.session.activity.keep_after(0);     let dir = std::env::temp_dir().join(format!("lc-activity-findmissing-{}", std::process::id()));     std::fs::create_dir_all(&dir).unwrap();     // run directly, not through a request (which runs frames): the search of an empty folder is over at once, but its@@ -479,6 +484,25 @@ fn find_missing_shows_an_indeterminate_row() {     assert_eq!((rows[0].total, rows[0].cancellable), (0, false), "indeterminate, no ✕");     assert!(h.step_until(T, |h| h.app.tasks.is_empty()));     assert!(h.app.session.activity.list().is_empty(), "the row goes with the task");+    let row = h.app.session.activity.finished().into_iter().next().expect("and a row says what it found");+    assert_eq!(row.ending, Ending::Done);+    assert!(row.summary.starts_with("Found 0 missing photos"), "{:?}", row.summary);+    let _ = std::fs::remove_dir_all(dir);+}++/// A Lightroom catalog that can't be read leaves a row saying the task failed, and why.+#[test]+fn a_lightroom_catalog_that_cannot_be_read_leaves_a_failed_row() {+    let mut h = demo();+    let dir = std::env::temp_dir().join(format!("lc-activity-lrcat-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    let path = dir.join("Not a catalog.lrcat");+    std::fs::write(&path, b"this is not a Lightroom catalog").unwrap();+    let r = crate::menus::run_ui_command(&mut h.app, "library.inspectLightroom", &json!({"path": path.to_string_lossy(), "wait": true}));+    assert!(r.is_some(), "the command is the interface's");+    let row = h.app.session.activity.finished().into_iter().next().expect("a failed task always leaves a row");+    assert_eq!((row.kind, row.ending), ("lightroom", Ending::Failed));+    assert!(row.summary.starts_with("Lightroom inspection failed"), "{:?}", row.summary);     let _ = std::fs::remove_dir_all(dir); } @@ -534,3 +558,130 @@ fn immediate_library_commands_and_auto_sync_show_completion() {     assert!(h.app.run("library.compact", json!({})).is_err());     assert!(h.app.ui.toast.is_none()); }++/// The demo app, reading the files as they are on disk (the demo library reads none), keeping every ended task's row.+fn demo_on_disk() -> Headless {+    let mut h = demo();+    h.app.session.media.file_probe = lightcraft_engine::Session::new().with_fs().media.file_probe;+    h.app.session.activity.keep_after(0);+    h+}++/// A folder with a valid PNG and, in a subfolder, a JPEG cut short before its end-of-image marker (the real reason).+fn folder_with_a_truncated_jpeg(tag: &str) -> std::path::PathBuf {+    use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta};+    let dir = std::env::temp_dir().join(format!("lc-activity-unreadable-{tag}-{}", std::process::id()));+    let _ = std::fs::remove_dir_all(&dir);+    std::fs::create_dir_all(dir.join("trip")).unwrap();+    let img = lightcraft_raster::Rgba8 { width: 64, height: 48, data: vec![[90, 120, 160, 255]; 64 * 48] };+    let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();+    std::fs::write(dir.join("good.png"), png).unwrap();+    let mut jpeg = lightcraft_codecs::encode_jpeg(&EncodeImage::rgba8(&img), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();+    jpeg.truncate(jpeg.len() - 2);+    std::fs::write(dir.join("trip").join("bad.jpg"), jpeg).unwrap();+    dir+}++/// What an import's row says it could not read.+fn the_row_names_the_truncated_jpeg(h: &Headless) {+    let rows = h.app.session.activity.finished();+    assert_eq!(rows.len(), 1, "{rows:?}");+    let row = &rows[0];+    assert_eq!((row.kind, row.ending, row.failed), ("import", Ending::Problems, 1), "{row:?}");+    let names: Vec<&str> = row.failures.iter().map(|f| f.name.as_str()).collect();+    assert_eq!(names, vec![std::path::Path::new("trip").join("bad.jpg").to_string_lossy()], "{row:?}");+    assert!(row.failures[0].reason.contains("truncated"), "{}", row.failures[0].reason);+}++#[test]+fn an_import_row_names_each_file_it_could_not_read_and_why() {+    let dir = folder_with_a_truncated_jpeg("paths");+    let mut h = demo_on_disk();+    crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();+    assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none()));+    the_row_names_the_truncated_jpeg(&h);+    let _ = std::fs::remove_dir_all(&dir);+}++/// The review hands the engine the checked files, not the folder: the names of those it cannot read still start at+/// the source folder. (A file the scan could not read is not offered, so this one is made to look readable.)+#[test]+fn a_reviewed_imports_row_names_unreadable_files_below_its_source() {+    let dir = folder_with_a_truncated_jpeg("review");+    let mut h = demo_on_disk();+    crate::import::open(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();+    assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))));+    h.app.session.activity.dismiss_all();+    let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no review") };+    for c in &mut opts.candidates {+        c.error = None;+    }+    opts.checked.iter_mut().for_each(|on| *on = true);+    assert_eq!(opts.selected_paths().len(), 2);+    assert_eq!(h.request("ui.dialog.confirm", json!({}), T)["ok"], true);+    assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none()));+    the_row_names_the_truncated_jpeg(&h);+    let _ = std::fs::remove_dir_all(&dir);+}++/// A scan that ran to its end says so, and says when it found nothing to import.+#[test]+fn a_scan_that_found_nothing_leaves_a_row_saying_so() {+    let mut h = demo();+    h.app.session.activity.keep_after(0);+    let dir = std::env::temp_dir().join(format!("lc-activity-emptyscan-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    crate::import::open(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();+    assert!(h.step_until(T, |h| h.app.scan.is_none()));+    let row = h.app.session.activity.finished().into_iter().next().expect("a row for the scan");+    assert_eq!((row.label.as_str(), row.ending, row.detail.as_str()), ("Scanning folder", Ending::Done, "No photos found"));+    let _ = std::fs::remove_dir_all(dir);+}++/// A scan that met files it could not read says so in its row, by name, whether it was for the import review or the+/// Local view (which leaves those files out, so nothing later would say it). The Local view's row says which folder,+/// and opening that folder again replaces it instead of adding another.+#[test]+fn a_scan_that_met_unreadable_files_leaves_a_row_with_problems() {+    for browse in [false, true] {+        let mut h = demo();+        h.app.session.activity.keep_after(0);+        // the files are read as on disk (the demo library reads none)+        h.app.session.media.file_probe = lightcraft_engine::Session::new().with_fs().media.file_probe;+        let dir = std::env::temp_dir().join(format!("lc-activity-badscan-{browse}-{}", std::process::id()));+        std::fs::create_dir_all(dir.join("sub")).unwrap();+        std::fs::write(dir.join("broken.jpg"), b"not a jpeg at all").unwrap();+        std::fs::write(dir.join("sub").join("also.jpg"), b"not a jpeg either").unwrap();+        let path = dir.to_string_lossy().to_string();+        let scan = |h: &mut Headless| {+            if browse {+                crate::import::browse(&mut h.app, &path, Some(true)).unwrap();+            } else {+                crate::import::open(&mut h.app, vec![path.clone()]).unwrap();+            }+            assert!(h.step_until(T, |h| h.app.scan.is_none()));+        };+        scan(&mut h);+        let names = |h: &Headless| {+            let mut v: Vec<String> = h.app.session.activity.finished()[0].failures.iter().map(|f| f.name.clone()).collect();+            v.sort();+            v+        };+        let row = h.app.session.activity.finished()[0].clone();+        assert_eq!((row.ending, row.failed, row.detail.as_str()), (Ending::Problems, 2, "2 photos not readable"), "browse {browse}: {row:?}");+        // the Local view names a file by where it is in the folder (two subfolders can hold the same name)+        let deeper = if browse { std::path::Path::new("sub").join("also.jpg").to_string_lossy().into_owned() } else { "also.jpg".into() };+        let mut want = vec!["broken.jpg".to_string(), deeper];+        want.sort();+        assert_eq!(names(&h), want, "{row:?}");+        if browse {+            let folder = std::path::absolute(&dir).unwrap().to_string_lossy().trim_end_matches(std::path::MAIN_SEPARATOR).to_string();+            assert_eq!(row.path.as_deref(), Some(folder.as_str()), "{row:?}");+            scan(&mut h);+            let rows = h.app.session.activity.finished();+            assert_eq!(rows.len(), 1, "one row for the folder: {rows:?}");+            assert!(rows[0].id != row.id && rows[0].failed == 2, "the new row is the one kept: {rows:?}");+        }+        let _ = std::fs::remove_dir_all(dir);+    }+}diff --git a/crates/ui-egui/src/tests/print.rs b/crates/ui-egui/src/tests/print.rs--- a/crates/ui-egui/src/tests/print.rs+++ b/crates/ui-egui/src/tests/print.rs@@ -314,6 +314,57 @@ fn printing_shows_a_row_in_the_activity_stack_and_stops() {     assert!(app.session.activity.list().is_empty(), "the row goes with the print"); } +#[test]+fn a_print_that_fails_leaves_a_row_that_still_knows_its_folder() {+    use lightcraft_engine::activity::Ending;+    let mut app = LightcraftApp::new(+        lightcraft_engine::Session::with_demo(),+        Services { write_shared: Some(Arc::new(|_, _| Err("The disk is full".into()))), ..Default::default() },+    );+    app.session.execute("print.set", &json!({"job": {"ppi": 100.0}})).unwrap();+    let ids: Vec<_> = app.session.catalog.photos().take(1).map(|p| p.id.0).collect();+    let dir = std::env::temp_dir().join("lc-print-fails");+    let path = dir.join("Full.pdf").to_string_lossy().into_owned();+    crate::export_task::start_print(&mut app, &json!({"path": path, "ids": ids})).unwrap();+    let ctx = egui::Context::default();+    let until = std::time::Instant::now() + Duration::from_secs(60);+    while app.export.is_some() && std::time::Instant::now() < until {+        crate::export_task::poll(&mut app, &ctx);+        std::thread::sleep(Duration::from_millis(10));+    }+    assert!(app.export.is_none(), "the print did not end");+    let rows = app.session.activity.finished();+    assert_eq!(rows.len(), 1, "{rows:?}");+    assert_eq!((rows[0].ending, rows[0].detail.as_str()), (Ending::Failed, "The disk is full"), "{:?}", rows[0]);+    // the row can still show where the PDF was going+    assert_eq!(rows[0].path.as_deref(), Some(dir.to_string_lossy().as_ref()), "{:?}", rows[0]);+}++/// A print that ended leaves a finished row (`activity.finished`): its headline is in the interface's language, like+/// the rest of the row.+#[test]+fn a_finished_print_row_is_in_the_interface_language() {+    crate::i18n::set_language(crate::i18n::Locale::De);+    let mut app =+        LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() });+    app.session.activity.keep_after(0);+    app.session.execute("print.set", &json!({"job": {"ppi": 100.0}})).unwrap();+    let ids: Vec<_> = app.session.catalog.photos().take(1).map(|p| p.id.0).collect();+    let path = std::env::temp_dir().join("lc-print-de").join("Druck.pdf").to_string_lossy().into_owned();+    crate::export_task::start_print(&mut app, &json!({"path": path, "ids": ids})).unwrap();+    let ctx = egui::Context::default();+    let until = std::time::Instant::now() + Duration::from_secs(60);+    while app.export.is_some() && std::time::Instant::now() < until {+        crate::export_task::poll(&mut app, &ctx);+        std::thread::sleep(Duration::from_millis(10));+    }+    let rows = app.session.activity.finished();+    // the catalog's own words, not ours: the row says what the German catalog says+    let headline = crate::i18n::tr_format!("Printed to PDF: {photos} photos, {pages} pages", photos = 1, pages = 1);+    crate::i18n::set_language(crate::i18n::Locale::En);+    assert_eq!(rows.first().map(|r| r.summary.as_str()), Some(headline.as_str()), "{rows:?}");+}+ #[test] fn the_print_menu_item_needs_a_photo_and_is_listed() {     let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());diff --git a/crates/ui-egui/src/tests/recheck.rs b/crates/ui-egui/src/tests/recheck.rs--- a/crates/ui-egui/src/tests/recheck.rs+++ b/crates/ui-egui/src/tests/recheck.rs@@ -76,6 +76,8 @@ fn recheck_result_is_dropped_after_switching_libraries() {     assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10)));     assert!(app.ui.toast.is_none(), "{:?}", app.ui.toast);     assert!(app.session.catalog.photo(id).is_none(), "the other library is untouched");+    // the user did not stop it: no row asks for attention+    assert!(app.session.activity.finished().iter().all(|r| !r.ending.needs_attention()), "{:?}", app.session.activity.finished()); }  /// The notice's Reload from Disk re-reads the file in the background (a row in the activity stack,@@ -241,6 +243,27 @@ fn reload_with_bad_ids_names_the_command() {     assert!(!background.contains("``") && !background.starts_with("photo.reload:"), "{background}"); } +/// A Reload that could not read a file says so in its row: it did not do everything it was asked to.+#[test]+fn a_reload_that_could_not_read_a_file_leaves_a_row_naming_it() {+    let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-unread-{}", std::process::id())));+    let (mut app, id) = app_with_stale_photo(&dir.0);+    let ctx = egui::Context::default();+    app.session.activity.keep_after(0);+    app.session.media.file_probe = Some(std::sync::Arc::new(|path| Err(format!("{path}: could not be read"))));+    let path = match &app.session.catalog.photo(id).unwrap().source {+        lightcraft_catalog::Source::File { path } => path.clone(),+        other => panic!("not a file: {other:?}"),+    };+    app.run("photo.reload", json!({"ids": [id.0]})).unwrap();+    assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10)));+    let finished = app.session.activity.finished();+    assert_eq!(finished.len(), 1, "{finished:?}");+    let row = &finished[0];+    assert_eq!((row.kind, row.ending, row.failed), ("reload", lightcraft_engine::activity::Ending::Problems, 1), "{row:?}");+    assert_eq!((row.failures[0].name.as_str(), row.failures[0].reason.as_str()), (path.as_str(), format!("{path}: could not be read").as_str()));+}+ /// A Reload stopped from the activity stack before its file was read says nothing (the raw wasn't /// tried again); one stopped after the file was read still says what came of it. #[test]@@ -250,7 +273,9 @@ fn a_cancelled_reload_reports_only_what_it_did() {     let (mut app, id) = app_with_stale_photo(&dir.0);     let ctx = egui::Context::default();     let native = app.session.media.file_probe.clone().unwrap();+    app.session.activity.keep_after(0);     for read in [false, true] {+        app.session.activity.dismiss_all();         // (the probe has started, the probe may return)         let gate = Arc::new((Mutex::new((false, false)), Condvar::new()));         let worker_gate = gate.clone();@@ -273,6 +298,12 @@ fn a_cancelled_reload_reports_only_what_it_did() {         gate.0.lock().unwrap().1 = true;         gate.1.notify_all();         assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10)));+        // however it went, the row says it was stopped: ✕ was pressed+        let finished = app.session.activity.finished();+        assert!(+            finished.len() == 1 && finished[0].kind == "reload" && finished[0].ending == lightcraft_engine::activity::Ending::Stopped,+            "{finished:?}"+        );         if read {             assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None);             assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast);diff --git a/crates/ui-egui/src/tests/sync.rs b/crates/ui-egui/src/tests/sync.rs--- a/crates/ui-egui/src/tests/sync.rs+++ b/crates/ui-egui/src/tests/sync.rs@@ -339,6 +339,39 @@ fn a_synchronize_shows_a_row_and_its_cross_stops_it() {     let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();     assert!(toast.contains("Stopped"), "{toast:?}");     assert!(h.app.session.activity.list().is_empty(), "the row goes with the run");+    let row = h.app.session.activity.finished().into_iter().next().expect("a stopped run leaves a row");+    assert_eq!((row.label.as_str(), row.ending), ("Synchronizing folder", lightcraft_engine::activity::Ending::Stopped));+    assert!(row.summary.contains("trip: Stopped"), "it names the folder and says how it ended: {:?}", row.summary);+}++#[test]+fn a_synchronize_that_ran_to_its_end_leaves_a_row_saying_what_it_did() {+    let dir = Scratch::new("donerow");+    let mut h = changed_folder(&dir);+    h.app.session.activity.keep_after(0);+    open_and_scan(&mut h, &dir.path("trip"));+    synchronize(&mut h);+    let row = h.app.session.activity.finished().into_iter().next().expect("a row for the run");+    assert_eq!((row.label.as_str(), row.ending), ("Synchronizing folder", lightcraft_engine::activity::Ending::Done));+    assert!(row.summary.ends_with("trip: Imported 1"), "it names the folder and says what it did: {:?}", row.summary);+}++#[test]+fn a_scan_that_found_unreadable_files_leaves_a_row_that_needs_looking_at() {+    let dir = Scratch::new("unreadablerow");+    let mut h = changed_folder(&dir);+    h.app.session.activity.keep_after(0);+    // the new file can't be read: the scan still completes, with it listed as unreadable+    h.app.session.media.file_probe = Some(std::sync::Arc::new(|_: &str| Err("cannot read".to_string())));+    open_and_scan(&mut h, &dir.path("trip"));+    assert!(counts(&h).unreadable > 0, "the scan found an unreadable file: {:?}", h.app.ui.dialog);+    let row = h.app.session.activity.finished().into_iter().next().expect("a row for the scan");+    assert_eq!((row.label.as_str(), row.ending), ("Looking for changes", lightcraft_engine::activity::Ending::Problems));+    assert_eq!(row.failed, counts(&h).unreadable, "it counts the files it could not read");+    assert_eq!(row.failures.len(), row.failed, "and names each: {:?}", row.failures);+    assert_eq!(row.failures[0].name, "c.png", "by where it is below the scanned folder: {:?}", row.failures);+    assert_eq!(row.failures[0].reason, "cannot read");+    assert!(row.detail.contains("not readable"), "{:?}", row.detail); }  #[test]@@ -363,6 +396,8 @@ fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() {     h.step();     assert!(h.app.sync.is_none(), "✕ stops the scan");     assert!(h.app.ui.dialog.is_none(), "and closes its dialog: there is nothing to show");+    let row = h.app.session.activity.finished().into_iter().next().expect("a stopped scan leaves a row");+    assert_eq!((row.label.as_str(), row.ending), ("Looking for changes", lightcraft_engine::activity::Ending::Stopped));     gate.store(true, std::sync::atomic::Ordering::Relaxed);     assert!(h.app.session.activity.list().is_empty());     h.settle(SETTLE);diff --git a/docs/background-tasks.md b/docs/background-tasks.md--- a/docs/background-tasks.md+++ b/docs/background-tasks.md@@ -4,7 +4,8 @@ Everything that takes a while runs off the UI thread and shows up in **one** pla top-left corner under the top bar (issue #345). Each task has a row with its name, a progress bar (a sweep while the amount of work isn't known), the count and what it's working on, and ✕ if it can stop. Quitting asks first while a task that can stop is running. Agents see the same list through `activity.list` / `activity.cancel`, and-`ui.inspect` reports it as `activity`.+`ui.inspect` reports it as `activity`; the tasks that ended and left a row are `activity.finished` /+`activity.dismiss` and `activityFinished`.  The registry is `lightcraft_engine::activity` (`crates/engine/src/activity.rs`, `Session::activity`). The stack is `crates/ui-egui/src/panels/activity.rs`. Every job that can last more than a moment uses it; **don't add a progress@@ -48,6 +49,20 @@ guard.detail(&file_name);   - `set_cancellable(false)` for a phase that has to finish once started (a Lightroom import adding its photos). - **Quiet periodic work** shows no row: the auto-import folder listing runs every few seconds through   `tasks::spawn(.., None, ..)`.+- **Say how it ended:** `guard.finish(Outcome { ending, summary, detail, failures, failed, path })` in place of+  letting the guard drop. These jobs do: exports (contact sheet and Print to PDF from the app included), imports,+  folder scans, Synchronize Folder, Build / Smart Previews, Photo Merge, Lightroom catalog inspect / import, model and+  place-name downloads, Fill Location from GPS, moving photos, Find Missing Photos and Reload from Disk. These do not+  yet: slideshow export, the engine's `print.exportPdf`, reading focus points, thumbnail-cache tidying, the face GPU+  check, Apple Photos imports, and the face scan and AI Denoise (queues whose row goes when they empty). `ending` is `Done`, `Problems` (not for every file),+  `Failed`, `Stopped`, or `Died` when the job finds its worker gone; `summary` is the sentence the row shows (UI code+  passes it translated); `failures` are the files with their reasons (`failed` when the job only counts them); `path`+  is one of the files it made, or the folder they are in or were to go to. `Outcome::ended(ending)` and `Outcome::failed(why)` cover the plain cases. The row+  stays in `activity.finished` until `activity.dismiss` lets go of it, for this+  session: always for a task that did not end well, and for one that ended well only if it ran 10 s or more. Keep the+  toast that says how it went: the row is the record, not the announcement. A guard that is only dropped leaves+  nothing, as before, unless a panic dropped it: then the row says the task stopped unexpectedly. A scan or Local folder read+  that another replaced leaves nothing either: only the row's ✕ makes it a stopped task.  ## Examples in the code @@ -68,4 +83,6 @@ guard.detail(&file_name); Assert the row (`session.activity.list()`: kind, label, total, `cancellable`), that `activity.cancel(id)` stops the job, and that the row is gone once the job ends. `crates/ui-egui/src/tests/activity.rs` has the helpers, and the job-specific tests show the pattern (`tests_sync.rs`, `export_task.rs`, `tests_denoise.rs`…). A row only appears-on screen once its task is 0.5 s old, so tests that click its ✕ wait for `activity:cancel:<id>` first.+on screen once its task is 0.5 s old, so tests that click its ✕ wait for `activity:cancel:<id>` first. For a job+that reports how it ended, assert `session.activity.finished()` (ending, summary, failures);+`session.activity.keep_after(0)` keeps a clean end's row however short the test's job.diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -79,7 +79,7 @@ filmstrip is visible. | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |-| `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |+| `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `activityFinished` (the tasks that ended and left a row, same shape as `activity.finished`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks; `count` is capped at 16) / `{id, toX?, toY?, dx?, dy?, steps?}` (steps are capped at 1000) / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews). `fx`/`fy` are fractions of the registered rect and may lie outside `[0,1]`; the resulting point must still be inside egui's clipped interaction rect, and a non-finite fraction or resolved point is rejected before any input is queued. Geometry comes from the latest completed layout; if the widget moves before the first event or before a press, or its latest clipped rect no longer contains the point, the request fails with an error instead of clicking another widget or retargeting. A target below an ordinary modal dialog may succeed as a no-op or a backdrop click, because egui routes that input to the dialog; a target covered by a non-modal overlay fails. The blocking library-problem/launch-list window is explicit: its dim takes the modal layer, but a background target fails instead of reporting a no-op success, and only that window's own controls (`button:library…`) pass. Its creation frame is shielded and redrawn with empty input like a prompt's, so background text/pointer input cannot act while the new modal areas settle. A quit prompt or settings notice is a real egui modal scope with a foreground backdrop. Only its own buttons (`button:quit…`, `button:noticeOk`) accept a `ui.clickWidget`; background targets fail, and background keystrokes, text/clipboard input, wheel/zoom input and AccessKit actions are dropped before panels see them. The prompt itself takes Tab/Shift+Tab, Enter/Space on the focused button, and Escape/Enter for the safe default Cancel; AccessKit Focus/Click works when targeted at a prompt button. Its first creation frame (including a Tasks→Unsaved or notice→next-notice transition) is shielded and redrawn with empty input, so a press/release in that frame, or a held press from before it, cannot act underneath it; a synthetic drag already in progress is canceled. Once settled, raw `ui.click`/`ui.drag`/`ui.pointer` and backdrop clicks are still delivered to the prompt backdrop as no-ops, and the prompt's own button clicks work normally. The area/layer check cannot see a different widget covering the point in the same layer. A `ui.clickWidget` whose widget disappears before its release also fails, after canceling the pointer press without a click or drag (the same rules apply when a click spans several host frames) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points (the same click and step caps apply) |@@ -98,6 +98,8 @@ filmstrip is visible. | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |+| `engine.execute {command: "activity.finished"}` | — | The tasks that ended and left a row, newest first: `{finished: [{id, kind, label, ending, summary, detail, failures: [{name, reason}], failed, path, ranMs, ageMs}]}`. `ending` is `done`, `problems` (not for every file), `failed`, `stopped` (cancelled) or `died` (its worker crashed). A clean end is kept when the task ran 10 s or more, any other always; `failed` counts the files that failed and `failures` holds the first 500 with their reasons; `path` is one of the files the task made, or the folder they are in or were to go to; at most 50 clean endings and 200 rows in all are kept; `summary` is as the app shows it (in its language) and empty for jobs that say nothing of themselves. These jobs report how they ended: exports (the contact sheet and Print to PDF from the app included), imports, folder scans, Synchronize Folder, Build / Smart Previews, Photo Merge, Lightroom catalog inspect / import, model and place-name downloads, Fill Location from GPS, moving photos, Find Missing Photos and Reload from Disk. The others leave no row, or only one for a worker that died: slideshow export, the engine's `print.exportPdf`, reading focus points, thumbnail-cache tidying, the face GPU check, Apple Photos imports, the face scan and AI Denoise |+| `engine.execute {command: "activity.dismiss"}` | `{id}` or `{all: true}` | Lets go of a finished task's row (or of all of them) → `{dismissed: n}`; an unknown id (it may have been dismissed already) is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes | | `engine.execute {command: "slideshow.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` |diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md--- a/docs/parity-checklist.md+++ b/docs/parity-checklist.md@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (LR-BEHAV-BG-TASKS: a background task says how it ended (`activity.finished`); LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6  The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good@@ -461,7 +461,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | |-| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/ui-egui/src/widgets/progress.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) |+| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `cmd:activity.finished`, `cmd:activity.dismiss`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/ui-egui/src/widgets/progress.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`, `activityFinished`) see the same list; exports (and the contact sheet and the app's Print to PDF), imports, folder scans, Synchronize Folder, Build / Smart Previews, Photo Merge, Lightroom catalog inspect / import, model and place-name downloads, Fill Location from GPS, moving photos, Find Missing Photos and Reload from Disk say how they ended and leave a finished row for agents when they ran long or did not go well (`activity.finished`, `activity.dismiss`); slideshow export, `print.exportPdf`, focus points, cache tidying, the face GPU check, Apple Photos imports, the face scan and AI Denoise do not yet. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) |@@ -959,3 +959,5 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |+| 2026-10-26 | minor | LR-BEHAV-BG-TASKS: a task says how it ended (`cmd:activity.finished`, `cmd:activity.dismiss`); exports and imports (each file that failed, with the reason), folder scans, Build / Smart Previews, model and place-name downloads, Fill Location from GPS and moving photos that ran long, were stopped or did not go well leave a finished row for agents; status unchanged (✅) |+| 2026-10-26 | minor | LR-BEHAV-BG-TASKS: Synchronize Folder (its scan and its run), Photo Merge, Lightroom catalogs, Find Missing Photos, the preview-only recheck and Reload from Disk say how they ended too (`cmd:activity.finished`), naming the files they could not do; every other task of the app is finished through `tasks::spawn`'s result; status unchanged (✅) |