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