UI: a Synchronize Folder that was stopped, failed or not saved says so (review fixes)

The end of a run threw away execute_fn's result, so a step that went
into the catalog but couldn't be saved (a full disk) went unmentioned,
and a run stopped with Cancel, or ended by a panic in its worker, with
nothing done toasted "The library is up to date with this folder."
The toast now leads with Stopped / Stopped by an error, adds Not saved
with the reason (both logged, and the toast stays longer), and only
says "up to date" when the run finished with nothing to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 18:54:39 -04:00
co-authored by Claude Opus 5.5
parent 05178a783f
commit 0f8b23875d
9 changed files with 81 additions and 32 deletions
+4 -1
View File
@@ -1911,5 +1911,8 @@
"Relinked": "Neu verknüpft",
"Removed": "Entfernt",
"Metadata read": "Metadaten gelesen",
"Failed": "Fehlgeschlagen"
"Failed": "Fehlgeschlagen",
"Stopped": "Angehalten",
"Stopped by an error (see the log)": "Durch einen Fehler angehalten (siehe Protokoll)",
"Not saved": "Nicht gespeichert"
}
+4 -1
View File
@@ -1756,5 +1756,8 @@
"Relinked": "Revinculadas",
"Removed": "Quitadas",
"Metadata read": "Metadatos leídos",
"Failed": "Con errores"
"Failed": "Con errores",
"Stopped": "Detenido",
"Stopped by an error (see the log)": "Detenido por un error (ver el registro)",
"Not saved": "Sin guardar"
}
+4 -1
View File
@@ -1756,5 +1756,8 @@
"Relinked": "リンクし直し",
"Removed": "除去",
"Metadata read": "メタデータ読み込み",
"Failed": "失敗"
"Failed": "失敗",
"Stopped": "停止しました",
"Stopped by an error (see the log)": "エラーで停止しました(ログを参照)",
"Not saved": "保存されていません"
}
+4 -1
View File
@@ -1756,5 +1756,8 @@
"Relinked": "Revinculadas",
"Removed": "Removidas",
"Metadata read": "Metadados lidos",
"Failed": "Com falha"
"Failed": "Com falha",
"Stopped": "Interrompido",
"Stopped by an error (see the log)": "Interrompido por um erro (veja o log)",
"Not saved": "Não salvo"
}
+4 -1
View File
@@ -1430,5 +1430,8 @@
"Relinked": "Связано заново",
"Removed": "Убрано",
"Metadata read": "Метаданные прочитаны",
"Failed": "Ошибки"
"Failed": "Ошибки",
"Stopped": "Остановлено",
"Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)",
"Not saved": "Не сохранено"
}
+4 -1
View File
@@ -1756,5 +1756,8 @@
"Relinked": "已重新链接",
"Removed": "已移除",
"Metadata read": "已读取元数据",
"Failed": "失败"
"Failed": "失败",
"Stopped": "已停止",
"Stopped by an error (see the log)": "因错误而停止(见日志)",
"Not saved": "未保存"
}
+4 -1
View File
@@ -1756,5 +1756,8 @@
"Relinked": "已重新連結",
"Removed": "已移除",
"Metadata read": "已讀取中繼資料",
"Failed": "失敗"
"Failed": "失敗",
"Stopped": "已停止",
"Stopped by an error (see the log)": "因錯誤而停止(請見記錄)",
"Not saved": "未儲存"
}
+48 -16
View File
@@ -192,17 +192,21 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {
}
};
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 = progress.clone();
let (p, c) = (progress.clone(), crashed.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()));
// (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();
app.sync_run = Some(SyncRun { name: name.clone(), progress, rx, commit: Some(commit), stopping: false });
app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new() });
Ok(json!({"started": true}))
}
@@ -211,7 +215,11 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {
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,
@@ -250,36 +258,60 @@ pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) {
}
};
let Some(mut commit) = run.commit.take() else { return };
let mut not_saved = Vec::new();
for step in steps {
let _ = app.session.execute_fn("folder.synchronize", |s| {
// (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)
});
}
if !finished {
if let Some(run) = app.sync_run.as_mut() {
run.commit = Some(commit);
}) {
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 = app.sync_run.take().map(|r| r.name.clone()).unwrap_or_default();
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;
let _ = app.session.execute_fn("folder.synchronize", |s| {
if let Err(e) = app.session.execute_fn("folder.synchronize", |s| {
report = Some(commit.finish(s));
Ok(Value::Null)
});
let Some(r) = report else { return };
}) {
not_saved.push(e.to_string());
}
let r = report.unwrap_or_default();
for (path, e) in &r.failed {
log::warn!("synchronize {name}: {path}: {e}");
}
let parts: Vec<String> =
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(" · ") };
app.toast(ctx, format!("{name}: {text}"));
if not_saved.is_empty() && !crashed {
app.toast(ctx, format!("{name}: {text}"));
} else {
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
+5 -9
View File
@@ -203,21 +203,17 @@ fn a_scan_gone_stale_in_the_dialog_is_made_again_in_the_background() {
}
#[test]
fn the_progress_window_can_stop_a_synchronize() {
fn a_stopped_synchronize_says_it_was_stopped_and_stays_at_most_one_step() {
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
// the progress window's Cancel, 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");
// 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"]);
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(toast.contains("Stopped"), "never 'up to date': {toast:?}");
assert!(h.app.session.undo.len() <= steps + 1, "what was done by then is at most one step");
}