Local: browse a folder in the background with progress; keep picked folders in the sidebar

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 <noreply@anthropic.com>
This commit is contained in:
Clément L
2026-10-04 10:22:47 +02:00
co-authored by Claude Sonnet 5.5
parent e3b61fc883
commit 919e65efaf
7 changed files with 124 additions and 15 deletions
+2 -1
View File
@@ -735,7 +735,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
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();
+6 -1
View File
@@ -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<Option<ProbeInfo>> = todo.iter().map(|f| s.import_probes.remove(f)).collect();
let missing: Vec<String> = 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<Result<ProbeInfo, String>> =
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;
+6 -1
View File
@@ -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];
+85 -3
View File
@@ -78,6 +78,9 @@ pub struct ImportTask {
pub failed: usize,
undo0: usize,
first: Option<u64>,
/// 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<ScanOutput>,
/// 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<String>) -> Result<Value, String
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
app.scan = Some(ScanTask { progress, rx, copy: false });
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false });
Ok(json!({"scanning": true}))
}
/// Show a folder's photos in the Local view (`library.browse` from the UI): the view switches at
/// once, the folder is listed and read on a worker thread (a network share can take minutes),
/// and the photos then join the view in small batches under a progress window. A browse already
/// running is replaced; the import review's scan is not.
pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) -> Result<Value, String> {
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<String> = if subfolders {
lightcraft_engine::import::expand(&[root], None)
} else {
let mut v: Vec<String> = 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<String> =
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| {
+5
View File
@@ -842,6 +842,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.dialog = Some(Dialog::Shortcuts);
Ok(Value::Null)
}
"library.browse" if !cfg!(target_arch = "wasm32") => {
// 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(),
+17 -9
View File
@@ -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);
+3
View File
@@ -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<String>,
/// 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,