From 6c19862876ec0ce35211260509ee5497ba6e002f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 25 Oct 2026 12:13:39 -0400 Subject: [PATCH 1/4] LR-IMP-ADD-DIALOG: an import checks the probes a scan left several files at a time Reusing a scan's probes costs one stat per file to see that it is as it was. They ran one after another, so on a network share every file waited for a round trip. They now run 16 at a time, in the import's batches and in the scan's check of an earlier scan's probes. Measured on this version with each stat made to take 2 ms (a stand-in for a share's round trip): 5,990 stats took 15.1 s one at a time and 0.95 s through `stat_all`, with the answers in the same order. An earlier version of this change, tried on a real network share, spent most of the import's time in these stats; that figure is not repeated here because it was not taken on this code. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/import/files.rs | 169 ++++++++++++++++++++++++++++++ crates/engine/src/import/job.rs | 17 ++- crates/engine/src/import/scan.rs | 24 ++--- docs/parity-checklist.md | 3 +- docs/whats-new.md | 6 ++ 5 files changed, 192 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs index 099794597..28f45a73f 100644 --- a/crates/engine/src/import/files.rs +++ b/crates/engine/src/import/files.rs @@ -238,6 +238,92 @@ pub(crate) fn probe_all( .collect() } +/// What the disk says about a file ([`stat_all`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Seen { + /// Not asked about: `cancel` was set first. + Unchecked, + /// Not there, or not readable. + Gone, + At { is_file: bool, len: u64 }, +} + +/// [`Seen`] of `path` from the file system. +fn stat_file(path: &str) -> Seen { + match std::fs::metadata(path) { + Ok(m) => Seen::At { is_file: m.is_file(), len: m.len() }, + Err(_) => Seen::Gone, + } +} + +/// Names asked about at once: a stat on a network share is a round trip (50 ms and more under load) +/// that waits for nothing but the server, so a few more than the readers' threads. +#[cfg(not(target_arch = "wasm32"))] +const STAT_THREADS: usize = 16; + +/// Ask `stat` about each of `paths`, several at once (one at a time, a folder of thousands of photos +/// on a network share waited minutes for its stats alone). The answers come in the order of `paths`; +/// the ones not reached once `cancel` is set are [`Seen::Unchecked`]. +pub(crate) fn stat_all(paths: &[&str], cancel: &std::sync::atomic::AtomicBool, stat: impl Fn(&str) -> Seen + Sync) -> Vec { + use std::sync::atomic::Ordering::Relaxed; + let mut seen = vec![Seen::Unchecked; paths.len()]; + #[cfg(not(target_arch = "wasm32"))] + { + let n = STAT_THREADS.min(paths.len()); + if n > 1 { + let next = std::sync::atomic::AtomicUsize::new(0); + let answers: Vec> = std::thread::scope(|sc| { + let readers: Vec<_> = (0..n) + .map(|_| { + sc.spawn(|| { + let mut mine = Vec::new(); + loop { + let i = next.fetch_add(1, Relaxed); + let Some(path) = paths.get(i) else { break }; + if cancel.load(Relaxed) { + break; + } + mine.push((i, stat(path))); + } + mine + }) + }) + .collect(); + // (a reader that died leaves its files unchecked) + readers.into_iter().map(|r| r.join().unwrap_or_default()).collect() + }); + for (i, s) in answers.into_iter().flatten() { + if let Some(slot) = seen.get_mut(i) { + *slot = s; + } + } + return seen; + } + } + for (slot, path) in seen.iter_mut().zip(paths) { + if cancel.load(Relaxed) { + break; + } + *slot = stat(path); + } + seen +} + +/// The probes in `cache` of `files` that are still true: the file is looked at ([`stat_all`]) and +/// `ok` says it is as it was probed. `None` for a file with no probe in `cache`, one that changed, +/// and one not looked at because `cancel` was set. +pub(super) fn reusable_probes( + files: &[String], + cache: &mut HashMap, + cancel: &std::sync::atomic::AtomicBool, + ok: impl Fn(Seen, &ProbeInfo) -> bool, +) -> Vec> { + let held: Vec> = files.iter().map(|f| cache.remove(f)).collect(); + let asked: Vec<&str> = files.iter().zip(&held).filter(|(_, h)| h.is_some()).map(|(f, _)| f.as_str()).collect(); + let mut seen = stat_all(&asked, cancel, stat_file).into_iter(); + held.into_iter().map(|h| h.filter(|info| seen.next().is_some_and(|s| s != Seen::Unchecked && ok(s, info)))).collect() +} + /// Copy `src` into `dir` as `name` (default: its own name), made unique with -1, -2…; returns the /// new path. The copy is verified ([`crate::import_move::copy_new`]: a new file, synced and /// checked against `probe_hash`, the content hash the probe computed — or compared byte for byte @@ -370,4 +456,87 @@ mod prepared_tests { assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed)); assert!(prepared.items.is_empty()); } + + /// Given files on a network share, when an import checks the probes a scan left, then it asks + /// the disk about several files at once: one at a time, 5,990 photos spent 300 s on stats alone + /// (99 % of the import's time), each a round trip to the server. + #[test] + fn stats_are_asked_for_several_files_at_once() { + use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + let (active, together) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let names: Vec = (0..6).map(|i| format!("file-{i}")).collect(); + let paths: Vec<&str> = names.iter().map(String::as_str).collect(); + let seen = stat_all(&paths, &std::sync::atomic::AtomicBool::new(false), |p| { + active.fetch_add(1, Relaxed); + // waits (at most a second) for another file to be asked about while this one is + let end = std::time::Instant::now() + std::time::Duration::from_secs(1); + while active.load(Relaxed) < 2 && std::time::Instant::now() < end { + std::thread::yield_now(); + } + together.fetch_max(active.load(Relaxed), Relaxed); + active.fetch_sub(1, Relaxed); + Seen::At { is_file: true, len: p.trim_start_matches("file-").parse().unwrap_or(u64::MAX) } + }); + assert!(together.load(Relaxed) >= 2, "the files were asked about one at a time"); + let lens: Vec = (0..6).map(|len| Seen::At { is_file: true, len }).collect(); + assert_eq!(seen, lens, "the answers keep the order of the files"); + } + + /// ✕ while the stats run: the files not asked about yet stay unchecked, and no more are asked. + #[test] + fn stats_stop_when_cancelled() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; + let (cancel, asked) = (AtomicBool::new(false), AtomicUsize::new(0)); + let names: Vec = (0..400).map(|i| format!("file-{i}")).collect(); + let paths: Vec<&str> = names.iter().map(String::as_str).collect(); + let seen = stat_all(&paths, &cancel, |_| { + asked.fetch_add(1, Relaxed); + cancel.store(true, Relaxed); + Seen::Gone + }); + assert_eq!(seen.len(), 400); + // at most the files the (up to 16) readers had already started + assert!(asked.load(Relaxed) <= 16, "asked about {} files after cancel", asked.load(Relaxed)); + assert_eq!(seen.iter().filter(|s| **s == Seen::Gone).count(), asked.load(Relaxed)); + assert!(seen.iter().all(|s| matches!(s, Seen::Gone | Seen::Unchecked))); + } + + /// A probe a scan left is used again for a file that is as it was, and only for it: a file that + /// changed size, one that is gone and one with no probe are read. + #[test] + fn a_scans_probe_is_reused_only_for_a_file_as_it_was() { + use std::sync::atomic::Ordering::Relaxed; + let dir = std::env::temp_dir().join(format!("lightcraft-reuse-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut files = Vec::new(); + for (name, bytes) in [("same.jpg", 10usize), ("grown.jpg", 10), ("fresh.jpg", 10)] { + let p = dir.join(name); + std::fs::write(&p, vec![0u8; bytes]).unwrap(); + files.push(p.to_string_lossy().to_string()); + } + let gone = dir.join("gone.jpg").to_string_lossy().to_string(); + files.push(gone.clone()); + let mut s = Session::new(); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let probed = |size: u64| ProbeInfo { format: "JPEG".into(), file_size: size, ..ProbeInfo::default() }; + job.cache.insert(files[0].clone(), probed(10)); + job.cache.insert(files[1].clone(), probed(5)); + job.cache.insert(gone.clone(), probed(10)); + let read = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let read_in_probe = read.clone(); + job.probe = Some(std::sync::Arc::new(move |p: &str| { + read_in_probe.lock().unwrap().push(p.to_string()); + Ok(probed(10)) + })); + let prepared = job.prepare_files(files.clone(), &std::sync::atomic::AtomicBool::new(false)); + let mut read = read.lock().unwrap().clone(); + read.sort(); + let mut expect = vec![files[1].clone(), files[2].clone(), gone]; + expect.sort(); + assert_eq!(read, expect, "only what was not as the scan saw it is read"); + assert_eq!(job.read_total.load(Relaxed), 3); + assert_eq!(prepared.items.len(), 4); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/import/job.rs b/crates/engine/src/import/job.rs index 1730518d5..41fc56a94 100644 --- a/crates/engine/src/import/job.rs +++ b/crates/engine/src/import/job.rs @@ -1,5 +1,5 @@ use super::commit::{How, Plan, Slot, Transfer, commit_prepared, name_family, run_transfers, system_clock}; -use super::files::{expand, probe_all}; +use super::files::{Seen, expand, probe_all, reusable_probes, sidecars_of}; use super::types::{ImportCandidate, ImportMode, ImportOptions, ImportReport, Kept, OnDeleted}; use super::{HashMap, MediaKind, Path, PathBuf, Photo, PhotoId, ProbeInfo, Session, Source}; @@ -44,7 +44,7 @@ pub struct ImportJob { pub(super) probe: Option, file_bytes: Option, /// Probes from a preceding scan (not read again). - cache: HashMap, + pub(super) cache: HashMap, naming: crate::sidecar::SidecarNaming, now: String, seq: usize, @@ -270,15 +270,10 @@ impl ImportJob { // files a preceding scan already probed are not read again (a network share is slow to read), // as long as they are still there, as they were: a file gone or changed since is probed again - let cached: Vec> = todo - .iter() - .map(|f| { - // (the web's files are not std::fs files: kept as probed) - self.cache - .remove(f) - .filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size)) - }) - .collect(); + let cached = reusable_probes(&todo, &mut self.cache, cancel, |seen, info| { + // (the web's files are not std::fs files: kept as probed) + cfg!(target_arch = "wasm32") || matches!(seen, Seen::At { is_file: true, len } if len == info.file_size) + }); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); // Reading the files is the slow part of adding them where they are: it is counted on its // own, and `cancel` stops it too (a large library takes long enough to want both). diff --git a/crates/engine/src/import/scan.rs b/crates/engine/src/import/scan.rs index ef925fa05..57ee995f2 100644 --- a/crates/engine/src/import/scan.rs +++ b/crates/engine/src/import/scan.rs @@ -1,4 +1,4 @@ -use super::files::{expand, probe_all}; +use super::files::{Seen, expand, probe_all, reusable_probes}; use super::types::ImportCandidate; use super::{HashMap, Path, PathBuf, PhotoId, ProbeInfo, Session, Source}; @@ -123,20 +123,14 @@ pub(crate) fn scan_files(mut input: ScanInput, files: Vec, progress: &Sc let by_path = library_files(&input.library, &files); let todo: Vec = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect(); progress.total.store(todo.len(), Relaxed); - // probes from a preceding `scan` are reused when the file is unchanged (same size) - let cached: Vec> = todo - .iter() - .map(|f| { - let info = input.cache.remove(f)?; - // (✕ set while the files' cached probes are checked: no more round trips to the disk; what is - // not reached counts as not probed, like the files after it) - if progress.cancel.load(Relaxed) { - return None; - } - let size = std::fs::metadata(f).map(|m| m.len()).ok(); - (size.is_none() || size == Some(info.file_size)).then_some(info) - }) - .collect(); + // probes from a preceding `scan` are reused when the file is unchanged (same size); a file that + // can't be looked at counts as unchanged (✕ set while they are checked: no more round trips to the + // disk; what is not reached counts as not probed, like the files after it) + let cached = reusable_probes(&todo, &mut input.cache, &progress.cancel, |seen, info| match seen { + Seen::At { len, .. } => len == info.file_size, + Seen::Gone => true, + Seen::Unchecked => false, + }); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); progress.done.store(todo.len() - missing.len(), Relaxed); let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..2f659407c 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-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left 16 files at a time; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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 @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-24 | patch | LR-IMP-ADD-DIALOG: an import checks that each file a scan already probed is as it was 16 files at a time instead of one after another; on a network share a folder of thousands of photos spent most of its import time waiting for those checks; status unchanged (✅) | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..04daf118e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-24 +### Importing a folder from a network share waits less +- After the import review, the import used to check that each photo was still as the review had seen it by asking the + disk about one photo after another, so on a network share every photo waited for a round trip to the server. It now + asks about 16 photos at a time, the same wait for every 16 instead of for each one. Reading the photos in the review + is unchanged. + ### Relative-date filters and smart albums follow the clock - A current view or source count using “in the last” rules now refreshes when the session clock moves into a new minute, even when no catalog edit occurred. This fixes stale photos remaining in the grid and stale counts for From d402fcc4b66bd5f411bc21bea22e6578325851c3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 25 Oct 2026 12:13:39 -0400 Subject: [PATCH 2/4] LR-IMP-ADD-DIALOG: an import looks up the sidecars of a batch's photos several at a time Where the folder listing can't vouch for a name (non-ASCII names, a listing that is gone), each photo's sidecar names are asked of the disk, nine per photo, one photo after another. The batch's lookups now run 16 at a time; the loop that plans the photos takes each one's answer in order. They use the same `at_once` as the probe checks, so the latency stand-in measured with the previous commit applies to them as well (16 at a time against one at a time); the lookups themselves were not timed on a share for this version. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/import/files.rs | 124 ++++++++++++++++++++++++------ crates/engine/src/import/job.rs | 20 ++++- docs/parity-checklist.md | 4 +- docs/whats-new.md | 4 +- 4 files changed, 123 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs index 28f45a73f..9b7be676a 100644 --- a/crates/engine/src/import/files.rs +++ b/crates/engine/src/import/files.rs @@ -245,7 +245,10 @@ pub(crate) enum Seen { Unchecked, /// Not there, or not readable. Gone, - At { is_file: bool, len: u64 }, + At { + is_file: bool, + len: u64, + }, } /// [`Seen`] of `path` from the file system. @@ -256,57 +259,74 @@ fn stat_file(path: &str) -> Seen { } } -/// Names asked about at once: a stat on a network share is a round trip (50 ms and more under load) -/// that waits for nothing but the server, so a few more than the readers' threads. +/// Files asked about at once: a question to a network share (a stat, a name looked up) is a round +/// trip (50 ms and more under load) that waits for nothing but the server, so a few more than the +/// readers' threads. #[cfg(not(target_arch = "wasm32"))] -const STAT_THREADS: usize = 16; +const AT_ONCE: usize = 16; -/// Ask `stat` about each of `paths`, several at once (one at a time, a folder of thousands of photos -/// on a network share waited minutes for its stats alone). The answers come in the order of `paths`; -/// the ones not reached once `cancel` is set are [`Seen::Unchecked`]. -pub(crate) fn stat_all(paths: &[&str], cancel: &std::sync::atomic::AtomicBool, stat: impl Fn(&str) -> Seen + Sync) -> Vec { +/// Run `ask` on each of `items`, several at once (one at a time, a folder of thousands of photos on +/// a network share waited minutes for its questions alone). The answers come in the order of +/// `items`; `None` for an item not reached once `cancel` is set (or whose asker died). +fn at_once(items: &[T], cancel: &std::sync::atomic::AtomicBool, ask: impl Fn(&T) -> R + Sync) -> Vec> { use std::sync::atomic::Ordering::Relaxed; - let mut seen = vec![Seen::Unchecked; paths.len()]; + let mut answers: Vec> = items.iter().map(|_| None).collect(); #[cfg(not(target_arch = "wasm32"))] { - let n = STAT_THREADS.min(paths.len()); + let n = AT_ONCE.min(items.len()); if n > 1 { let next = std::sync::atomic::AtomicUsize::new(0); - let answers: Vec> = std::thread::scope(|sc| { - let readers: Vec<_> = (0..n) + let mine: Vec> = std::thread::scope(|sc| { + let askers: Vec<_> = (0..n) .map(|_| { sc.spawn(|| { let mut mine = Vec::new(); loop { let i = next.fetch_add(1, Relaxed); - let Some(path) = paths.get(i) else { break }; + let Some(item) = items.get(i) else { break }; if cancel.load(Relaxed) { break; } - mine.push((i, stat(path))); + mine.push((i, ask(item))); } mine }) }) .collect(); - // (a reader that died leaves its files unchecked) - readers.into_iter().map(|r| r.join().unwrap_or_default()).collect() + askers.into_iter().map(|a| a.join().unwrap_or_default()).collect() }); - for (i, s) in answers.into_iter().flatten() { - if let Some(slot) = seen.get_mut(i) { - *slot = s; + for (i, r) in mine.into_iter().flatten() { + if let Some(slot) = answers.get_mut(i) { + *slot = Some(r); } } - return seen; + return answers; } } - for (slot, path) in seen.iter_mut().zip(paths) { + for (slot, item) in answers.iter_mut().zip(items) { if cancel.load(Relaxed) { break; } - *slot = stat(path); + *slot = Some(ask(item)); } - seen + answers +} + +/// Ask `stat` about each of `paths`, several at once ([`at_once`]); the ones not reached once +/// `cancel` is set are [`Seen::Unchecked`]. +pub(crate) fn stat_all(paths: &[&str], cancel: &std::sync::atomic::AtomicBool, stat: impl Fn(&str) -> Seen + Sync) -> Vec { + at_once(paths, cancel, |p| stat(p)).into_iter().map(|s| s.unwrap_or(Seen::Unchecked)).collect() +} + +/// The sidecar of each of `files` (`find` says which: a few names looked up on the disk for each, +/// a round trip apiece on a network share), several files at once ([`at_once`]); `None` for a +/// file with no sidecar or not reached once `cancel` is set. +pub(super) fn sidecars_of( + files: &[&str], + cancel: &std::sync::atomic::AtomicBool, + find: impl Fn(&str) -> Option + Sync, +) -> Vec> { + at_once(files, cancel, |f| find(f)).into_iter().map(Option::flatten).collect() } /// The probes in `cache` of `files` that are still true: the file is looked at ([`stat_all`]) and @@ -539,4 +559,62 @@ mod prepared_tests { assert_eq!(prepared.items.len(), 4); let _ = std::fs::remove_dir_all(&dir); } + + /// Given photos on a network share, when an import looks for their sidecars, then it asks about + /// several photos at once: one photo after another, nine names each, they were 77 % of the + /// import's preparation (95 % where a folder listing could not vouch for the names). + #[test] + fn sidecars_are_looked_up_for_several_photos_at_once() { + use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + let (active, together) = (AtomicUsize::new(0), AtomicUsize::new(0)); + let names: Vec = (0..6).map(|i| format!("photo-{i}.jpg")).collect(); + let files: Vec<&str> = names.iter().map(String::as_str).collect(); + let found = sidecars_of(&files, &std::sync::atomic::AtomicBool::new(false), |f| { + active.fetch_add(1, Relaxed); + let end = std::time::Instant::now() + std::time::Duration::from_secs(1); + while active.load(Relaxed) < 2 && std::time::Instant::now() < end { + std::thread::yield_now(); + } + together.fetch_max(active.load(Relaxed), Relaxed); + active.fetch_sub(1, Relaxed); + f.ends_with("-3.jpg").then(|| PathBuf::from(f).with_extension("xmp")) + }); + assert!(together.load(Relaxed) >= 2, "the photos were looked up one at a time"); + let expect: Vec> = (0..6).map(|i| (i == 3).then(|| PathBuf::from("photo-3.xmp"))).collect(); + assert_eq!(found, expect, "each sidecar stays with its photo"); + } + + /// The sidecars found for a batch go to the photos they belong to, whichever have one, and a + /// photo that failed to probe takes none of the others'. + #[test] + fn a_batchs_sidecars_go_to_their_own_photos() { + let dir = std::env::temp_dir().join(format!("lightcraft-sidecars-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let xmp = r#""#; + let files: Vec = ["a.jpg", "broken.jpg", "c.jpg", "d.jpg"].iter().map(|n| dir.join(n).to_string_lossy().to_string()).collect(); + for f in &files { + std::fs::write(f, b"x").unwrap(); + } + std::fs::write(dir.join("c.xmp"), xmp).unwrap(); + let mut s = Session::new(); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + job.probe = Some(std::sync::Arc::new(|p: &str| { + if p.ends_with("broken.jpg") { + Err("unreadable".into()) + } else { + Ok(ProbeInfo { format: "JPEG".into(), file_size: 1, ..ProbeInfo::default() }) + } + })); + let prepared = job.prepare_files(files, &std::sync::atomic::AtomicBool::new(false)); + let rating = |name: &str| { + prepared.items.iter().find_map(|i| match i { + PreparedItem::Ready(r) if r.path.ends_with(name) => Some(r.sidecar.as_ref().and_then(|sc| sc.rating)), + _ => None, + }) + }; + assert_eq!((rating("a.jpg"), rating("c.jpg"), rating("d.jpg")), (Some(None), Some(Some(4)), Some(None))); + assert!(prepared.items.iter().any(|i| matches!(i, PreparedItem::Failed(p, _) if p.ends_with("broken.jpg")))); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/import/job.rs b/crates/engine/src/import/job.rs index 41fc56a94..702c89c36 100644 --- a/crates/engine/src/import/job.rs +++ b/crates/engine/src/import/job.rs @@ -289,6 +289,20 @@ impl ImportJob { library.as_ref(), todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))), ); + // The sidecar of every file that was probed, looked up for the batch at once: each is a few + // names asked of the disk, which on a network share made a folder of thousands of photos wait + // a round trip per name, one name after another. + let asked: Vec<&str> = todo.iter().zip(&probed).filter(|(_, p)| p.is_ok()).map(|(f, _)| f.as_str()).collect(); + // (a file whose content the library has is skipped below as a duplicate: it asks the disk nothing) + let known: std::collections::HashSet<&str> = todo + .iter() + .zip(&probed) + .filter(|(_, p)| !opts.local && p.as_ref().is_ok_and(|i| i.content_hash.as_ref().is_some_and(|h| self.by_hash.contains_key(h)))) + .map(|(f, _)| f.as_str()) + .collect(); + let mut found = + sidecars_of(&asked, cancel, |f| if known.contains(f) { None } else { crate::sidecar::find_sidecar_for_import(&stems, self.naming, f) }) + .into_iter(); // Files are planned one by one, in order (duplicates, sidecars, names, folders); their // copies or moves — the slow part, reading and writing every byte and verifying it — are // collected and run on several threads (issue #367), then their outcomes take their places. @@ -306,6 +320,8 @@ impl ImportJob { continue; } }; + // (this file's place in the lookups above: files that failed to probe have none) + let found_sidecar = found.next().flatten(); // a copy of a file whose transfer is still pending: a duplicate only if that one succeeds if !opts.local && let Some(h) = &info.content_hash @@ -327,9 +343,7 @@ impl ImportJob { // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files // and names the copy when the file itself has none let raw = info.kind == MediaKind::Raw; - let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path) - .and_then(|f| std::fs::read_to_string(f).ok()) - .or_else(|| info.xmp.clone().filter(|_| raw)); + let packet = found_sidecar.and_then(|f| std::fs::read_to_string(f).ok()).or_else(|| info.xmp.clone().filter(|_| raw)); let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { Ok(sc) => Some(sc), diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2f659407c..776a55a99 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-25 · **Change:** minor (LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left 16 files at a time; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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 @@ -959,4 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | -| 2026-10-24 | patch | LR-IMP-ADD-DIALOG: an import checks that each file a scan already probed is as it was 16 files at a time instead of one after another; on a network share a folder of thousands of photos spent most of its import time waiting for those checks; status unchanged (✅) | +| 2026-10-24 | patch | LR-IMP-ADD-DIALOG: an import checks that each file a scan already probed is as it was up to 16 files at a time (an import batch's files, at most) instead of one after another, and looks up a batch's sidecars the same way (`import::sidecars_of`); on a network share a folder of thousands of photos spent most of its import time waiting for those checks; status unchanged (✅) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 04daf118e..98f430e31 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -5,8 +5,10 @@ ### Importing a folder from a network share waits less - After the import review, the import used to check that each photo was still as the review had seen it by asking the disk about one photo after another, so on a network share every photo waited for a round trip to the server. It now - asks about 16 photos at a time, the same wait for every 16 instead of for each one. Reading the photos in the review + asks about up to 16 photos at a time, the same wait for every 16 instead of for each one. Reading the photos in the review is unchanged. +- Looking for each photo's `.xmp` sidecar, which asks the disk several names for every photo, also goes up to 16 + photos at a time. ### Relative-date filters and smart albums follow the clock - A current view or source count using “in the last” rules now refreshes when the session clock moves into a new From 67fca064838de5512cd5ea658401c9d67565c0ba Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 25 Oct 2026 12:32:12 -0400 Subject: [PATCH 3/4] LR-IMP-ADD-DIALOG: the parity row says up to 16 files at a time, as the header and revision row do A batch asks at most 8 at once on a machine with 4 cores or fewer (batch_size clamps to 8..=16). Co-Authored-By: Claude --- docs/parity-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 776a55a99..d164de1dd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -69,7 +69,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/dialogs/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/widgets/album_picker.rs`, `crates/ui-egui/src/tests/album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests/import.rs`) | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/dialogs/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/widgets/album_picker.rs`, `crates/ui-egui/src/tests/album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests/import.rs`). An import (and a scan of a folder scanned before) checks that each file a scan already probed is as it was by asking the disk about up to 16 files at a time (an import batch's files, at most), not one after another (`stat_all`, `crates/engine/src/import/files.rs`), and looks up the sidecars of a batch's photos the same way (`sidecars_of`); on a network share each file was a round trip | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → the import review), `crates/ui-egui/src/drop_overlay.rs`, `crates/ui-egui/src/tests/drop_import.rs` | while files are dragged over the window a veil and a card say what dropping does (review N items for import, import N presets, open the face model; a note to close the open dialog first when one is open); photos and folders open the import review as File ▸ Import does (Settings ▸ Import ▸ Import dropped files without review imports them at once; the web build always does); with no library open on the desktop (the launch list, a library that couldn't open) photos wait for the library opened and presets are refused, and the card says so; a drop while a dialog is open (also in the web build) or while an import is running is refused with a toast, and the card says so; a finished scan waits for an open dialog to close before its review opens; the control channel's `ui.dropFiles` hovers or drops files for scripts; dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests/import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/dialogs/import.rs`) | From 8ccc688e2ee547f57083406048fbd48bd2b629b3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 28 Oct 2026 08:09:16 -0400 Subject: [PATCH 4/4] LR-IMP-ADD-DIALOG: files.rs imports HashMap itself, as #3111's explicit imports need --- crates/engine/src/import/files.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/import/files.rs b/crates/engine/src/import/files.rs index 9b7be676a..9cbe22997 100644 --- a/crates/engine/src/import/files.rs +++ b/crates/engine/src/import/files.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + #[cfg(not(target_arch = "wasm32"))] use super::commit::workers; #[cfg(test)]