From 919e65efafe2163cff3ec093d891da7cf8fddf8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20L?= Date: Sun, 4 Oct 2026 10:22:47 +0200 Subject: [PATCH 1/7] Local: browse a folder in the background with progress; keep picked folders in the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browse Folder listed, probed and imported the whole folder on the UI thread, so a network share froze the window with no feedback. It now lists and reads on a worker (progress window with Cancel), then adds the photos in small batches reusing the scan's probes. Folders picked with Browse Folder… stay in the Local list after browsing elsewhere. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/library.rs | 3 +- crates/engine/src/import.rs | 7 ++- crates/ui-egui/src/headless.rs | 7 ++- crates/ui-egui/src/import.rs | 88 +++++++++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 5 ++ crates/ui-egui/src/panels/left.rs | 26 +++++---- crates/ui-egui/src/state.rs | 3 ++ 7 files changed, 124 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index f1aad8d4..78139184 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -735,7 +735,7 @@ pub fn specs() -> Vec { "Add Photos…", ["File"], Some("Cmd+Shift+I"), - "{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}", + "{paths: [file or folder (recursive)], mode?: add|copy (add = reference the files in place; copy = into the library's Originals/YYYY/YYYY-MM-DD/), destination?: folder for copies, organize?: date|month|flat, rename?: file-name template for copies ({name} {seq:N} {date:%Y%m%d} {camera} {title}), renameStart?: 1, metadataPreset?: name, dng?: bool (copy raws as DNG), local?: bool (browsing: the photos stay out of the library, like library.browse), album?: albumId, albumName?: new album, preset?: presetId, keywords?: [..]} → {imported, duplicates, failed, album?}", always, |s, p| { let paths = strs(p, "paths"); @@ -781,6 +781,7 @@ pub fn specs() -> Vec { rename_start: p.get("renameStart").and_then(Value::as_u64).unwrap_or(1) as usize, metadata_preset, convert_dng: super::bool_or(p, "dng", false), + local: super::bool_or(p, "local", false), ..Default::default() }; let undo0 = s.undo.len(); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 155291de..5b9211d3 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -528,7 +528,12 @@ pub fn import_with(s: &mut Session, paths: &[String], opts: &ImportOptions) -> c } } - let probed = probe_all(s.media.file_probe.as_ref(), &todo, &ScanProgress::default()); + // files a preceding scan already probed are not read again (a network share is slow to read) + let cached: Vec> = todo.iter().map(|f| s.import_probes.remove(f)).collect(); + let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); + let mut fresh = probe_all(s.media.file_probe.as_ref(), &missing, &ScanProgress::default()).into_iter(); + let probed: Vec> = + cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); let now = (s.clock)(); let mut ops = promote; diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 24c6146a..96d22e32 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -767,7 +767,12 @@ mod tests { } let library_before = h.app.session.catalog.photos().filter(|p| !p.local).count(); let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); - assert_eq!(r["result"]["photos"], 1, "{r}"); + // the folder is read in the background: the view switches at once, the photos follow + assert_eq!(r["result"]["scanning"], true, "{r}"); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::Folder); + h.settle(SETTLE); + assert!(h.app.scan.is_none() && h.app.import.is_none()); + assert_eq!(h.app.session.visible_cloned().len(), 1); assert!(h.settle(SETTLE), "a thumbnail that can't load must not keep the renderer busy"); // this test session has no file hooks: the thumbnail fails once, is remembered, and isn't retried let id = h.app.session.visible_cloned()[0]; diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index f6ce6450..a78dbcff 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -78,6 +78,9 @@ pub struct ImportTask { pub failed: usize, undo0: usize, first: Option, + /// Reading a folder for the Local view: the photos stay out of the library, and nothing is + /// selected or announced as added. + browse: bool, } /// A folder scan running on a worker thread (a network share can take minutes to read; the @@ -87,6 +90,8 @@ pub struct ScanTask { rx: std::sync::mpsc::Receiver, /// Open the review with "copy into the library" checked (a camera / card). pub copy: bool, + /// Browsing a folder (Local): the photos are read in place instead of opening the review. + browse: bool, } /// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). @@ -105,10 +110,66 @@ pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result) -> Result { + let dir = std::path::absolute(std::path::Path::new(path)).map_err(|e| e.to_string())?; + if !dir.is_dir() { + return Err(format!("{path}: not a folder")); + } + if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) { + return Err("an import is running".into()); + } + if let Some(t) = app.scan.take() { + t.progress.cancel.store(true, Ordering::Relaxed); + } + app.import = None; + let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders)); + app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders }); + app.session.source = lightcraft_engine::LibrarySource::Folder; + let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s)); + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel(); + let p = progress.clone(); + let root = dir_s.clone(); + let job = move || { + let files: Vec = if subfolders { + lightcraft_engine::import::expand(&[root], None) + } else { + let mut v: Vec = std::fs::read_dir(&root) + .map(|rd| { + rd.flatten() + .map(|e| e.path()) + .filter(|f| { + f.is_file() + && lightcraft_engine::import::is_supported(f) + && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) + }) + .map(|f| f.to_string_lossy().to_string()) + .collect() + }) + .unwrap_or_default(); + v.sort(); + v + }; + let _ = tx.send(scan_with(input, &files, &p)); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(job); + #[cfg(target_arch = "wasm32")] + job(); + app.scan = Some(ScanTask { progress, rx, copy: false, browse: true }); + app.renderer.forget_imports(); + Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) +} + /// Collect a finished scan and open the review (called every frame). pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(task) = app.scan.as_ref() else { return }; @@ -127,6 +188,17 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { return; } app.session.import_probes = out.probes; + if task.browse { + // what the library doesn't know yet joins the Local view + let queue: Vec = + out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect(); + if !queue.is_empty() { + let undo0 = app.session.undo.len(); + let total = queue.len(); + app.import = Some(ImportTask { queue, total, params: json!({"mode": "add", "local": true}), undo0, browse: true, ..Default::default() }); + } + return; + } if out.candidates.is_empty() { app.toast(ctx, "No photos found"); return; @@ -143,7 +215,11 @@ pub fn scan_progress(app: &mut LightcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let total = task.progress.total.load(Ordering::Relaxed); let done = task.progress.done.load(Ordering::Relaxed); - let text = if total == 0 { "Looking for photos…".to_string() } else { format!("Reading photos… {done} of {total}") }; + let text = if total == 0 { + if task.browse { "Reading folder…" } else { "Looking for photos…" }.to_string() + } else { + format!("Reading photos… {done} of {total}") + }; let mut cancel = false; egui::Window::new("Scanning").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 80.0]).show( ctx, @@ -249,6 +325,12 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let steps = app.session.undo.len().saturating_sub(task.undo0); let label = format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }); app.session.merge_undo(steps, &label); + if task.browse { + if task.failed > 0 { + app.toast(ctx, format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" })); + } + return; + } if let Some(f) = task.first { let _ = app.run("library.select", json!({"ids": [f]})); } @@ -267,7 +349,7 @@ pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(task) = &app.import else { return }; let t = Tokens::get(ctx); let frac = task.done as f32 / task.total.max(1) as f32; - let text = format!("Adding photos… {} of {}", task.done, task.total); + let text = format!("{} photos… {} of {}", if task.browse { "Reading" } else { "Adding" }, task.done, task.total); egui::Window::new("Importing").title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([340.0, 60.0]).show( ctx, |ui| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2958889e..8bf3778e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -842,6 +842,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // listed and read in the background (see `import::browse`) + let Some(path) = p.get("path").and_then(Value::as_str) else { return None }; + crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool)) + } "file.addPhotos" => { let paths = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index df8e620b..219aaaca 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -182,11 +182,13 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); - if let Some(b) = &browsing - && !places.iter().any(|(_, p)| *p == b.path) - { - let name = std::path::Path::new(&b.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| b.path.clone()); - places.push((name, b.path.clone())); + // picked folders stay listed; the folder being browsed is listed even when it wasn't picked + let roots = app.ui.local_roots.iter().cloned().chain(browsing.iter().map(|b| b.path.clone())); + for path in roots { + if !places.iter().any(|(_, p)| *p == path) { + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone()); + places.push((name, path)); + } } let current = browsing.as_ref().map(|b| b.path.clone()); for (name, path) in places { @@ -194,10 +196,16 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, "Browse Folder…", None, false, 0.0).clicked() { let picked = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(path) = picked - && let Err(e) = app.run("library.browse", json!({"path": path})) - { - app.toast(ui.ctx(), e); + if let Some(path) = picked { + match app.run("library.browse", json!({"path": path})) { + Ok(r) => { + let dir = r["path"].as_str().unwrap_or(&path).to_string(); + if !app.ui.local_roots.contains(&dir) { + app.ui.local_roots.push(dir); + } + } + Err(e) => app.toast(ui.ctx(), e), + } } } ui.add_space(10.0); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 85d08db1..d618bafa 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -287,6 +287,8 @@ pub struct UiState { pub spots_threshold: f32, /// The library filter bar above the grid. pub filter_bar: bool, + /// Folders picked with Local → Browse Folder…, kept in the sidebar after browsing elsewhere. + pub local_roots: Vec, /// Culling: after a rating, flag or colour-label key, move to the next photo. pub auto_advance: bool, /// Full-screen preview (F): the photo alone on black, no chrome. @@ -538,6 +540,7 @@ impl Default for UiState { settings: AppSettings::default(), window_fullscreen: None, filter_bar: false, + local_roots: Vec::new(), compare: None, reference: None, toast: None, From e076f65df8bf8ce9d9e7f9dd4dac216b729e35c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20L?= Date: Sun, 4 Oct 2026 10:32:18 +0200 Subject: [PATCH 2/7] Sidebar: count missing files on a worker thread; read browsed folders in smaller batches The Missing count stat()ed every catalog photo on the UI thread, which on a network share (and with browsed NAS photos in the catalog) froze the window for seconds during a folder browse. It now runs in the background and shows the last finished count. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/import.rs | 3 +- crates/ui-egui/src/panels/left.rs | 49 +++++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a78dbcff..8bf00e3e 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -291,7 +291,8 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result /// Run one batch of the import in progress (called every frame). pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(task) = app.import.as_mut() else { return }; - let n = task.queue.len().min(BATCH); + // (browsing reads a sidecar per file, slow on a network share: keep each frame short) + let n = task.queue.len().min(if task.browse { 2 } else { BATCH }); let batch: Vec = task.queue.drain(..n).collect(); let mut p = task.params.clone(); p["paths"] = json!(batch); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 219aaaca..cf89b9da 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -146,20 +146,51 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } -/// How many library photos have no file (cached in egui memory, refreshed every 5 s). +/// How many library photos have no file. Checking stats every file, which on a network share +/// takes seconds, so it runs on a worker thread: the count shown is the last finished one, +/// refreshed at most every 5 s (or when the catalog changed). fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; + #[derive(Clone, Default)] + struct Job { + n: std::sync::Arc, + running: std::sync::Arc, + at: f64, + rev: u64, + } let id = egui::Id::new("missing-count"); let now = ui.input(|i| i.time); let rev = app.session.catalog.revision; - if let Some((n, at, r)) = ui.data(|d| d.get_temp::<(usize, f64, u64)>(id)) - && now - at < 5.0 - && r == rev - { - return n; + let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() }); + let fresh = now - job.at < 5.0 && job.rev == rev; + if !fresh && !job.running.load(Relaxed) { + let paths: Vec = app + .session + .catalog + .photos() + .filter(|p| !p.deleted) + .filter_map(|p| match &p.source { + lightcraft_catalog::Source::File { path } => Some(path.clone()), + _ => None, + }) + .collect(); + job.running.store(true, Relaxed); + job.at = now; + job.rev = rev; + let (n, running, ctx) = (job.n.clone(), job.running.clone(), ui.ctx().clone()); + let work = move || { + let missing = if cfg!(target_arch = "wasm32") { 0 } else { paths.iter().filter(|p| !std::path::Path::new(p).exists()).count() }; + n.store(missing, Relaxed); + running.store(false, Relaxed); + ctx.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(work); + #[cfg(target_arch = "wasm32")] + work(); + ui.data_mut(|d| d.insert_temp(id, job.clone())); } - let n = lightcraft_engine::cmd::missing::missing(&app.session).len(); - ui.data_mut(|d| d.insert_temp(id, (n, now, rev))); - n + job.n.load(Relaxed) } /// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, From d6be9f63424ac74f7831c65c8368b963d6209958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20L?= Date: Sun, 4 Oct 2026 10:35:48 +0200 Subject: [PATCH 3/7] Local: clicking the folder being read again keeps the running read instead of restarting it Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/headless.rs | 33 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/import.rs | 10 ++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 96d22e32..7225e070 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -894,6 +894,39 @@ mod tests { /// The folder scan runs in the background: the request returns at once and the review opens /// when the scan finishes (a folder on a network share must not freeze the window). + /// Clicking the folder being read again keeps the running read (and its progress). + #[test] + fn browsing_the_same_folder_again_does_not_restart() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-again-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (g, n) = (gate.clone(), started.clone()); + h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { + n.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + while !g.load(std::sync::atomic::Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) + })); + let browse = + |h: &mut Headless| h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), t); + assert_eq!(browse(&mut h)["result"]["scanning"], true); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(browse(&mut h)["result"]["scanning"], true); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(started.load(std::sync::atomic::Ordering::Relaxed), 1, "the second click must not start another read"); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + h.settle(SETTLE); + assert_eq!(h.app.session.visible_cloned().len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn add_folder_scans_in_the_background() { let mut h = demo([1200.0, 900.0]); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 8bf00e3e..719b235a 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -126,12 +126,18 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option) -> if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) { return Err("an import is running".into()); } + let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders)); + let running = app.scan.as_ref().is_some_and(|t| t.browse) || app.import.as_ref().is_some_and(|t| t.browse); + if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) { + // already reading this folder: clicking it again must not restart the progress + app.session.source = lightcraft_engine::LibrarySource::Folder; + return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})); + } if let Some(t) = app.scan.take() { t.progress.cancel.store(true, Ordering::Relaxed); } app.import = None; - let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); - let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders)); app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders }); app.session.source = lightcraft_engine::LibrarySource::Folder; let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s)); From 257a98267828e40dce0ee7712c9c013248c2c2fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20L?= Date: Sun, 4 Oct 2026 10:42:34 +0200 Subject: [PATCH 4/7] Say when a filter hides the photos of an empty grid or filmstrip Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/detail.rs | 5 +++++ crates/ui-egui/src/panels/grid.rs | 11 ++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index e0f00f37..df5ca4f2 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1219,6 +1219,11 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let active = app.session.selection.active; let cell_w = 120.0; let ppp = ui.ctx().pixels_per_point(); + if ids.is_empty() { + let why = if app.session.filter != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" }; + ui.painter().text(r.center(), Align2::CENTER_CENTER, why, t.font(12.5), t.text_dim); + return; + } let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0)))); // a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work) child.style_mut().always_scroll_the_only_direction = true; diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index c6d4f98f..060bb620 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -46,10 +46,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } app.canvas_rect = Some(ui.max_rect()); if ids.is_empty() { - if app.session.source == lightcraft_engine::LibrarySource::Folder { + if app.session.filter != Default::default() { + super::empty_message( + ui, + ui.max_rect(), + "No matching photos", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)", + ); + } else if app.session.source == lightcraft_engine::LibrarySource::Folder { super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local"); - } else if app.session.filter != Default::default() { - super::empty_message(ui, ui.max_rect(), "No matching photos", "Change the filter, or clear it (View → Clear Filters)"); } else { super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here"); } From 3ac1e55a6cbf7d8c9b2f97d53d5c09dd498842d3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 09:50:57 -0400 Subject: [PATCH 5/7] Local: drop the needless struct update in library.import (clippy) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 78139184..b4c3b292 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -782,7 +782,6 @@ pub fn specs() -> Vec { metadata_preset, convert_dng: super::bool_or(p, "dng", false), local: super::bool_or(p, "local", false), - ..Default::default() }; let undo0 = s.undo.len(); let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default(); From 4267a68b30948c904b49c0c85725131c2f715996 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 09:51:48 -0400 Subject: [PATCH 6/7] Local: use ? for the browse path (clippy) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8bf3778e..df3acda8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -844,7 +844,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { // listed and read in the background (see `import::browse`) - let Some(path) = p.get("path").and_then(Value::as_str) else { return None }; + let path = p.get("path").and_then(Value::as_str)?; crate::import::browse(app, path, p.get("subfolders").and_then(Value::as_bool)) } "file.addPhotos" => { From 65bc8bcb4247bbcc7f65068f321faa6d4dc572d0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 10:02:32 -0400 Subject: [PATCH 7/7] Headless: a running folder scan counts as busy, so settle waits for a background browse Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 7225e070..01719115 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -173,6 +173,7 @@ impl Headless { pub fn busy(&self) -> bool { self.app.renderer.in_flight() > 0 || self.app.merge.busy() + || self.app.scan.is_some() || self.app.import.is_some() || self.app.export.is_some() || !self.app.synthetic.is_empty()