LRC-LIB-LIBFOLDERS: Synchronize Folder says it is listing the folder instead of sitting at "0 of N"

The scan lists the whole folder tree before it checks a photo. On a large
folder on a network share that takes minutes, and the scan's row showed
"0 of 64085" with an empty bar all that while: it looked frozen. ✕ did
not stop the listing either.

- engine: SyncProgress counts the folders and files the walk listed and
  says when the listing is over; a folder note (walk::NoteFolder) now
  answers whether to go on, and a cancelled scan answers no.
- ui: while the folder is listed, the row has an indeterminate bar and
  reads "Listing folders: N folders, M files · folder" (all 13
  catalogs); the dialog says the same; then the photos checked are
  counted as before.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-21 13:17:04 -04:00
co-authored by Claude
parent e142e2ffa4
commit 3bb084762d
21 changed files with 170 additions and 22 deletions
+28 -4
View File
@@ -52,13 +52,19 @@ use crate::{EngineError, Result, Session};
/// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped.
const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024;
/// How far a scan is: the folder's files probed ([`ScanProgress`], whose `cancel` stops the
/// whole scan) and the library's photos checked.
/// How far a scan is: the folders and files listed so far (the walk, which comes first and on a
/// network share can take minutes), the folder's files probed ([`ScanProgress`], whose `cancel`
/// stops the whole scan, walk included) and the library's photos checked.
#[derive(Default)]
pub struct SyncProgress {
pub files: ScanProgress,
pub checked: std::sync::atomic::AtomicUsize,
pub to_check: std::sync::atomic::AtomicUsize,
/// Folders and files (photos or not) the walk has listed.
pub folders_listed: std::sync::atomic::AtomicUsize,
pub files_listed: std::sync::atomic::AtomicUsize,
/// The walk is over (or there was nothing to walk).
pub listed: std::sync::atomic::AtomicBool,
}
impl SyncProgress {
@@ -68,6 +74,13 @@ impl SyncProgress {
let done = self.files.done.load(Relaxed).saturating_add(self.checked.load(Relaxed));
(done, self.files.total.load(Relaxed).saturating_add(self.to_check.load(Relaxed)))
}
/// While a scan lists the folder (before any file can be checked): the folders and files
/// listed so far. `None` once the listing is over.
pub fn listing(&self) -> Option<(usize, usize)> {
use std::sync::atomic::Ordering::Relaxed;
(!self.listed.load(Relaxed)).then(|| (self.folders_listed.load(Relaxed), self.files_listed.load(Relaxed)))
}
}
/// What a scan of a folder needs, taken from the session so it can run on a worker thread.
@@ -196,6 +209,7 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
let SyncInput { folder, scan, ids, naming, labels } = input;
let seen: Vec<Arc<Photo>> = ids.iter().filter_map(|id| labels.photo(*id)).map(Arc::clone).collect();
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
progress.listed.store(true, std::sync::atomic::Ordering::Relaxed);
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
}
// (how each photo's sidecar is named: a pass over the library, made here and not while the
@@ -207,15 +221,25 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
// network share that is one request for a folder's names where it was several for each
// photo. Then both halves wait on the disk at once: the new files are probed (on their own
// threads) beside the checks of the photos, which ask the disk only what the listing can't say.
// (how many photos there are to check is known before the walk: the count shows while it runs)
// (how many photos there are to check is known before the walk; while the walk runs, the
// folders and files it listed tell how far it is)
progress.to_check.store(photos.len(), std::sync::atomic::Ordering::Relaxed);
let mut listing = Listing::default();
let (files, _) = crate::import::expand_noting(
std::slice::from_ref(&folder),
scan.skip(),
crate::walk::Limits::default(),
Some(&mut |dir, names, complete| listing.note(dir, names, complete)),
Some(&mut |dir, names, complete| {
use std::sync::atomic::Ordering::Relaxed;
progress.folders_listed.fetch_add(1, Relaxed);
let files = names.iter().filter(|(_, kind)| *kind == crate::walk::EntryKind::File).count();
progress.files_listed.fetch_add(files, Relaxed);
listing.note(dir, names, complete);
// ✕ stops the walk too (a cancelled scan's result is not used)
!progress.files.cancel.load(Relaxed)
}),
);
progress.listed.store(true, std::sync::atomic::Ordering::Relaxed);
let listing = &listing;
// (no threads on the web: one after the other there)
#[cfg(target_arch = "wasm32")]
+12 -4
View File
@@ -146,8 +146,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() {
.collect();
// (`dir.jpg` is gone, though a sidecar of its name is there)
let mut l = Listing::default();
let walked =
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| l.note(d, names, complete));
let walked = crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| {
l.note(d, names, complete);
true
});
assert_eq!(walked.files.len(), 12);
let nothing = Listing::default();
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
@@ -167,7 +169,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() {
l.asked.load(std::sync::atomic::Ordering::Relaxed)
};
let mut with = Listing::default();
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| with.note(d, names, complete));
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| {
with.note(d, names, complete);
true
});
let (with, without) = (asked(&with), asked(&Listing::default()));
// (without: each photo's file, and the names of its sidecar up to the one that is there: 20 questions where
// names are found in any case, 21 where they are not)
@@ -181,7 +186,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() {
// a folder of plain names only: the disk is asked nothing at all
let sub = dir.join("sub");
let mut only_plain = Listing::default();
crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| only_plain.note(d, names, complete));
crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| {
only_plain.note(d, names, complete);
true
});
let mut c2 = Catalog::new();
for (name, expected) in [("deep.jpg", Check::News), ("gone.jpg", Check::Missing), ("gone.jpg.xmp", Check::Missing)] {
let p = photo(&mut c2, &sub.join(name));
+35
View File
@@ -314,6 +314,41 @@ fn a_cancelled_scan_stops_checking_files() {
assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}");
}
/// While the folder is listed, before a single photo can be checked, the scan says how far the
/// listing is: a folder tree on a network share takes minutes to list, and "0 of 64,085" all
/// that while looks like a scan that has stopped.
#[test]
fn a_scan_says_how_many_folders_and_files_it_has_listed() {
let dir = Scratch::new("listed");
let mut s = library(&dir);
write_png(&dir.path("trip/day2/c.png"), 3);
std::fs::write(dir.path("trip/day2/notes.txt"), "not a photo").unwrap();
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
let progress = crate::sync::SyncProgress::default();
assert_eq!(progress.listing(), Some((0, 0)), "listing is the first thing a scan does");
crate::sync::scan_with(input, &progress);
assert_eq!(progress.listing(), None, "the listing is done once the scan is");
let (folders, files) =
(progress.folders_listed.load(std::sync::atomic::Ordering::Relaxed), progress.files_listed.load(std::sync::atomic::Ordering::Relaxed));
// trip, trip/day1, trip/day2; a.png, b.png, c.png and the note (every file is listed, photo or not)
assert_eq!((folders, files), (3, 4));
}
/// ✕ on a scan's row stops the listing too, not only the checks after it.
#[test]
fn a_cancelled_scan_stops_listing_the_folders() {
let dir = Scratch::new("cancel-listing");
let mut s = library(&dir);
for i in 0..20u8 {
write_png(&dir.path(&format!("trip/more/{i}/x.png")), i);
}
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
let progress = crate::sync::SyncProgress::default();
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
crate::sync::scan_with(input, &progress);
assert_eq!(progress.folders_listed.load(std::sync::atomic::Ordering::Relaxed), 1, "the walk stopped after the folder it was in");
}
#[test]
fn many_photos_are_checked_in_parallel_with_the_same_answer() {
let dir = Scratch::new("many");
+10 -5
View File
@@ -58,8 +58,9 @@ pub enum EntryKind {
/// What [`files_in_noting`] hands over for each folder it lists: the folder, the name and kind
/// of every entry in it (hidden ones too), and whether that is all of them (not when the walk
/// stopped inside the folder, or an entry could not be read).
pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool);
/// stopped inside the folder, or an entry could not be read). It answers whether to go on: `false`
/// ends the walk there (a scan cancelled; [`Walked::truncated`] is left alone: who stopped it knows).
pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool) -> bool;
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
@@ -154,8 +155,10 @@ fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&
};
children.push((p, dir));
}
if let (Some(note), Some(listed)) = (note.as_mut(), listed) {
note(&path, listed, complete);
if let (Some(note), Some(listed)) = (note.as_mut(), listed)
&& !note(&path, listed, complete)
{
break;
}
children.sort();
let below = depth.saturating_add(1);
@@ -193,6 +196,7 @@ mod tests {
let mut entries: Vec<(String, EntryKind)> = entries.into_iter().map(|(n, k)| (n.to_string_lossy().to_string(), k)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
noted.push((dir.to_path_buf(), entries, complete));
true
});
assert_eq!(walked, files_in(&root, None, Limits::default(), |_| true), "the same walk, noted or not");
let named = |n: &str, k: EntryKind| (n.to_string(), k);
@@ -205,7 +209,8 @@ mod tests {
// stopped inside the first folder: its listing is not all of it
let mut cut = Vec::new();
let walked = files_in_noting(&root, None, Limits { max_depth: 8, max_entries: 2 }, |_| true, &mut |dir, entries, complete| {
cut.push((dir.to_path_buf(), entries.len(), complete))
cut.push((dir.to_path_buf(), entries.len(), complete));
true
});
assert!(walked.truncated);
assert_eq!(cut.first().map(|(dir, n, complete)| (dir.clone(), *n, *complete)), Some((root.clone(), 2, false)));
+1
View File
@@ -296,5 +296,6 @@
"Already in “{}”": "Již v „{}“",
"Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?",
"Listing folders: {folders} folders, {files} files": "Procházení složek – složky: {folders}, soubory: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí."
}
+1
View File
@@ -296,5 +296,6 @@
"Already in “{}”": "Bereits in „{}“",
"Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben",
"Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?",
"Listing folders: {folders} folders, {files} files": "Ordner werden aufgelistet: {folders} Ordner, {files} Dateien",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück."
}
+1
View File
@@ -296,5 +296,6 @@
"Already in “{}”": "Ya está en “{}”",
"Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”",
"Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?",
"Listing folders: {folders} folders, {files} files": "Listando carpetas: {folders} carpetas, {files} archivos",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve."
}
+1
View File
@@ -296,5 +296,6 @@
"Already in “{}”": "Déjà dans « {} »",
"Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »",
"Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?",
"Listing folders: {folders} folders, {files} files": "Liste des dossiers : {folders} dossiers, {files} fichiers",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place."
}
+2 -1
View File
@@ -296,5 +296,6 @@
"Already in “{}”": "Già in “{}”",
"Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}",
"Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Foto spostate in “{1}”: {n}{0:.0}. Modifica → Annulla le riporta indietro."
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Foto spostate in “{1}”: {n}{0:.0}. Modifica → Annulla le riporta indietro.",
"Listing folders: {folders} folders, {files} files": "Elenco delle cartelle: {folders} cartelle, {files} file"
}
+1
View File
@@ -296,5 +296,6 @@
"Renames {n} face{} across the library. The files are not changed.": "ライブラリ全体で{n}件の顔の名前を変更します。ファイルは変更されません。{:.0}",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません",
"Listing folders: {folders} folders, {files} files": "フォルダーを一覧中:フォルダー {folders} 個、ファイル {files} 個",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})"
}
+1
View File
@@ -296,5 +296,6 @@
"Already in “{}”": "Already in “{}”",
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
"Listing folders: {folders} folders, {files} files": "საქაღალდეების ჩამოთვლა: საქაღალდე — {folders}, ფაილი — {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back."
}
+1
View File
@@ -296,5 +296,6 @@
"Already in “{}”": "Już w „{}”",
"Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?",
"Listing folders: {folders} folders, {files} files": "Przeglądanie folderów — foldery: {folders}, pliki: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je."
}
@@ -296,5 +296,6 @@
"Renames {n} face{} across the library. The files are not changed.": "Renomeia {n} rosto{} na biblioteca. Os arquivos não são alterados.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem",
"Listing folders: {folders} folders, {files} files": "Listando pastas: {folders} pastas, {files} arquivos",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})"
}
+1
View File
@@ -296,5 +296,6 @@
"Already in “{}”": "Уже в «{}»",
"Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?",
"Listing folders: {folders} folders, {files} files": "Просмотр папок — папок: {folders}, файлов: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их."
}
+1
View File
@@ -296,5 +296,6 @@
"Already in “{}”": "Уже в «{}»",
"Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?",
"Listing folders: {folders} folders, {files} files": "Перегляд тек — тек: {folders}, файлів: {files}",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх."
}
@@ -296,5 +296,6 @@
"Renames {n} face{} across the library. The files are not changed.": "在整个图库中重命名 {n} 张面孔。不会改动文件。{:.0}",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已添加 {what} 合并({mp}):完整尺寸({full})所需的内存超出了这台电脑的内存",
"Listing folders: {folders} folders, {files} files": "正在列出文件夹:{folders} 个文件夹,{files} 个文件",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})"
}
@@ -296,5 +296,6 @@
"Renames {n} face{} across the library. The files are not changed.": "在整個照片庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體",
"Listing folders: {folders} folders, {files} files": "正在列出資料夾:{folders} 個資料夾,{files} 個檔案",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})"
}
+41 -7
View File
@@ -18,12 +18,32 @@ use crate::theme::Tokens;
/// A running scan of the dialog's folder.
pub struct SyncTask {
path: String,
/// What the dialog calls the folder (its row's detail).
name: String,
progress: Arc<SyncProgress>,
rx: std::sync::mpsc::Receiver<FolderChanges>,
/// Its row in the activity stack; ✕ cancels the scan and closes the dialog.
guard: TaskGuard,
}
/// What a scan's row shows: (done, of how many, detail). While the folder is listed, which comes
/// first and on a network share can take minutes, a bar that moves on its own and how many
/// folders and files were listed (a count stuck at "0 of 64085" looks like a scan that stopped);
/// then the files and photos checked.
pub(crate) fn scan_row(progress: &SyncProgress, name: &str) -> (u64, u64, String) {
match progress.listing() {
Some((folders, files)) => (0, 0, format!("{} · {name}", listing_text(folders, files))),
None => {
let (done, total) = progress.counts();
(done as u64, total as u64, name.to_string())
}
}
}
fn listing_text(folders: usize, files: usize) -> String {
crate::i18n::tr_format!("Listing folders: {folders} folders, {files} files", folders = folders, files = files)
}
/// Open the dialog for folder `path` (called `name` in it) and start scanning.
pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Result<(), String> {
cancel(app);
@@ -40,7 +60,7 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu
job();
let guard = app.session.activity.start("sync", "Looking for changes", Cancel::Flag(progress.files.cancel.clone()));
guard.detail(name);
app.sync = Some(SyncTask { path: path.to_string(), progress, rx, guard });
app.sync = Some(SyncTask { path: path.to_string(), name: name.to_string(), progress, rx, guard });
let d = SyncChoice::default();
app.ui.dialog = Some(Dialog::SynchronizeFolder {
path: path.to_string(),
@@ -90,8 +110,9 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
app.ui.dialog = None;
return;
}
let (done, total) = task.progress.counts();
task.guard.progress(done as u64, total as u64);
let (done, total, detail) = scan_row(&task.progress, &task.name);
task.guard.progress(done, total);
task.guard.detail(&detail);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
let changes = match task.rx.try_recv() {
Ok(c) => c,
@@ -120,9 +141,21 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
/// How far the scan is (files probed, of how many).
fn progress(app: &LightcraftApp) -> Option<(usize, usize)> {
app.sync.as_ref().map(|t| t.progress.counts())
/// How far the scan is: the folders and files listed while it lists the folder, else the files
/// and photos checked (done, of how many).
enum ScanStage {
Listing(usize, usize),
Checking(usize, usize),
}
fn progress(app: &LightcraftApp) -> Option<ScanStage> {
app.sync.as_ref().map(|t| match t.progress.listing() {
Some((folders, files)) => ScanStage::Listing(folders, files),
None => {
let (done, total) = t.progress.counts();
ScanStage::Checking(done, total)
}
})
}
/// The dialog's body.
@@ -138,7 +171,8 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
ui.horizontal(|ui| {
ui.spinner();
match progress(app) {
Some((done, total)) if total > 0 => ui.label(format!("{} {done} / {total}", crate::i18n::tr("Scanning…"))),
Some(ScanStage::Listing(folders, files)) => ui.label(listing_text(folders, files)),
Some(ScanStage::Checking(done, total)) if total > 0 => ui.label(format!("{} {done} / {total}", crate::i18n::tr("Scanning…"))),
_ => ui.label(crate::i18n::tr("Scanning…")),
}
});
+19
View File
@@ -13,6 +13,9 @@
//! * Synchronize hands the work to the background at once: the dialog closes, the app keeps
//! answering, and the photos arrive over the next frames under a row in the activity stack, as one undo
//! step.
//! * While the folder is listed (minutes, on a network share), the scan's row says how many
//! folders and files it has listed, with a bar that moves on its own, not "0 of 64085"; then it
//! counts the files and photos checked.
use std::time::Duration;
@@ -343,3 +346,19 @@ fn a_local_folder_with_library_photos_offers_synchronize() {
assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog opens at once");
assert!(h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. }))));
}
#[test]
fn while_the_folder_is_listed_its_row_says_what_was_listed_and_its_bar_moves_on_its_own() {
use std::sync::atomic::Ordering::Relaxed;
let p = lightcraft_engine::sync::SyncProgress::default();
p.to_check.store(64085, Relaxed);
p.folders_listed.store(312, Relaxed);
p.files_listed.store(18402, Relaxed);
let (done, total, detail) = crate::sync::scan_row(&p, "photos/Various");
assert_eq!((done, total), (0, 0), "a bar that moves on its own, not 0 of 64085");
assert_eq!(detail, "Listing folders: 312 folders, 18402 files · photos/Various");
// the listing is over: the files and photos checked, of how many
p.listed.store(true, Relaxed);
p.checked.store(10, Relaxed);
assert_eq!(crate::sync::scan_row(&p, "photos/Various"), (10, 64085, "photos/Various".to_string()));
}
+2 -1
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (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 ✕; earlier: LRC-PRINT-PAGE: Print Page Options) · **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
@@ -896,3 +896,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. |
| 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) |
| 2026-10-16 | patch | LR-META-XMP: a TIFF or JPEG imported beside a raw of its stem reads its own `<file>.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives with the raw or after it (a stem sidecar belongs to one file: a raw first, then the first by name); status unchanged (✅) |
| 2026-10-16 | minor | LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan row says how many folders and files it has listed while it lists the folder (an indeterminate bar, not "0 of N"), and ✕ stops the listing; statuses unchanged |
+9
View File
@@ -24,6 +24,15 @@
another ratio (4 × 5, 2 × 3…) keeps it portrait, as in Lightroom, and a Custom ratio taller than wide gives a
portrait crop.
## 2026-10-16
### Synchronize Folder shows that it is listing the folder
- A folder with tens of thousands of photos, above all on a network share, can take minutes to list before
Synchronize Folder checks a single photo. Its row in the activity stack used to sit at "0 of 64085" with an empty
bar all that time, as if it had stopped. It now says how many folders and files it has listed so far, with a bar
that moves on its own, and then counts the photos it checks. The dialog says the same.
- ✕ on the row stops the listing too, not only the checks after it.
## 2026-10-15
### Constrain Crop tightens the crop you see