From 0f8b23875db3385d7cbd42a07725656a239359e9 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:54:39 -0400 Subject: [PATCH] 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 --- crates/ui-egui/locales/de.json | 5 ++- crates/ui-egui/locales/es.json | 5 ++- crates/ui-egui/locales/ja.json | 5 ++- crates/ui-egui/locales/pt-br.json | 5 ++- crates/ui-egui/locales/ru.json | 5 ++- crates/ui-egui/locales/zh-hans.json | 5 ++- crates/ui-egui/locales/zh-hant.json | 5 ++- crates/ui-egui/src/sync.rs | 64 +++++++++++++++++++++-------- crates/ui-egui/src/tests_sync.rs | 14 +++---- 9 files changed, 81 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 475b96bfc..64c70e031 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index a84c43613..d023cf6c4 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b3321139b..9c5072025 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1756,5 +1756,8 @@ "Relinked": "リンクし直し", "Removed": "除去", "Metadata read": "メタデータ読み込み", - "Failed": "失敗" + "Failed": "失敗", + "Stopped": "停止しました", + "Stopped by an error (see the log)": "エラーで停止しました(ログを参照)", + "Not saved": "保存されていません" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9039c17ae..21a3d0c84 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 3e4e5c714..952ef5c92 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1430,5 +1430,8 @@ "Relinked": "Связано заново", "Removed": "Убрано", "Metadata read": "Метаданные прочитаны", - "Failed": "Ошибки" + "Failed": "Ошибки", + "Stopped": "Остановлено", + "Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)", + "Not saved": "Не сохранено" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b143b3118..a67df15a8 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1756,5 +1756,8 @@ "Relinked": "已重新链接", "Removed": "已移除", "Metadata read": "已读取元数据", - "Failed": "失败" + "Failed": "失败", + "Stopped": "已停止", + "Stopped by an error (see the log)": "因错误而停止(见日志)", + "Not saved": "未保存" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 4284d1885..3d179de54 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1756,5 +1756,8 @@ "Relinked": "已重新連結", "Removed": "已移除", "Metadata read": "已讀取中繼資料", - "Failed": "失敗" + "Failed": "失敗", + "Stopped": "已停止", + "Stopped by an error (see the log)": "因錯誤而停止(請見記錄)", + "Not saved": "未儲存" } diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 22d31b098..b60243ac1 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -192,17 +192,21 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { } }; 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 { pub struct SyncRun { name: String, progress: Arc, + /// The work ended in a panic (logged). + crashed: Arc, rx: std::sync::mpsc::Receiver, + /// Steps that went into the catalog but could not be saved (a full disk…), and why. + not_saved: Vec, commit: Option, /// 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 = + for e in ¬_saved { + log::warn!("synchronize {name}: not saved: {e}"); + } + let mut 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(); + // 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 diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index bf1c33867..dff960bc9 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -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"); }