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"); }