Files
lightcraft/crates/ui-egui/src/sync.rs
T
Nathaniel LedfordandClaude b0f184da33 LR-BEHAV-BG-TASKS: one progress bar, spinner and busy button
Progress bars were five raw egui::ProgressBar calls with fixed widths
(240, 260, 380), one hand-drawn bar in the activity stack, and a stock
spinner; text-only waits had no sign of life. progress.rs now has
Progress (a clamped, NaN-safe fraction or a gentle sweep when unknown,
fills the layout's width, announced as a progress indicator with its
percentage and registered by id) and a vector-arc spinner. Button::busy
shows the spinner in the icon's place, keeps the width, ignores clicks
and is announced busy.

Thumbnails still being made draw a themed skeleton colour (a Tokens
field, static) in the grid, the filmstrip and Compare, instead of a
literal grey that was nearly black on the light themes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 02:02:47 -04:00

392 lines
16 KiB
Rust

//! 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`). 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};
use crate::LightcraftApp;
use crate::state::{Dialog, SyncCounts};
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);
let input = SyncInput::new(&mut app.session, path, disk).map_err(|e| e.to_string())?;
let progress = Arc::new(SyncProgress::default());
let (tx, rx) = std::sync::mpsc::channel();
let p = progress.clone();
let job = move || {
let _ = tx.send(scan_with(input, &p));
};
#[cfg(not(target_arch = "wasm32"))]
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
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(), name: name.to_string(), progress, rx, guard });
let d = SyncChoice::default();
app.ui.dialog = Some(Dialog::SynchronizeFolder {
path: path.to_string(),
name: name.to_string(),
disk,
counts: None,
import_new: d.import_new,
relink_moved: d.relink_moved,
remove_missing: d.remove_missing,
read_metadata: d.read_metadata,
});
Ok(())
}
/// Stop a running scan and let go of a finished one the dialog made (one an agent made with
/// `folder.scanChanges` is the agent's).
fn cancel(app: &mut LightcraftApp) {
if let Some(t) = app.sync.take() {
t.progress.files.cancel.store(true, Ordering::Relaxed);
}
if std::mem::take(&mut app.sync_owns_changes) {
app.session.folder_changes = None;
}
}
/// Collect a finished scan into the dialog (called every frame). A scan whose dialog was closed
/// is cancelled.
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
let open_for = match &app.ui.dialog {
Some(Dialog::SynchronizeFolder { path, .. }) => Some(path.clone()),
_ => None,
};
let Some(open_for) = open_for else {
if app.sync.is_some() || app.sync_owns_changes {
cancel(app);
}
return;
};
let Some(task) = app.sync.as_ref() else { return };
if task.path != open_for {
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, 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,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(_) => {
app.sync = None;
app.ui.dialog = None;
app.toast(ctx, crate::i18n::tr("Scan failed"));
return;
}
};
app.sync = None;
let c = SyncCounts {
offline: changes.offline,
new: changes.new.len(),
duplicates: changes.duplicates,
unreadable: changes.unreadable.len(),
missing: changes.missing.len(),
metadata: changes.metadata.len(),
moved: changes.moved.len(),
};
app.session.folder_changes = Some(changes);
app.sync_owns_changes = true;
if let Some(Dialog::SynchronizeFolder { counts, .. }) = &mut app.ui.dialog {
*counts = Some(c);
}
}
/// 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.
pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
let Dialog::SynchronizeFolder { path, name, counts, import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { return };
let t = Tokens::get(ui.ctx());
ui.set_min_width(420.0);
ui.label(crate::i18n::tr("Bring the library up to date with this folder and the folders inside it:"));
ui.label(egui::RichText::new(name.as_str()).strong());
ui.label(egui::RichText::new(path.as_str()).color(t.text_dim));
ui.add_space(6.0);
let Some(c) = counts.as_ref() else {
ui.horizontal(|ui| {
crate::progress::spinner(ui, 14.0);
match progress(app) {
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…")),
}
});
return;
};
if c.offline {
ui.label(crate::i18n::tr("This folder isn't there: it was moved or renamed, or its disk isn't connected."));
return;
}
let choice = |ui: &mut egui::Ui, on: &mut bool, n: usize, text: String, widget: &str| {
let r = ui.add_enabled(n > 0, egui::Checkbox::new(on, text));
crate::widgets::register(ui.ctx(), widget, r.rect);
};
let count = |text: &str, n: usize| format!("{} ({n})", crate::i18n::tr(text));
let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim));
let skipped = |ui: &mut egui::Ui| {
if c.duplicates > 0 {
dim(ui, &count("Skipped: files already in the library elsewhere", c.duplicates));
}
if c.unreadable > 0 {
dim(ui, &count("Skipped: files that can't be read", c.unreadable));
}
};
if c.up_to_date() {
// nothing to choose: the answer takes the choices' place, and the one button closes
// (`only_shows`)
ui.label(crate::i18n::tr("The library is up to date with this folder."));
skipped(ui);
return;
}
choice(ui, import_new, c.new, count("Import new photos", c.new), "syncImportNew");
choice(ui, relink_moved, c.moved, count("Relink photos whose file was renamed or moved", c.moved), "syncRelinkMoved");
choice(ui, remove_missing, c.missing, count("Remove missing photos from the library", c.missing), "syncRemoveMissing");
choice(ui, read_metadata, c.metadata, count("Read metadata updates from XMP sidecars", c.metadata), "syncReadMetadata");
ui.add_space(6.0);
if c.missing > 0 {
dim(ui, crate::i18n::tr("Missing photos move to Recently Deleted and can be restored; no file on disk is touched."));
}
if c.metadata > 0 {
dim(ui, crate::i18n::tr("Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says."));
}
skipped(ui);
}
/// 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.
/// A folder that is up to date has nothing to hand over: confirming it only closes the dialog.
pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {
let Dialog::SynchronizeFolder { path, name, disk, counts: Some(counts), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg
else {
return Err("the folder is still being scanned".into());
};
// (before the busy checks: nothing is imported or written, so a running import or
// synchronize can't clash with it, and the dialog closes either way. Like every result the
// dialog shows, it is the scan's: a file added since is found by the next Synchronize)
if counts.up_to_date() {
return Ok(json!({"upToDate": true}));
}
if app.sync_run.is_some() {
return Err(crate::i18n::tr("A folder is already being synchronized").to_string());
}
// (an import readies its files against the library as it was: both could add the same file)
if app.import.is_some() {
return Err(crate::i18n::tr("Wait for the import to finish").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;
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 crashed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let (tx, rx) = std::sync::mpsc::channel();
let (p, c) = (progress.clone(), crashed.clone());
let job = move || {
// (a panic in the work ends it; what was handed over is still committed, and the end
// says it failed)
if lightcraft_engine::guard::catch("synchronize", || work.run(&p, |step| tx.send(step).is_ok())).is_err() {
c.store(true, Ordering::Relaxed);
}
};
#[cfg(not(target_arch = "wasm32"))]
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
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}))
}
/// 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>,
/// The work ended in a panic (logged).
crashed: Arc<std::sync::atomic::AtomicBool>,
rx: std::sync::mpsc::Receiver<SyncStep>,
/// Steps that went into the catalog but could not be saved (a full disk…), and why.
not_saved: Vec<String>,
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 {
/// (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));
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() {
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 };
let mut not_saved = Vec::new();
for step in steps {
// (a step that went in but couldn't be saved — a full disk — is said at the end)
if let Err(e) = app.session.execute_fn("folder.synchronize", |s| {
commit.apply(s, step);
Ok(Value::Null)
}) {
not_saved.push(e.to_string());
}
}
let Some(run) = app.sync_run.as_mut() else { return };
run.not_saved.extend(not_saved);
if !finished {
run.commit = Some(commit);
return;
}
let (name, stopped, crashed, mut not_saved) =
(run.name.clone(), run.stopping, run.crashed.load(Ordering::Relaxed), std::mem::take(&mut run.not_saved));
app.sync_run = None;
let mut report = None;
if let Err(e) = app.session.execute_fn("folder.synchronize", |s| {
report = Some(commit.finish(s));
Ok(Value::Null)
}) {
not_saved.push(e.to_string());
}
let r = report.unwrap_or_default();
for (path, e) in &r.failed {
log::warn!("synchronize {name}: {path}: {e}");
}
for e in &not_saved {
log::warn!("synchronize {name}: not saved: {e}");
}
let mut 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();
// how it ended comes first: never "up to date" for a run that was stopped or failed
if crashed {
parts.insert(0, crate::i18n::tr("Stopped by an error (see the log)").to_string());
} else if stopped {
parts.insert(0, crate::i18n::tr("Stopped").to_string());
}
if let Some(e) = not_saved.first() {
parts.push(format!("{}: {e}", crate::i18n::tr("Not saved")));
}
let text = if parts.is_empty() { crate::i18n::tr("The library is up to date with this folder.").to_string() } else { parts.join(" · ") };
if not_saved.is_empty() && !crashed {
app.toast(ctx, format!("{name}: {text}"));
} else {
app.toast_for(ctx, format!("{name}: {text}"), 12.0);
}
}