From 3bb084762d970834870a31c3d3af67968c1f2ca0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 16 Oct 2026 13:03:58 -0400 Subject: [PATCH 1/6] LRC-LIB-LIBFOLDERS: Synchronize Folder says it is listing the folder instead of sitting at "0 of N" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/engine/src/sync.rs | 32 ++++++++++++-- crates/engine/src/sync/tests_listing.rs | 16 +++++-- crates/engine/src/tests_sync.rs | 35 +++++++++++++++ crates/engine/src/walk.rs | 15 ++++--- crates/ui-egui/locales/cs-formats.json | 1 + crates/ui-egui/locales/de-formats.json | 1 + crates/ui-egui/locales/es-formats.json | 1 + crates/ui-egui/locales/fr-formats.json | 1 + crates/ui-egui/locales/it-formats.json | 3 +- crates/ui-egui/locales/ja-formats.json | 1 + crates/ui-egui/locales/ka-formats.json | 1 + crates/ui-egui/locales/pl-formats.json | 1 + crates/ui-egui/locales/pt-br-formats.json | 1 + crates/ui-egui/locales/ru-formats.json | 1 + crates/ui-egui/locales/uk-formats.json | 1 + crates/ui-egui/locales/zh-hans-formats.json | 1 + crates/ui-egui/locales/zh-hant-formats.json | 1 + crates/ui-egui/src/sync.rs | 48 ++++++++++++++++++--- crates/ui-egui/src/tests_sync.rs | 19 ++++++++ docs/parity-checklist.md | 3 +- docs/whats-new.md | 9 ++++ 21 files changed, 170 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 6f236796e..4691e1512 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -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> = 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")] diff --git a/crates/engine/src/sync/tests_listing.rs b/crates/engine/src/sync/tests_listing.rs index d4664dd15..4348ca830 100644 --- a/crates/engine/src/sync/tests_listing.rs +++ b/crates/engine/src/sync/tests_listing.rs @@ -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)); diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 39378b100..18dc49024 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -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"); diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs index f8a3ba251..12dbadbd6 100644 --- a/crates/engine/src/walk.rs +++ b/crates/engine/src/walk.rs @@ -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))); diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index bbfa7246d..60ce63b07 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -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í." } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 8dcf0a9d5..d7d2049be 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -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." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 022048a26..e4e069ba6 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -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." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index a091ec5ea..7706d7d2b 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -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." } diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 26d520175..571de1aeb 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -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" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 689a342ec..12efa24ad 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -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})" } diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json index afbfd2815..9c46658cb 100644 --- a/crates/ui-egui/locales/ka-formats.json +++ b/crates/ui-egui/locales/ka-formats.json @@ -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." } diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index d5352fe61..29903f6b4 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -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." } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 81b10d3c0..4618ba5f9 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -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})" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index cefaebd47..ffeac91cc 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -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}. Правка → Отменить вернёт их." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 1632e5646..3891cc0f8 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -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}. Редагування → Скасувати поверне їх." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 4445f6581..7f4bd425a 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -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})" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 514491c8c..ffe70708a 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -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})" } diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index cd4a267af..100a35088 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -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, rx: std::sync::mpsc::Receiver, /// 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 { + 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…")), } }); diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index 9e0db5297..a0a830f20 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -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())); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 02f5bf08f..b441be8f9 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-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 `.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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index cfeed33ed..171bb64e0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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 From 80fcab83b0c7a8c6a6dc7b3ba94b213dd7755684 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 20 Oct 2026 07:09:30 -0400 Subject: [PATCH 2/6] LRC-LIB-LIBFOLDERS: The listing row counts folders and files as labels, so one reads right The row read "1 folders, 1 files"; it now reads "folders: 1, files: 1" (all 13 catalogs). `listed` is stored with Release and loaded with Acquire, so a reader that sees the listing over also sees what was stored before it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 10 ++++++---- crates/ui-egui/locales/cs-formats.json | 2 +- crates/ui-egui/locales/de-formats.json | 2 +- crates/ui-egui/locales/es-formats.json | 2 +- crates/ui-egui/locales/fr-formats.json | 2 +- crates/ui-egui/locales/it-formats.json | 2 +- crates/ui-egui/locales/ja-formats.json | 2 +- crates/ui-egui/locales/ka-formats.json | 2 +- crates/ui-egui/locales/pl-formats.json | 2 +- crates/ui-egui/locales/pt-br-formats.json | 2 +- crates/ui-egui/locales/ru-formats.json | 2 +- crates/ui-egui/locales/uk-formats.json | 2 +- crates/ui-egui/locales/zh-hans-formats.json | 2 +- crates/ui-egui/locales/zh-hant-formats.json | 2 +- crates/ui-egui/src/sync.rs | 2 +- crates/ui-egui/src/tests_sync.rs | 2 +- 16 files changed, 21 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 4691e1512..eab923e5e 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -78,8 +78,10 @@ impl SyncProgress { /// 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))) + use std::sync::atomic::Ordering::{Acquire, Relaxed}; + // (Acquire pairs with the Release store of `listed`: once the listing is seen to be + // over, so is what was stored before it, and the counts that follow are the final ones) + (!self.listed.load(Acquire)).then(|| (self.folders_listed.load(Relaxed), self.files_listed.load(Relaxed))) } } @@ -209,7 +211,7 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { let SyncInput { folder, scan, ids, naming, labels } = input; let seen: Vec> = 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); + progress.listed.store(true, std::sync::atomic::Ordering::Release); 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 @@ -239,7 +241,7 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { !progress.files.cancel.load(Relaxed) }), ); - progress.listed.store(true, std::sync::atomic::Ordering::Relaxed); + progress.listed.store(true, std::sync::atomic::Ordering::Release); let listing = &listing; // (no threads on the web: one after the other there) #[cfg(target_arch = "wasm32")] diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 60ce63b07..8a50d38f1 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -296,6 +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}", + "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í." } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index d7d2049be..af29f7134 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -296,6 +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", + "Listing folders — folders: {folders}, files: {files}": "Ordner werden aufgelistet – Ordner: {folders}, Dateien: {files}", "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index e4e069ba6..fd1540169 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -296,6 +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", + "Listing folders — folders: {folders}, files: {files}": "Listando carpetas — carpetas: {folders}, archivos: {files}", "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 7706d7d2b..ae2b77872 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -296,6 +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", + "Listing folders — folders: {folders}, files: {files}": "Liste des dossiers — dossiers : {folders}, fichiers : {files}", "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place." } diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 571de1aeb..ccd01e6a7 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -297,5 +297,5 @@ "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.", - "Listing folders: {folders} folders, {files} files": "Elenco delle cartelle: {folders} cartelle, {files} file" + "Listing folders — folders: {folders}, files: {files}": "Elenco delle cartelle — cartelle: {folders}, file: {files}" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 12efa24ad..ce56500b5 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -296,6 +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} 個", + "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})" } diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json index 9c46658cb..c3b48ef62 100644 --- a/crates/ui-egui/locales/ka-formats.json +++ b/crates/ui-egui/locales/ka-formats.json @@ -296,6 +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}", + "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." } diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 29903f6b4..e3ce316a0 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -296,6 +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}", + "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." } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 4618ba5f9..bab81a368 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -296,6 +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", + "Listing folders — folders: {folders}, files: {files}": "Listando pastas — pastas: {folders}, arquivos: {files}", "{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})" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index ffeac91cc..1efc30268 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -296,6 +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}", + "Listing folders — folders: {folders}, files: {files}": "Просмотр папок — папок: {folders}, файлов: {files}", "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 3891cc0f8..0ada44e08 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -296,6 +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}", + "Listing folders — folders: {folders}, files: {files}": "Перегляд тек — тек: {folders}, файлів: {files}", "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 7f4bd425a..a28b07d2c 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -296,6 +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} 个文件", + "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})" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index ffe70708a..119272225 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -296,6 +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} 個檔案", + "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})" } diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 100a35088..67053787a 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -41,7 +41,7 @@ pub(crate) fn scan_row(progress: &SyncProgress, name: &str) -> (u64, u64, String } fn listing_text(folders: usize, files: usize) -> String { - crate::i18n::tr_format!("Listing folders: {folders} folders, {files} files", folders = folders, files = files) + 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. diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index a0a830f20..bc37d4eab 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -356,7 +356,7 @@ fn while_the_folder_is_listed_its_row_says_what_was_listed_and_its_bar_moves_on_ 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"); + assert_eq!(detail, "Listing folders — folders: 312, files: 18402 · photos/Various"); // the listing is over: the files and photos checked, of how many p.listed.store(true, Relaxed); p.checked.store(10, Relaxed); From 8ca082ba6a646eda361ad6b3a2e1c0587b6a5ce1 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 20 Oct 2026 08:15:13 -0400 Subject: [PATCH 3/6] LRC-LIB-LIBFOLDERS: The docs say the walk for new files stops when the scan is cancelled The module docs and the parity checklist still said it is not cancelled midway. Co-Authored-By: Claude Opus 5.5 --- 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 b441be8f9..e2ee3f06f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -702,7 +702,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files stops when the scan is cancelled, and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | From d1d3dbbb0e0cb2f165ebf3170a113a0c84d16d31 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 20 Oct 2026 08:55:01 -0400 Subject: [PATCH 4/6] LRC-LIB-LIBFOLDERS: the sync module docs say the walk stops when the scan is cancelled 439ab04c2 fixed the checklist row but missed the module docs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index eab923e5e..6f641fa22 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -34,8 +34,8 @@ //! - a sidecar is found as Read Metadata from File finds it, including the other naming //! scheme's file (`IMG_1.xmp` for `IMG_1.JPG` beside `IMG_1.CR3`). //! -//! The walk for new files stops at the import's limits on depth and entries (logged), and is -//! not cancelled midway. +//! The walk for new files stops at the import's limits on depth and entries (logged), or when the +//! scan is cancelled. use std::collections::HashMap; use std::path::{Path, PathBuf}; From fd7fbe59e4741001806c9287d5ccca1c9c280b75 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 20 Oct 2026 09:17:09 -0400 Subject: [PATCH 5/6] LRC-LIB-LIBFOLDERS: a scan cancelled during its listing asks the disk nothing more The cancelled result is never used, but scan_files still checked each listed file's cached probe against the file. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 6f641fa22..713c697a7 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -242,6 +242,11 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { }), ); progress.listed.store(true, std::sync::atomic::Ordering::Release); + // ✕ during the listing: its result is not used, so nothing more is asked of the disk (the files' cached probes + // would each be checked against the file, a round trip per file on a network share) + if progress.files.cancel.load(std::sync::atomic::Ordering::Relaxed) { + return FolderChanges { path: folder, seen, ..Default::default() }; + } let listing = &listing; // (no threads on the web: one after the other there) #[cfg(target_arch = "wasm32")] From df1e6b44267a007eacabf655d326fbb7626f7fbd Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Tue, 20 Oct 2026 10:24:00 -0400 Subject: [PATCH 6/6] LRC-LIB-LIBFOLDERS: a scan cancelled while it goes through the files stops checking the cached probes against the disk Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/import.rs | 5 +++++ crates/engine/src/tests_sync.rs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 7a3bc1f05..d06c3f886 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -691,6 +691,11 @@ pub(crate) fn scan_files(mut input: ScanInput, files: Vec, progress: &Sc .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) }) diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 18dc49024..88c184335 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -720,3 +720,22 @@ fn sidecar_namings_for_many_photos_agree_with_one_at_a_time() { assert_eq!(s.sidecar_namings(&ids), one_by_one); assert!(one_by_one.contains(&crate::sidecar::SidecarNaming::Full), "the case that matters is there"); } + +/// ✕ set after the listing, while the scan goes through the files: the probes a preceding scan +/// kept are not checked against the disk one by one (a round trip per file on a network share); +/// the files not reached come back as errors. +#[test] +fn a_cancelled_scan_does_not_check_the_cached_probes_against_the_disk() { + let dir = Scratch::new("cancel-cached"); + let mut s = library(&dir); + write_png(&dir.path("trip/new.png"), 9); + let found = crate::import::scan(&mut s, &[dir.path("trip")]); + assert!(found.iter().any(|c| c.name == "new.png" && c.error.is_none()), "{found:?}"); + assert!(!s.import_probes.is_empty(), "the scan kept the new file's probe"); + let (input, paths) = crate::import::ScanInput::new(&mut s, &[dir.path("trip")]); + let progress = crate::import::ScanProgress::default(); + progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + let out = crate::import::scan_with(input, &paths, &progress); + let new = out.candidates.iter().find(|c| c.name == "new.png").expect("new.png is a candidate"); + assert!(new.error.is_some(), "the cached probe was used after ✕: {new:?}"); +}