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

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