Issue #345: Synchronize Folder and the contact sheet in the activity stack

Synchronize Folder (#604) shows its scan ("Looking for changes", while
its dialog is open) and its run ("Synchronizing folder") as rows: the
scan's cross stops it and closes the dialog, the run's stops it like
Cancel did (what was done stays, as one undo step; the end says Stopped).
Its progress window is gone. The contact-sheet PDF export (#563), which
went through the export task already, gets a test for its row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Vyrodov
2026-10-10 09:17:45 +02:00
co-authored by Claude Opus 5.5
parent 8c3c586b80
commit 56dfc8d027
15 changed files with 127 additions and 48 deletions
+3 -1
View File
@@ -2029,5 +2029,7 @@
"Reading Lightroom catalog": "Lightroom-Katalog wird gelesen",
"Merging": "Zusammenfügen",
"Downloading model": "Modell wird heruntergeladen",
"Finding faces": "Gesichter werden gesucht"
"Finding faces": "Gesichter werden gesucht",
"Synchronizing folder": "Ordner wird synchronisiert",
"Looking for changes": "Änderungen werden gesucht"
}
+3 -1
View File
@@ -1874,5 +1874,7 @@
"Reading Lightroom catalog": "Leyendo catálogo de Lightroom",
"Merging": "Combinando",
"Downloading model": "Descargando modelo",
"Finding faces": "Buscando caras"
"Finding faces": "Buscando caras",
"Synchronizing folder": "Sincronizando carpeta",
"Looking for changes": "Buscando cambios"
}
+3 -1
View File
@@ -1874,5 +1874,7 @@
"Reading Lightroom catalog": "Lecture du catalogue Lightroom",
"Merging": "Fusion en cours",
"Downloading model": "Téléchargement du modèle",
"Finding faces": "Recherche des visages"
"Finding faces": "Recherche des visages",
"Synchronizing folder": "Synchronisation du dossier",
"Looking for changes": "Recherche des modifications"
}
+3 -1
View File
@@ -1876,5 +1876,7 @@
"Reading Lightroom catalog": "Lightroom カタログを読み取り中",
"Merging": "合成中",
"Downloading model": "モデルをダウンロード中",
"Finding faces": "顔を検索中"
"Finding faces": "顔を検索中",
"Synchronizing folder": "フォルダを同期中",
"Looking for changes": "変更を確認中"
}
+3 -1
View File
@@ -1874,5 +1874,7 @@
"Reading Lightroom catalog": "Lendo catálogo do Lightroom",
"Merging": "Mesclando",
"Downloading model": "Baixando modelo",
"Finding faces": "Procurando rostos"
"Finding faces": "Procurando rostos",
"Synchronizing folder": "Sincronizando pasta",
"Looking for changes": "Procurando alterações"
}
+3 -1
View File
@@ -1588,5 +1588,7 @@
"Merging": "Слияние",
"Downloading model": "Загрузка модели",
"Finding faces": "Поиск лиц",
"Quit Anyway": "Всё равно выйти"
"Quit Anyway": "Всё равно выйти",
"Synchronizing folder": "Синхронизация папки",
"Looking for changes": "Поиск изменений"
}
+3 -1
View File
@@ -2068,5 +2068,7 @@
"Reading Lightroom catalog": "Читання каталогу Lightroom",
"Merging": "Об’єднання",
"Downloading model": "Завантаження моделі",
"Finding faces": "Пошук облич"
"Finding faces": "Пошук облич",
"Synchronizing folder": "Синхронізація папки",
"Looking for changes": "Пошук змін"
}
+3 -1
View File
@@ -1874,5 +1874,7 @@
"Reading Lightroom catalog": "正在读取 Lightroom 目录",
"Merging": "正在合并",
"Downloading model": "正在下载模型",
"Finding faces": "正在查找人脸"
"Finding faces": "正在查找人脸",
"Synchronizing folder": "正在同步文件夹",
"Looking for changes": "正在查找更改"
}
+3 -1
View File
@@ -1874,5 +1874,7 @@
"Reading Lightroom catalog": "正在讀取 Lightroom 目錄",
"Merging": "正在合併",
"Downloading model": "正在下載模型",
"Finding faces": "正在尋找臉孔"
"Finding faces": "正在尋找臉孔",
"Synchronizing folder": "正在同步資料夾",
"Looking for changes": "正在尋找變更"
}
+20
View File
@@ -215,6 +215,26 @@ mod contact_sheet_tests {
assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["photos"], 1);
}
#[test]
fn contact_sheet_export_shows_a_row_and_its_cross_stops_it() {
let mut app = LightcraftApp::new(
lightcraft_engine::Session::with_demo(),
crate::Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() },
);
let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect();
start_contact_sheet(&mut app, &json!({"path": "Sheet.pdf", "ids": ids})).unwrap();
let rows = app.session.activity.list();
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].total), ("export", "Exporting contact sheet", 24));
assert!(rows[0].cancellable);
app.session.activity.cancel(rows[0].id).unwrap();
assert!(app.export.as_ref().unwrap().cancel.load(Ordering::Relaxed), "the stack's ✕ stops the sheet");
let task = app.export.take().unwrap();
assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err(), "a stopped sheet is not written");
drop(task);
assert!(app.session.activity.list().is_empty(), "the row goes with the export");
}
#[test]
fn cancelled_contact_sheet_never_calls_writer() {
let mut app = LightcraftApp::new(
-1
View File
@@ -1066,7 +1066,6 @@ impl LightcraftApp {
panels::notices::show(self, &ctx);
panels::dialogs::show(self, &ctx);
panels::library_problem::show(self, &ctx);
sync::progress_window(self, &ctx);
export_task::poll(self, &ctx);
panels::activity::show(self, &ctx);
pick::poll(self, &ctx);
+26 -32
View File
@@ -1,11 +1,13 @@
//! Synchronize Folder in the app: the folder is scanned on a worker thread (a network share can
//! take minutes; no frame waits for it) while its dialog is open, and the dialog then says what
//! changed and what to do about it. Synchronize runs `folder.synchronize`, which acts on exactly
//! the scan the dialog showed (see `lightcraft_engine::sync`).
//! the scan the dialog showed (see `lightcraft_engine::sync`). Both the scan and the run show a row in the activity
//! stack (issue #345); its ✕ stops them like the dialog's Cancel.
use std::sync::Arc;
use std::sync::atomic::Ordering;
use lightcraft_engine::activity::{Cancel, TaskGuard};
use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncCommit, SyncInput, SyncJob, SyncProgress, SyncStep, scan_with};
use serde_json::{Value, json};
@@ -18,6 +20,8 @@ pub struct SyncTask {
path: 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,
}
/// Open the dialog for folder `path` (called `name` in it) and start scanning.
@@ -34,7 +38,9 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
app.sync = Some(SyncTask { path: path.to_string(), progress, rx });
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 });
let d = SyncChoice::default();
app.ui.dialog = Some(Dialog::SynchronizeFolder {
path: path.to_string(),
@@ -78,6 +84,14 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
cancel(app);
return;
}
if task.guard.is_cancelled() {
// ✕ on its row: a scan stopped part-way has nothing to show
cancel(app);
app.ui.dialog = None;
return;
}
let (done, total) = task.progress.counts();
task.guard.progress(done as u64, total as u64);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
let changes = match task.rx.try_recv() {
Ok(c) => c,
@@ -210,7 +224,9 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new() });
let guard = app.session.activity.start("sync", "Synchronizing folder", Cancel::Flag(progress.files.cancel.clone()));
guard.detail(name);
app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new(), guard });
Ok(json!({"started": true}))
}
@@ -227,6 +243,8 @@ pub struct SyncRun {
commit: Option<SyncCommit>,
/// Cancel was pressed: nothing new is started; what was readied is still committed.
stopping: bool,
/// Its row in the activity stack (its ✕ is Cancel).
guard: TaskGuard,
}
impl SyncRun {
@@ -253,6 +271,11 @@ impl Drop for SyncRun {
pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(run) = app.sync_run.as_mut() else { return };
ctx.request_repaint_after(std::time::Duration::from_millis(100));
if run.guard.is_cancelled() {
run.stopping = true;
}
let (done, total) = run.counts();
run.guard.progress(done as u64, total as u64);
let mut steps = Vec::new();
let finished = loop {
match run.rx.try_recv() {
@@ -317,32 +340,3 @@ pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) {
app.toast_for(ctx, format!("{name}: {text}"), 12.0);
}
}
/// The progress window while a synchronize runs, with Cancel (what was done stays, as one undo
/// step; nothing new is started).
pub fn progress_window(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(run) = &app.sync_run else { return };
let t = Tokens::get(ctx);
let (done, total) = run.counts();
let text =
if run.stopping { crate::i18n::tr("Stopping…").to_string() } else { format!("{} {done} / {total}", crate::i18n::tr("Synchronizing…")) };
let frac = if total == 0 { 0.0 } else { done as f32 / total as f32 };
let stopping = run.stopping;
let mut cancel = false;
egui::Window::new(crate::i18n::tr("Synchronize Folder"))
.id(egui::Id::new("sync-progress"))
.title_bar(false)
.resizable(false)
.anchor(egui::Align2::CENTER_BOTTOM, [0.0, -80.0])
.fixed_size([340.0, 80.0])
.show(ctx, |ui| {
ui.label(egui::RichText::new(text).color(t.text));
ui.add(egui::ProgressBar::new(frac).desired_width(320.0));
let r = ui.add_enabled(!stopping, egui::Button::new(crate::i18n::tr("Cancel")));
crate::widgets::register(ui.ctx(), "button:syncCancel", r.rect);
cancel = r.clicked();
});
if cancel && let Some(run) = app.sync_run.as_mut() {
run.cancel();
}
}
+50 -2
View File
@@ -9,7 +9,7 @@
//! * Ticking "Remove missing photos" also moves those to Recently Deleted.
//! * Cancel leaves the library as it was.
//! * 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 progress window, as one undo
//! answering, and the photos arrive over the next frames under a row in the activity stack, as one undo
//! step.
use std::time::Duration;
@@ -218,7 +218,7 @@ fn a_stopped_synchronize_says_it_was_stopped_and_stays_at_most_one_step() {
let steps = h.app.session.undo.len();
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
// the progress window's Cancel, before any frame committed anything
// Cancel (what the row's ✕ does), before any frame committed anything
h.app.sync_run.as_mut().expect("running").cancel();
assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends");
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
@@ -254,3 +254,51 @@ fn a_synchronize_and_an_import_never_run_at_once() {
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &d);
assert!(r.is_err() && h.app.sync_run.is_none(), "no synchronize while importing: {r:?}");
}
#[test]
fn a_synchronize_shows_a_row_and_its_cross_stops_it() {
let dir = Scratch::new("row");
let mut h = changed_folder(&dir);
open_and_scan(&mut h, &dir.path("trip"));
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
let rows = h.app.session.activity.list();
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!((rows[0].kind, rows[0].label.as_str()), ("sync", "Synchronizing folder"));
assert!(rows[0].detail.ends_with("trip"), "the folder as the dialog named it: {:?}", rows[0].detail);
assert!(rows[0].cancellable);
// the row's ✕ (as `activity.cancel`): what was done stays, and the end says it was stopped
h.app.session.activity.cancel(rows[0].id).unwrap();
assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends");
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(toast.contains("Stopped"), "{toast:?}");
assert!(h.app.session.activity.list().is_empty(), "the row goes with the run");
}
#[test]
fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() {
let dir = Scratch::new("scanrow");
let mut h = changed_folder(&dir);
// a scan that can't finish until the test lets it: reading the new file waits on a flag
let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let g = gate.clone();
h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| {
while !g.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(5));
}
Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() })
}));
crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap();
let rows = h.app.session.activity.list();
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("sync", "Looking for changes", "trip"));
assert!(rows[0].cancellable);
h.app.session.activity.cancel(rows[0].id).unwrap();
h.step();
assert!(h.app.sync.is_none(), "✕ stops the scan");
assert!(h.app.ui.dialog.is_none(), "and closes its dialog: there is nothing to show");
gate.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(h.app.session.activity.list().is_empty());
h.settle(SETTLE);
assert!(h.app.ui.dialog.is_none(), "a stopped scan opens nothing later");
}
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,7 +4,7 @@
### Activity stack
- Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and
folder scans, exports, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,
folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,
AI Denoise and Find Missing Photos. Each row has the task's name, a progress bar, the count and what it is working
on; ✕ stops the tasks that can stop. Three rows show, then "+N more". The separate export panel and the progress
windows and toasts are gone.
@@ -101,7 +101,7 @@
file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then
choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently
Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing
runs in the background with a progress window and Cancel; everything it does is one undo step, and no file on disk
runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk
is touched. Agents use `folder.scanChanges` and `folder.synchronize`.
### Folder colour labels