From d43efe713a61530e30dd923d17a2cff80838d2f3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:43:39 -0400 Subject: [PATCH] UI: Synchronize Folder runs in the background, with progress and Cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronize used to run folder.synchronize on the UI thread: the import of the new files, the existence checks and the sidecar reads all made the window wait. Confirming now only takes the dialog's scan and hands its SyncWork to a worker thread; the dialog closes at once, a progress window ("Synchronizing… n / m", Cancel) shows while it runs, the steps are committed between frames through execute_fn (auto-write, durable save, panic guard) and the whole run is one undo step, with a toast saying what was done. Cancel stops before the next piece; what was done stays. A stale scan is still refused and made again. Headless `busy` now waits for the scan and the run. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 8 +- crates/ui-egui/locales/es.json | 8 +- crates/ui-egui/locales/ja.json | 8 +- crates/ui-egui/locales/pt-br.json | 8 +- crates/ui-egui/locales/ru.json | 9 +- crates/ui-egui/locales/zh-hans.json | 8 +- crates/ui-egui/locales/zh-hant.json | 8 +- crates/ui-egui/src/headless.rs | 2 + crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/sync.rs | 161 ++++++++++++++++++++++++---- crates/ui-egui/src/tests_sync.rs | 46 ++++++-- 11 files changed, 238 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index baadd1afa..475b96bfc 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1905,5 +1905,11 @@ "Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind", "Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden.", - "Relink photos whose file was renamed or moved": "Fotos neu verknüpfen, deren Datei umbenannt oder verschoben wurde" + "Relink photos whose file was renamed or moved": "Fotos neu verknüpfen, deren Datei umbenannt oder verschoben wurde", + "A folder is already being synchronized": "Es wird bereits ein Ordner synchronisiert", + "Synchronizing…": "Wird synchronisiert…", + "Relinked": "Neu verknüpft", + "Removed": "Entfernt", + "Metadata read": "Metadaten gelesen", + "Failed": "Fehlgeschlagen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 037e0644b..a84c43613 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1750,5 +1750,11 @@ "Skipped: files already in the library elsewhere": "Omitidos: archivos que ya están en la biblioteca en otro lugar", "Skipped: files that can't be read": "Omitidos: archivos que no se pueden leer", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta carpeta no está: se movió o se le cambió el nombre, o su disco no está conectado.", - "Relink photos whose file was renamed or moved": "Volver a vincular fotos cuyo archivo se renombró o movió" + "Relink photos whose file was renamed or moved": "Volver a vincular fotos cuyo archivo se renombró o movió", + "A folder is already being synchronized": "Ya se está sincronizando una carpeta", + "Synchronizing…": "Sincronizando…", + "Relinked": "Revinculadas", + "Removed": "Quitadas", + "Metadata read": "Metadatos leídos", + "Failed": "Con errores" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index dfd14f8ca..b3321139b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1750,5 +1750,11 @@ "Skipped: files already in the library elsewhere": "スキップ: 別の場所ですでにライブラリにあるファイル", "Skipped: files that can't be read": "スキップ: 読み込めないファイル", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "このフォルダが見つかりません。移動または名前の変更がされたか、ディスクが接続されていません。", - "Relink photos whose file was renamed or moved": "ファイル名の変更や移動があった写真をリンクし直す" + "Relink photos whose file was renamed or moved": "ファイル名の変更や移動があった写真をリンクし直す", + "A folder is already being synchronized": "すでにフォルダを同期中です", + "Synchronizing…": "同期中…", + "Relinked": "リンクし直し", + "Removed": "除去", + "Metadata read": "メタデータ読み込み", + "Failed": "失敗" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 7c7769ad3..9039c17ae 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1750,5 +1750,11 @@ "Skipped: files already in the library elsewhere": "Ignorados: arquivos que já estão na biblioteca em outro lugar", "Skipped: files that can't be read": "Ignorados: arquivos que não podem ser lidos", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta pasta não está lá: foi movida ou renomeada, ou o disco dela não está conectado.", - "Relink photos whose file was renamed or moved": "Vincular de novo fotos cujo arquivo foi renomeado ou movido" + "Relink photos whose file was renamed or moved": "Vincular de novo fotos cujo arquivo foi renomeado ou movido", + "A folder is already being synchronized": "Uma pasta já está sendo sincronizada", + "Synchronizing…": "Sincronizando…", + "Relinked": "Revinculadas", + "Removed": "Removidas", + "Metadata read": "Metadados lidos", + "Failed": "Com falha" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 8bcab8f60..3e4e5c714 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1423,5 +1423,12 @@ "Skipped: files already in the library elsewhere": "Пропущено: файлы, которые уже есть в медиатеке в другом месте", "Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён.", - "Relink photos whose file was renamed or moved": "Заново связать фото, файл которых переименован или перемещён" + "Relink photos whose file was renamed or moved": "Заново связать фото, файл которых переименован или перемещён", + "A folder is already being synchronized": "Папка уже синхронизируется", + "Synchronizing…": "Синхронизация…", + "Imported": "Импортировано", + "Relinked": "Связано заново", + "Removed": "Убрано", + "Metadata read": "Метаданные прочитаны", + "Failed": "Ошибки" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ef6ab58c3..b143b3118 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1750,5 +1750,11 @@ "Skipped: files already in the library elsewhere": "已跳过:已在图库其他位置的文件", "Skipped: files that can't be read": "已跳过:无法读取的文件", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此文件夹不存在:它已被移动或重命名,或其磁盘未连接。", - "Relink photos whose file was renamed or moved": "重新链接文件被重命名或移动的照片" + "Relink photos whose file was renamed or moved": "重新链接文件被重命名或移动的照片", + "A folder is already being synchronized": "已有文件夹正在同步", + "Synchronizing…": "正在同步…", + "Relinked": "已重新链接", + "Removed": "已移除", + "Metadata read": "已读取元数据", + "Failed": "失败" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index a59bc8fb7..4284d1885 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1750,5 +1750,11 @@ "Skipped: files already in the library elsewhere": "已略過:已在照片庫其他位置的檔案", "Skipped: files that can't be read": "已略過:無法讀取的檔案", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此資料夾不存在:它已被移動或重新命名,或其磁碟未連接。", - "Relink photos whose file was renamed or moved": "重新連結檔案被重新命名或移動的照片" + "Relink photos whose file was renamed or moved": "重新連結檔案被重新命名或移動的照片", + "A folder is already being synchronized": "已有資料夾正在同步", + "Synchronizing…": "正在同步…", + "Relinked": "已重新連結", + "Removed": "已移除", + "Metadata read": "已讀取中繼資料", + "Failed": "失敗" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e6929269f..e5c6427ed 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -266,6 +266,8 @@ impl Headless { || crate::panels::left::fs_cached_running(&self.view.ctx) > 0 || self.app.merge.busy() || self.app.scan.is_some() + || self.app.sync.is_some() + || self.app.sync_run.is_some() || self.app.import.is_some() || self.app.export.is_some() || self.app.session.denoise_busy() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8f1b41822..503c50b28 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -247,6 +247,8 @@ pub struct LightcraftApp { pub sync: Option, /// `session.folder_changes` came from the Synchronize Folder dialog (and goes with it). pub sync_owns_changes: bool, + /// A Synchronize Folder at work in the background. + pub sync_run: Option, /// A Lightroom catalog inspect/import in progress. pub lightroom: Option, /// Last terminal Lightroom result, exposed by the command's status/wait response. @@ -320,6 +322,7 @@ impl LightcraftApp { scan: None, sync: None, sync_owns_changes: false, + sync_run: None, lightroom: None, lightroom_last: None, export: None, @@ -744,6 +747,7 @@ impl LightcraftApp { merge::poll(self, ctx); import::poll_scan(self, ctx); sync::poll(self, ctx); + sync::poll_run(self, ctx); import::tick(self, ctx); lightroom_import::tick(self, ctx); tasks::poll(self, ctx); @@ -960,6 +964,7 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); import::progress(self, &ctx); + sync::progress_window(self, &ctx); import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index c44bc9421..22d31b098 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncInput, SyncProgress, scan_with}; +use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncCommit, SyncInput, SyncJob, SyncProgress, SyncStep, scan_with}; use serde_json::{Value, json}; use crate::LightcraftApp; @@ -162,28 +162,151 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { } } -/// Synchronize: `folder.synchronize` with the dialog's choices. +/// Synchronize with the dialog's choices: the scan the dialog showed is handed to a worker +/// thread at once ([`SyncRun`]); nothing is read from disk on the UI thread. A scan that went +/// stale (the folder's photos changed meanwhile) is refused and made again, keeping the choices. pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { - let Dialog::SynchronizeFolder { path, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { + let Dialog::SynchronizeFolder { path, name, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { return Err("the folder is still being scanned".into()); }; - let r = app.run( - "folder.synchronize", - json!({"path": path, "disk": disk, "scanned": true, "importNew": import_new, "relinkMoved": relink_moved, "removeMissing": remove_missing, "readMetadata": read_metadata}), - ); + if app.sync_run.is_some() { + return Err(crate::i18n::tr("A folder is already being synchronized").to_string()); + } + let choice = SyncChoice { import_new: *import_new, relink_moved: *relink_moved, remove_missing: *remove_missing, read_metadata: *read_metadata }; app.sync_owns_changes = false; - if r.is_err() { - // (the folder changed since the scan, or went offline): look again, in the background, - // keeping what was ticked; the error is what is reported, not a failure to look again - let name = match dlg { - Dialog::SynchronizeFolder { name, .. } => name.clone(), - _ => String::new(), - }; - if open(app, path, &name, *disk).is_ok() - && let Some(Dialog::SynchronizeFolder { import_new: i, relink_moved: l, remove_missing: m, read_metadata: x, .. }) = &mut app.ui.dialog - { - (*i, *l, *m, *x) = (*import_new, *relink_moved, *remove_missing, *read_metadata); + let started = match app.session.take_folder_changes(path) { + Some(changes) => SyncJob::start(&mut app.session, changes, choice).map_err(|e| e.to_string()), + None => Err(format!("{path} changed since it was scanned: scan again")), + }; + let (work, commit) = match started { + Ok(halves) => halves, + Err(e) => { + // look again, in the background, keeping what was ticked + if open(app, path, name, *disk).is_ok() + && let Some(Dialog::SynchronizeFolder { import_new: i, relink_moved: l, remove_missing: m, read_metadata: x, .. }) = + &mut app.ui.dialog + { + (*i, *l, *m, *x) = (*import_new, *relink_moved, *remove_missing, *read_metadata); + } + return Err(e); } + }; + let progress = Arc::new(SyncProgress::default()); + let (tx, rx) = std::sync::mpsc::channel(); + let p = progress.clone(); + let job = move || { + // (a panic in the work ends it; what was handed over is still committed) + let _ = lightcraft_engine::guard::catch("synchronize", || work.run(&p, |step| tx.send(step).is_ok())); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(job); + #[cfg(target_arch = "wasm32")] + job(); + app.sync_run = Some(SyncRun { name: name.clone(), progress, rx, commit: Some(commit), stopping: false }); + Ok(json!({"started": true})) +} + +/// A synchronize at work: the file-system half on a worker thread, its steps committed here +/// between frames ([`poll_run`]). +pub struct SyncRun { + name: String, + progress: Arc, + rx: std::sync::mpsc::Receiver, + commit: Option, + /// Cancel was pressed: nothing new is started; what was readied is still committed. + stopping: bool, +} + +impl SyncRun { + /// (done, of how many) — for a progress row. + pub fn counts(&self) -> (usize, usize) { + self.progress.counts() + } + /// Stop after the piece in progress. + pub fn cancel(&mut self) { + self.stopping = true; + self.progress.files.cancel.store(true, Ordering::Relaxed); + } +} + +impl Drop for SyncRun { + /// A dropped run (the app closing) stops its worker at the next piece. + fn drop(&mut self) { + self.progress.files.cancel.store(true, Ordering::Relaxed); + } +} + +/// Commit what the worker readied (called every frame); when it is done, one undo step and a +/// word on what happened. +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)); + let mut steps = Vec::new(); + let finished = loop { + match run.rx.try_recv() { + Ok(step) => steps.push(step), + Err(std::sync::mpsc::TryRecvError::Empty) => break false, + Err(std::sync::mpsc::TryRecvError::Disconnected) => break true, + } + }; + let Some(mut commit) = run.commit.take() else { return }; + for step in steps { + let _ = app.session.execute_fn("folder.synchronize", |s| { + commit.apply(s, step); + Ok(Value::Null) + }); + } + if !finished { + if let Some(run) = app.sync_run.as_mut() { + run.commit = Some(commit); + } + return; + } + let name = app.sync_run.take().map(|r| r.name.clone()).unwrap_or_default(); + let mut report = None; + let _ = app.session.execute_fn("folder.synchronize", |s| { + report = Some(commit.finish(s)); + Ok(Value::Null) + }); + let Some(r) = report else { return }; + for (path, e) in &r.failed { + log::warn!("synchronize {name}: {path}: {e}"); + } + let parts: Vec = + [("Imported", r.imported), ("Relinked", r.relinked), ("Removed", r.removed), ("Metadata read", r.read), ("Failed", r.failed.len())] + .into_iter() + .filter(|(_, n)| *n > 0) + .map(|(what, n)| format!("{} {n}", crate::i18n::tr(what))) + .collect(); + let text = if parts.is_empty() { crate::i18n::tr("The library is up to date with this folder.").to_string() } else { parts.join(" · ") }; + app.toast(ctx, format!("{name}: {text}")); +} + +/// 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(); } - r } diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index d4ccd0ad3..3873fbec1 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -8,6 +8,9 @@ //! * Synchronize with the defaults imports the new photos and leaves missing ones alone. //! * 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 +//! step. use std::time::Duration; @@ -93,6 +96,19 @@ fn open_and_scan(h: &mut Headless, folder: &str) { assert!(scanned, "the scan finished: {:?}", h.app.ui.dialog); } +/// Synchronize: the dialog closes and the work goes to the background at once (nothing has +/// changed yet when the confirm returns); then wait for it. +fn synchronize(h: &mut Headless) { + let before = in_library(h); + // the dialog's confirm itself, between frames: it only hands the work over + let dlg = h.app.ui.dialog.take().expect("the dialog is open"); + let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); + assert!(r.is_ok(), "{r:?}"); + assert!(h.app.sync_run.is_some(), "the work runs in the background"); + assert_eq!(in_library(h), before, "nothing was done on the UI thread"); + assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "the work finishes"); +} + fn counts(h: &Headless) -> crate::state::SyncCounts { match &h.app.ui.dialog { Some(Dialog::SynchronizeFolder { counts: Some(c), .. }) => c.clone(), @@ -115,10 +131,8 @@ fn synchronizing_with_the_defaults_imports_the_new_photos_only() { let dir = Scratch::new("defaults"); let mut h = changed_folder(&dir); open_and_scan(&mut h, &dir.path("trip")); - let r = h.request("ui.dialog.confirm", json!({}), T); - assert_eq!(r["ok"], true, "{r}"); + synchronize(&mut h); assert_eq!(in_library(&h), vec!["a.png", "b.png", "c.png"], "c came in; the missing b stays"); - assert!(h.app.ui.dialog.is_none()); } #[test] @@ -129,8 +143,7 @@ fn ticking_remove_missing_also_removes_the_missing_photos() { let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); - let r = h.request("ui.dialog.confirm", json!({}), T); - assert_eq!(r["ok"], true, "{r}"); + synchronize(&mut h); assert_eq!(in_library(&h), vec!["a.png", "c.png"], "b went to Recently Deleted"); let r = h.request("engine.execute", json!({"command": "edit.undo", "params": {}}), T); assert_eq!(r["ok"], true, "{r}"); @@ -181,7 +194,26 @@ fn a_scan_gone_stale_in_the_dialog_is_made_again_in_the_background() { let rescanned = h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. }))); assert!(rescanned, "and scans again"); assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { remove_missing: true, .. })), "keeping what was ticked"); - let r = h.request("ui.dialog.confirm", json!({}), T); - assert_eq!(r["ok"], true, "{r}"); + synchronize(&mut h); assert_eq!(in_library(&h), vec!["a.png", "c.png"], "c came in, the missing b went"); } + +#[test] +fn the_progress_window_can_stop_a_synchronize() { + let dir = Scratch::new("stop"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + 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()); + // Cancel before the first frame commits anything + h.app.sync_run.as_mut().expect("running").cancel(); + assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends"); + // whatever was done by then is at most one undo step + assert!(h.app.session.undo.len() <= steps + 1); + if h.app.session.undo.len() == steps + 1 { + let r = h.request("engine.execute", json!({"command": "edit.undo", "params": {}}), T); + assert_eq!(r["ok"], true, "{r}"); + } + assert_eq!(in_library(&h), vec!["a.png", "b.png"]); +}