UI: Synchronize Folder runs in the background, with progress and Cancel

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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 18:43:39 -04:00
co-authored by Claude Opus 5.5
parent 90a38718e7
commit d43efe713a
11 changed files with 238 additions and 33 deletions
+7 -1
View File
@@ -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"
}
+7 -1
View File
@@ -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"
}
+7 -1
View File
@@ -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": "失敗"
}
+7 -1
View File
@@ -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"
}
+8 -1
View File
@@ -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": "Ошибки"
}
+7 -1
View File
@@ -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": "失败"
}
+7 -1
View File
@@ -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": "失敗"
}
+2
View File
@@ -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()
+5
View File
@@ -247,6 +247,8 @@ pub struct LightcraftApp {
pub sync: Option<sync::SyncTask>,
/// `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<sync::SyncRun>,
/// A Lightroom catalog inspect/import in progress.
pub lightroom: Option<lightroom_import::LightroomTask>,
/// 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);
+142 -19
View File
@@ -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<Value, String> {
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<SyncProgress>,
rx: std::sync::mpsc::Receiver<SyncStep>,
commit: Option<SyncCommit>,
/// 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<String> =
[("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
}
+39 -7
View File
@@ -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"]);
}