Merge pull request #43 from GlemNL/fix/issue-23-local-folder-async

Local: browse a folder in the background with progress
This commit is contained in:
Brandon Thomas 2026-10-04 10:24:03 -04:00 committed by GitHub
commit 0467eb6c58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 218 additions and 28 deletions

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,7 +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),
..Default::default()
local: super::bool_or(p, "local", false),
};
let undo0 = s.undo.len();
let mut report = serde_json::to_value(crate::import::import_with(s, &paths, &opts)?).unwrap_or_default();

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;

View File

@ -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()
@ -767,7 +768,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];
@ -889,6 +895,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]);

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,72 @@ 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());
}
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;
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 +194,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 +221,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,
@ -215,7 +297,8 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String>
/// 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<String> = task.queue.drain(..n).collect();
let mut p = task.params.clone();
p["paths"] = json!(batch);
@ -249,6 +332,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 +356,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| {

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 path = p.get("path").and_then(Value::as_str)?;
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(),

View File

@ -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;

View File

@ -56,10 +56,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
_ => "Remove a filter above, or choose Clear all".to_string(),
};
super::empty_message(ui, ui.max_rect(), "No photos match the active filters", &body);
} else 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");
}

View File

@ -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<AtomicUsize>,
running: std::sync::Arc<AtomicBool>,
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<String> = 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,
@ -182,11 +213,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 +227,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);

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,