From b9b80aee8a499f11f71a28c1f16d27c69a82750d Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Thu, 15 Oct 2026 15:35:11 +0200 Subject: [PATCH 1/3] LR-BEHAV-BG-TASKS: Reload from Disk reads its files in the background photo.reload decoded every targeted file on the UI thread, so a selection of raws froze the window until all were read. The command is now split into plan / probe / apply (engine `cmd::reload`); the app intercepts photo.reload and probes the files on a worker thread with a cancellable activity row (kind `reload`, counted progress), applying the result as one undo step. `wait: true` keeps the old in-place behaviour for agents; CLI/MCP unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/convert.rs | 33 +++------- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/reload.rs | 93 +++++++++++++++++++++++++++++ crates/engine/src/import.rs | 2 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 4 ++ crates/ui-egui/src/panels/edit.rs | 13 ++-- crates/ui-egui/src/panels/grid.rs | 5 +- crates/ui-egui/src/reload.rs | 67 +++++++++++++++++++++ crates/ui-egui/src/tasks.rs | 29 ++++++++- crates/ui-egui/src/tests_recheck.rs | 64 +++++++++++++++++++- docs/background-tasks.md | 1 + docs/parity-checklist.md | 5 +- 13 files changed, 283 insertions(+), 35 deletions(-) create mode 100644 crates/engine/src/cmd/reload.rs create mode 100644 crates/ui-egui/src/reload.rs diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index eae955cce..0871e4352 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -313,30 +313,15 @@ pub(crate) fn reload_ops( } /// Re-read photos whose files changed on disk (an external editor saved them): new size, -/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} +/// dimensions and content hash, cached sources dropped. The app runs the same steps +/// ([`crate::cmd::reload`]) with the files read on a worker thread. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { - let ids = s.targets(p)?; - let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids - .iter() - .filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) { - Some(Source::File { path }) => Some((*id, path)), - _ => None, - }) - .collect(); - let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::>()); - let mut ops = Vec::new(); - let mut reloaded = Vec::new(); - for ((id, _), info) in paths.into_iter().zip(probed) { - let Ok(info) = info else { continue }; - reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded); - } - for id in &reloaded { - s.media.forget(*id); - } - if !ops.is_empty() { - s.commit("Reload Changed Files", Op::Batch { ops })?; - } - Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()})) + use crate::cmd::reload as r; + let items = r::plan(s, p)?; + let probe = r::file_probe(s); + let results = r::probe(probe.as_ref(), &items, &std::sync::atomic::AtomicBool::new(false), &|_| {}); + let reloaded = r::apply(s, items, results)?; + Ok(r::to_json(&reloaded)) } /// Duplicate: copy the file next to itself (`-copy`, never overwriting) and add it with the @@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec { "Reload from Disk", ["Photo"], None, - "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}", + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings); in the app the files are read in the background (activity row `reload`, cancellable) and the reply is {background: true, count} unless `wait: true` → {reloaded}", has_selection, reload ), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4b86d4012..bf6733a7a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -38,6 +38,7 @@ pub mod previews; mod print; mod query; pub mod recheck; +pub mod reload; mod slideshow; mod sync; mod xmp; diff --git a/crates/engine/src/cmd/reload.rs b/crates/engine/src/cmd/reload.rs new file mode 100644 index 000000000..9fc0a590f --- /dev/null +++ b/crates/engine/src/cmd/reload.rs @@ -0,0 +1,93 @@ +//! Reload from Disk (`photo.reload`), split so the app can read the files on a worker thread: a +//! raw re-decode takes a second or more per file, and a selection of them froze the window while +//! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads +//! their files without the session (cancellable, with progress), and [`apply`] commits what came +//! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use lightcraft_catalog::{Op, PhotoId, Source}; +use serde_json::{Value, json}; + +use crate::media::{FileProbe, ProbeInfo}; +use crate::{Result, Session}; + +/// A photo to re-read: its id and the file it had when it was listed (a photo relinked meanwhile +/// is skipped by [`apply`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Item { + pub id: PhotoId, + pub path: String, +} + +/// The photos `p` targets (`ids`, else the selection) that have a file. No file-system calls. +pub fn plan(s: &Session, p: &Value) -> Result> { + Ok(s.targets(p)? + .into_iter() + .filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) { + Some(Source::File { path }) => Some(Item { id, path: path.clone() }), + _ => None, + }) + .collect()) +} + +/// The session's file probe, to [`probe`] with on a worker thread (`None` in a session without file +/// access, where a reload reads nothing but the file name, as the command does). +pub fn file_probe(s: &Session) -> Option { + s.media.file_probe.clone() +} + +/// Read each listed photo's file as Reload does, several at once. Doesn't touch the session. +/// `progress(done)` is called while it runs (from this thread); once `cancel` is set no further +/// file is started, and the files not reached come back as errors (left unchanged by [`apply`]). +pub fn probe( + probe: Option<&FileProbe>, + items: &[Item], + cancel: &AtomicBool, + progress: &dyn Fn(usize), +) -> Vec> { + let paths: Vec = items.iter().map(|i| i.path.clone()).collect(); + let done = AtomicUsize::new(0); + #[cfg(not(target_arch = "wasm32"))] + let r = std::thread::scope(|sc| { + let h = sc.spawn(|| crate::import::probe_all(probe, &paths, &done, cancel)); + while !h.is_finished() { + progress(done.load(Ordering::Relaxed)); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + // a probe that panicked (already logged): nothing is known about any file + h.join().unwrap_or_else(|_| paths.iter().map(|p| Err(format!("{p}: could not be read"))).collect()) + }); + #[cfg(target_arch = "wasm32")] + let r = crate::import::probe_all(probe, &paths, &done, cancel); + progress(done.load(Ordering::Relaxed)); + r +} + +/// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual +/// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed +/// or relinked since it was listed is skipped. Returns the photos that changed. +pub fn apply(s: &mut Session, items: Vec, results: Vec>) -> Result> { + let mut ops = Vec::new(); + let mut reloaded = Vec::new(); + for (item, info) in items.into_iter().zip(results) { + let Ok(info) = info else { continue }; + let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path)); + if !current { + continue; + } + crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded); + } + for id in &reloaded { + s.media.forget(*id); + } + if !ops.is_empty() { + s.commit("Reload Changed Files", Op::Batch { ops })?; + } + Ok(reloaded) +} + +/// `{reloaded: [ids]}`. +pub fn to_json(reloaded: &[PhotoId]) -> Value { + json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()}) +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 9aa847b01..b98dcd591 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -494,7 +494,7 @@ pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> V /// Probe `paths` (several at once), counting each in `done`. Stops before the next file once /// `cancel` is set: the files not reached come back as errors. -fn probe_all( +pub(crate) fn probe_all( probe: Option<&crate::media::FileProbe>, paths: &[String], done: &std::sync::atomic::AtomicUsize, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..080996420 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod pick; pub mod print_view; pub mod recheck; pub mod region; +pub mod reload; pub mod render; pub mod shortcuts; pub mod sidebar; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9fcfc8b9d..7918d4f8a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -417,6 +417,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option app.toast_error(ctx, e), - Ok(_) if decoded => app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")), + Ok(r) if r["background"] == true => {} + // nothing to read in the background (a photo without a file): answered in place + Ok(_) if app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()) => { + app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")) + } Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")), } } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index c6365b784..10d12ccb6 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1162,7 +1162,10 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } if ui.button(crate::i18n::tr("Reload from Disk")).clicked() { - let _ = app.run("photo.reload", json!({})); + // the files are read in the background (activity row `reload`) + if let Err(e) = app.run("photo.reload", json!({})) { + app.toast_error(ui.ctx(), e); + } } if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() { let _ = app.run("photo.virtualCopy", json!({})); diff --git a/crates/ui-egui/src/reload.rs b/crates/ui-egui/src/reload.rs new file mode 100644 index 000000000..32823cf16 --- /dev/null +++ b/crates/ui-egui/src/reload.rs @@ -0,0 +1,67 @@ +//! Reload from Disk in the app: the files are read on a worker thread (a raw's decode takes a +//! second or more, and a selection of them froze the window), with a row in the activity stack +//! that counts the files and can stop. What came of it is applied back here, as one undo step, +//! unless another library was opened meanwhile. Agents get the same with `wait: true`, which runs +//! the engine's command in place. + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use lightcraft_engine::cmd::reload; +use serde_json::{Value, json}; + +use crate::LightcraftApp; + +/// The activity stack's label while the files are read (the menu item's name, already translated). +pub const LABEL: &str = "Reload from Disk"; + +/// `photo.reload` from the app: start reading the targeted files in the background → +/// `{background: true, count}`. `None` hands the command to the engine: with `wait: true`, or when +/// no targeted photo has a file (it then answers at once, refusing an empty selection as usual). +pub fn command(app: &mut LightcraftApp, p: &Value) -> Option> { + if p.get("wait").and_then(Value::as_bool) == Some(true) { + return None; + } + let items = match reload::plan(&app.session, p) { + Ok(items) => items, + Err(e) => return Some(Err(format!("photo.reload: {e}"))), + }; + if items.is_empty() { + return None; + } + let count = items.len(); + // the photos shown from their embedded preview, to say afterwards whether they decode now + let preview_only: Vec<_> = + items.iter().filter(|i| app.session.catalog.photo(i.id).is_some_and(|ph| ph.preview_only.is_some())).map(|i| i.id).collect(); + let library = app.session.library_generation(); + let probe = reload::file_probe(&app.session); + let cancel = Arc::new(AtomicBool::new(false)); + let flag = cancel.clone(); + let work = move |task: lightcraft_engine::activity::TaskHandle| { + 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) + }; + let done = move |app: &mut LightcraftApp, ctx: &egui::Context, (items, results)| { + if app.session.library_generation() != library { + return; + } + if let Err(e) = reload::apply(&mut app.session, items, results) { + return app.toast_error(ctx, 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(); + 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, + (_, _, 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); + }; + if let Err(e) = crate::tasks::spawn_tracked(app, LABEL, "reload", cancel, work, done) { + return Some(Err(e)); + } + Some(Ok(json!({"background": true, "count": count}))) +} diff --git a/crates/ui-egui/src/tasks.rs b/crates/ui-egui/src/tasks.rs index 70abd629a..86dc79381 100644 --- a/crates/ui-egui/src/tasks.rs +++ b/crates/ui-egui/src/tasks.rs @@ -50,6 +50,34 @@ pub fn spawn( kind: Option<&'static str>, work: impl FnOnce() -> T + Send + 'static, done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + 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) +} + +/// [`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. +pub fn spawn_tracked( + app: &mut LightcraftApp, + label: &str, + kind: &'static str, + cancel: std::sync::Arc, + work: impl FnOnce(lightcraft_engine::activity::TaskHandle) -> T + Send + 'static, + done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, +) -> Result<(), String> { + let guard = app.session.activity.start(kind, label, lightcraft_engine::activity::Cancel::Flag(cancel)); + let handle = guard.handle(); + start(app, label, Some(guard), move || work(handle), done) +} + +/// Run `work` on a worker thread and list it in `app.tasks` with its activity row (if any). +fn start( + app: &mut LightcraftApp, + label: &str, + guard: Option, + work: impl FnOnce() -> T + Send + 'static, + done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, ) -> Result<(), String> { let (tx, rx) = channel::(); let repaint = app.tasks.repaint.clone(); @@ -64,7 +92,6 @@ pub fn spawn( 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(); - let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No)); app.tasks.running.push(Task { label: label.to_string(), rx, _guard: guard }); Ok(()) } diff --git a/crates/ui-egui/src/tests_recheck.rs b/crates/ui-egui/src/tests_recheck.rs index 88149e464..c343050fc 100644 --- a/crates/ui-egui/src/tests_recheck.rs +++ b/crates/ui-egui/src/tests_recheck.rs @@ -78,13 +78,75 @@ fn recheck_result_is_dropped_after_switching_libraries() { assert!(app.session.catalog.photo(id).is_none(), "the other library is untouched"); } -/// The notice's Reload from Disk re-reads the file and says what came of it. +/// The notice's Reload from Disk re-reads the file in the background (a row in the activity stack, +/// the window not waiting for the decode) and says what came of it. #[test] fn notice_reload_button_rereads_the_file() { let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-reload-{}", std::process::id()))); let (mut app, id) = app_with_stale_photo(&dir.0); let ctx = egui::Context::default(); crate::panels::edit::reload_preview_only(&mut app, &ctx, id); + let rows = app.session.activity.list(); + let row = rows.iter().find(|r| r.kind == "reload").unwrap_or_else(|| panic!("no reload row: {rows:?}")); + assert_eq!(row.label, crate::reload::LABEL); + assert!(row.cancellable); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert!(app.session.activity.list().iter().all(|r| r.kind != "reload"), "the row goes with the task"); 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); + // one undo step + assert_eq!(app.session.undo.last().map(|u| u.label.as_str()), Some("Reload Changed Files")); +} + +/// Reload from Disk on a selection returns at once and reads the files on a worker thread; `wait` +/// runs it in place; a cancelled reload changes nothing it didn't read. +#[test] +fn reload_of_a_selection_runs_in_the_background() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-sel-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + app.session.selection = lightcraft_engine::Selection { ids: vec![id], active: Some(id) }; + let r = app.run("photo.reload", json!({})).unwrap(); + assert_eq!(r, json!({"background": true, "count": 1})); + assert!(app.tasks.is_running(crate::reload::LABEL)); + assert!(app.session.activity.list().iter().any(|r| r.kind == "reload")); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); + + // agents can wait for the result: the engine's reply, nothing left running + let r = app.run("photo.reload", json!({"ids": [id.0], "wait": true})).unwrap(); + assert!(r.get("reloaded").is_some(), "{r}"); + assert!(app.tasks.is_empty()); + + // cancelled before any file is read: nothing changes + let p = app.session.catalog.photo(id).unwrap().clone(); + app.session + .catalog + .apply(lightcraft_catalog::Op::SetContent { + id, + width: p.width, + height: p.height, + file_size: p.file_size, + content_hash: p.content_hash.clone(), + preview_only: Some("Cr3 files are not decoded yet".into()), + preview_checked: "0.5.0".into(), + }) + .unwrap(); + let items = lightcraft_engine::cmd::reload::plan(&app.session, &json!({"ids": [id.0]})).unwrap(); + let probe = lightcraft_engine::cmd::reload::file_probe(&app.session); + let cancelled = std::sync::atomic::AtomicBool::new(true); + let results = lightcraft_engine::cmd::reload::probe(probe.as_ref(), &items, &cancelled, &|_| {}); + assert!(lightcraft_engine::cmd::reload::apply(&mut app.session, items, results).unwrap().is_empty()); + assert!(app.session.catalog.photo(id).unwrap().preview_only.is_some()); +} + +/// With no photo selected the command is refused at once, as before (no task, no row). +#[test] +fn reload_with_nothing_selected_starts_nothing() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-none-{}", std::process::id()))); + let (mut app, _) = app_with_stale_photo(&dir.0); + app.session.selection = lightcraft_engine::Selection::default(); + let _ = app.run("photo.reload", json!({})); + assert!(app.tasks.is_empty()); + assert!(app.session.activity.list().iter().all(|r| r.kind != "reload")); } diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 8ef8789d8..58fd3d6c5 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -60,6 +60,7 @@ guard.detail(&file_name); | Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | | Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | | Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) | +| Reload from Disk | `crates/ui-egui/src/reload.rs`, `crates/engine/src/cmd/reload.rs` | a command split into plan / probe / apply so the app reads the files on a worker thread (`tasks::spawn_tracked`: progress and `Cancel::Flag`); `wait: true` runs it in place | | Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps | ## Testing diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..4473ed8e8 100644 --- 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-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **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`, `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 and Find Missing Photos 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`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.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-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) | @@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; status unchanged (✅) | | 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | | 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | From 7b2d7b25ea569ab477e0dec9a6e1e9aa013286f8 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 18 Oct 2026 18:16:20 +0200 Subject: [PATCH 2/3] LR-BEHAV-BG-TASKS: an older Reload can't overwrite a newer one; background Reload auto-writes sidecars Review of #1998. Each reload plan takes a ticket; apply skips a photo a later-started reload has already been applied to, so an older read that finishes last no longer restores the previous dimensions and content hash. The app's background completion now commits through Session::execute_fn, so the XMP sidecar auto-write (and the save) happen as for the command. Regression tests: an_older_reload_does_not_replace_a_newer_one, a_background_reload_writes_the_sidecar_with_auto_write_on. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/reload.rs | 33 +++++++++--- crates/engine/src/lib.rs | 3 ++ crates/ui-egui/src/reload.rs | 10 ++-- crates/ui-egui/src/tests_recheck.rs | 80 ++++++++++++++++++++++++++++- docs/background-tasks.md | 2 +- docs/parity-checklist.md | 4 +- 6 files changed, 118 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/reload.rs b/crates/engine/src/cmd/reload.rs index 9fc0a590f..0c1cb964c 100644 --- a/crates/engine/src/cmd/reload.rs +++ b/crates/engine/src/cmd/reload.rs @@ -3,6 +3,10 @@ //! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads //! their files without the session (cancellable, with progress), and [`apply`] commits what came //! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once. +//! +//! Reloads can overlap (the user picks Reload from Disk again while a first one still reads), so +//! each [`plan`] takes a ticket and [`apply`] drops a photo's result when a reload planned later +//! has already been applied to it: an older read never replaces a newer one. use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -18,14 +22,28 @@ use crate::{Result, Session}; pub struct Item { pub id: PhotoId, pub path: String, + /// The [`plan`] it came from, in the order reloads were started (see [`Tickets`]). + pub ticket: u64, } -/// The photos `p` targets (`ids`, else the selection) that have a file. No file-system calls. -pub fn plan(s: &Session, p: &Value) -> Result> { - Ok(s.targets(p)? +/// Which reload each photo last took its file information from, so a reload started earlier but +/// finishing later doesn't put back what its file was before (session memory, not saved). +#[derive(Debug, Default)] +pub struct Tickets { + last: u64, + applied: std::collections::BTreeMap, +} + +/// The photos `p` targets (`ids`, else the selection) that have a file, under a new ticket. No +/// file-system calls. +pub fn plan(s: &mut Session, p: &Value) -> Result> { + let ids = s.targets(p)?; + s.reload_tickets.last = s.reload_tickets.last.saturating_add(1); + let ticket = s.reload_tickets.last; + Ok(ids .into_iter() .filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) { - Some(Source::File { path }) => Some(Item { id, path: path.clone() }), + Some(Source::File { path }) => Some(Item { id, path: path.clone(), ticket }), _ => None, }) .collect()) @@ -66,16 +84,19 @@ pub fn probe( /// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual /// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed -/// or relinked since it was listed is skipped. Returns the photos that changed. +/// or relinked since it was listed, or already given a newer reload's result, is skipped. Returns +/// the photos that changed. pub fn apply(s: &mut Session, items: Vec, results: Vec>) -> Result> { let mut ops = Vec::new(); let mut reloaded = Vec::new(); for (item, info) in items.into_iter().zip(results) { let Ok(info) = info else { continue }; let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path)); - if !current { + let superseded = s.reload_tickets.applied.get(&item.id).is_some_and(|t| *t > item.ticket); + if !current || superseded { continue; } + s.reload_tickets.applied.insert(item.id, item.ticket); crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded); } for id in &reloaded { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25e6ef52f..ab6c048f9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -240,6 +240,8 @@ pub struct Session { /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. pub(crate) skip_auto_write: bool, + /// The order of Reload from Disk runs, so an older one finishing late is dropped ([`cmd::reload`]). + pub(crate) reload_tickets: cmd::reload::Tickets, /// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by /// [`Session::retry_unsaved_sidecars`] once their file is reachable. pub(crate) unsaved_sidecars: std::collections::BTreeSet, @@ -434,6 +436,7 @@ impl Session { pending_sidecar_repairs: Vec::new(), interaction: None, skip_auto_write: false, + reload_tickets: Default::default(), unsaved_sidecars: Default::default(), denoise: Default::default(), face_models_dir: None, diff --git a/crates/ui-egui/src/reload.rs b/crates/ui-egui/src/reload.rs index 32823cf16..94c9510ab 100644 --- a/crates/ui-egui/src/reload.rs +++ b/crates/ui-egui/src/reload.rs @@ -1,7 +1,8 @@ //! Reload from Disk in the app: the files are read on a worker thread (a raw's decode takes a //! second or more, and a selection of them froze the window), with a row in the activity stack -//! that counts the files and can stop. What came of it is applied back here, as one undo step, -//! unless another library was opened meanwhile. Agents get the same with `wait: true`, which runs +//! that counts the files and can stop. What came of it is applied back here through the session's +//! command wrapper (one undo step, saved, sidecars auto-written), unless another library was opened +//! meanwhile or a reload started later has already been applied to the photo. Agents get the same with `wait: true`, which runs //! the engine's command in place. use std::sync::Arc; @@ -22,7 +23,7 @@ pub fn command(app: &mut LightcraftApp, p: &Value) -> Option items, Err(e) => return Some(Err(format!("photo.reload: {e}"))), }; @@ -47,7 +48,8 @@ pub fn command(app: &mut LightcraftApp, p: &Value) -> Option **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (LR-BEHAV-BG-TASKS: Reload from Disk in the background) · **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 @@ -797,7 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| -| 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; status unchanged (✅) | +| 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; its result is committed through the command wrapper (XMP sidecars auto-written as before) and an older reload finishing late no longer overwrites a newer one; status unchanged (✅) | | 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | | 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | | 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) | From 5b5cdb646e48da7b407d89ddd8c08694c7d5d4ff Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 18 Oct 2026 19:57:32 +0200 Subject: [PATCH 3/3] LR-BEHAV-BG-TASKS: background Reload names itself in bad-ids errors; a stopped Reload doesn't say the raw still can't be decoded Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/reload.rs | 13 ++++++- crates/ui-egui/src/tests_recheck.rs | 54 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/reload.rs b/crates/ui-egui/src/reload.rs index 94c9510ab..8748bfd65 100644 --- a/crates/ui-egui/src/reload.rs +++ b/crates/ui-egui/src/reload.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; +use lightcraft_engine::EngineError; use lightcraft_engine::cmd::reload; use serde_json::{Value, json}; @@ -25,7 +26,11 @@ pub fn command(app: &mut LightcraftApp, p: &Value) -> Option items, - Err(e) => return Some(Err(format!("photo.reload: {e}"))), + // named as `Session::execute` names a shared parameter check's refusal + Err(EngineError::BadParams { cmd, msg }) if cmd.is_empty() => { + return Some(Err(EngineError::BadParams { cmd: "photo.reload".into(), msg }.to_string())); + } + Err(e) => return Some(Err(e.to_string())), }; if items.is_empty() { return None; @@ -38,6 +43,7 @@ pub fn command(app: &mut LightcraftApp, p: &Value) -> Option Option 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(), diff --git a/crates/ui-egui/src/tests_recheck.rs b/crates/ui-egui/src/tests_recheck.rs index 88286027b..4d808f736 100644 --- a/crates/ui-egui/src/tests_recheck.rs +++ b/crates/ui-egui/src/tests_recheck.rs @@ -228,3 +228,57 @@ fn a_background_reload_writes_the_sidecar_with_auto_write_on() { assert_eq!(app.session.catalog.photo(id).unwrap().meta.gps, Some((41.0, 2.0))); assert!(std::fs::read_to_string(&sidecar).unwrap().contains("GPSLatitude"), "the sidecar was not written"); } + +/// Bad `ids` are refused naming the command once, as the engine's own reply does (`wait: true`). +#[test] +fn reload_with_bad_ids_names_the_command() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-bad-{}", std::process::id()))); + let (mut app, _) = app_with_stale_photo(&dir.0); + let background = app.run("photo.reload", json!({"ids": "x"})).unwrap_err(); + let waited = app.run("photo.reload", json!({"ids": "x", "wait": true})).unwrap_err(); + assert_eq!(background, waited); + assert!(background.contains("`photo.reload`"), "{background}"); + assert!(!background.contains("``") && !background.starts_with("photo.reload:"), "{background}"); +} + +/// 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] +fn a_cancelled_reload_reports_only_what_it_did() { + use std::sync::{Arc, Condvar, Mutex}; + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-cancel-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + let native = app.session.media.file_probe.clone().unwrap(); + for read in [false, true] { + // (the probe has started, the probe may return) + let gate = Arc::new((Mutex::new((false, false)), Condvar::new())); + let worker_gate = gate.clone(); + let native = native.clone(); + app.session.media.file_probe = Some(Arc::new(move |path| { + let mut state = worker_gate.0.lock().unwrap(); + state.0 = true; + worker_gate.1.notify_all(); + let _ = worker_gate.1.wait_timeout_while(state, Duration::from_secs(10), |s| !s.1).unwrap(); + // a read the cancel stopped comes back as an error, as a file not reached does + if read { native(path) } else { Err(format!("{path}: cancelled")) } + })); + app.ui.toast = None; + app.run("photo.reload", json!({"ids": [id.0]})).unwrap(); + let (state, _) = gate.1.wait_timeout_while(gate.0.lock().unwrap(), Duration::from_secs(5), |s| !s.0).unwrap(); + assert!(state.0, "the reload started reading"); + drop(state); + let row = app.session.activity.list().into_iter().find(|r| r.kind == "reload").unwrap(); + app.session.activity.cancel(row.id).unwrap(); + gate.0.lock().unwrap().1 = true; + gate.1.notify_all(); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + 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); + } else { + assert!(app.session.catalog.photo(id).unwrap().preview_only.is_some()); + assert!(app.ui.toast.is_none(), "a cancelled reload reported {:?}", app.ui.toast); + } + } +}