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] 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 f1aad8d4f..781391844 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 155291dec..5b9211d39 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 24c6146a7..96d22e326 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 f6ce64504..a78dbcff5 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 2958889e9..8bf3778eb 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 df8e620b1..219aaacae 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 85d08db11..d618bafae 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,