From f6bb274da3ecc08ab98a5e4bf4754a399866c444 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:56:04 -0400 Subject: [PATCH] UI: a Synchronize Folder and an import never run at once (review fix) Each readies its files against the library as it was when it started, so with the synchronize in the background, dropping the same folder on the window (or Auto Import) while it ran could add the same files twice. Imports (the review, dropped paths, Auto Import) wait while a folder is being synchronized, and Synchronize waits for a running import. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 4 +++- crates/ui-egui/locales/es.json | 4 +++- crates/ui-egui/locales/ja.json | 4 +++- crates/ui-egui/locales/pt-br.json | 4 +++- crates/ui-egui/locales/ru.json | 4 +++- crates/ui-egui/locales/zh-hans.json | 4 +++- crates/ui-egui/locales/zh-hant.json | 4 +++- crates/ui-egui/src/import.rs | 8 ++++++++ crates/ui-egui/src/lib.rs | 7 ++++++- crates/ui-egui/src/sync.rs | 4 ++++ crates/ui-egui/src/tests_sync.rs | 29 +++++++++++++++++++++++++++++ 11 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 64c70e031..eb237a2e8 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1914,5 +1914,7 @@ "Failed": "Fehlgeschlagen", "Stopped": "Angehalten", "Stopped by an error (see the log)": "Durch einen Fehler angehalten (siehe Protokoll)", - "Not saved": "Nicht gespeichert" + "Not saved": "Nicht gespeichert", + "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", + "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index d023cf6c4..c40f6372c 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1759,5 +1759,7 @@ "Failed": "Con errores", "Stopped": "Detenido", "Stopped by an error (see the log)": "Detenido por un error (ver el registro)", - "Not saved": "Sin guardar" + "Not saved": "Sin guardar", + "A folder is being synchronized": "Se está sincronizando una carpeta", + "Wait for the import to finish": "Espera a que termine la importación" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9c5072025..bd55af76c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1759,5 +1759,7 @@ "Failed": "失敗", "Stopped": "停止しました", "Stopped by an error (see the log)": "エラーで停止しました(ログを参照)", - "Not saved": "保存されていません" + "Not saved": "保存されていません", + "A folder is being synchronized": "フォルダを同期中です", + "Wait for the import to finish": "読み込みが終わるまでお待ちください" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 21a3d0c84..7e9511241 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1759,5 +1759,7 @@ "Failed": "Com falha", "Stopped": "Interrompido", "Stopped by an error (see the log)": "Interrompido por um erro (veja o log)", - "Not saved": "Não salvo" + "Not saved": "Não salvo", + "A folder is being synchronized": "Uma pasta está sendo sincronizada", + "Wait for the import to finish": "Aguarde a importação terminar" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 952ef5c92..4f3689f7d 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1433,5 +1433,7 @@ "Failed": "Ошибки", "Stopped": "Остановлено", "Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)", - "Not saved": "Не сохранено" + "Not saved": "Не сохранено", + "A folder is being synchronized": "Идёт синхронизация папки", + "Wait for the import to finish": "Дождитесь окончания импорта" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index a67df15a8..f12273d05 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1759,5 +1759,7 @@ "Failed": "失败", "Stopped": "已停止", "Stopped by an error (see the log)": "因错误而停止(见日志)", - "Not saved": "未保存" + "Not saved": "未保存", + "A folder is being synchronized": "正在同步文件夹", + "Wait for the import to finish": "请等待导入完成" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 3d179de54..f7e7c35e7 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1759,5 +1759,7 @@ "Failed": "失敗", "Stopped": "已停止", "Stopped by an error (see the log)": "因錯誤而停止(請見記錄)", - "Not saved": "未儲存" + "Not saved": "未儲存", + "A folder is being synchronized": "正在同步資料夾", + "Wait for the import to finish": "請等待匯入完成" } diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 49e3a2664..4b1e40770 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -393,6 +393,12 @@ pub struct ScanTask { sources: Vec, } +/// An import and a Synchronize Folder never run at once: each readies its files against the +/// library as it was when it started, so both could add the same file. +pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> { + if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) } +} + /// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result { if app.scan.is_some() { @@ -565,6 +571,7 @@ impl ScanTask { /// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result { + busy_synchronizing(app)?; let queue = d.selected_paths(); if queue.is_empty() { return Err("no photos selected".into()); @@ -616,6 +623,7 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result /// Start importing `paths` (files or folders) in the background, e.g. dropped on the window. pub fn start_paths(app: &mut LightcraftApp, paths: Vec) -> Result { + busy_synchronizing(app)?; if app.import.is_some() || app.scan.as_ref().is_some_and(|t| !t.browse) { return Err("an import is running".into()); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 503c50b28..05591366a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -772,7 +772,12 @@ impl LightcraftApp { if let Some(folder) = self.session.import_defaults.auto_folder.clone() { const LABEL: &str = "Auto Import"; let now = ctx.input(|i| i.time); - if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && self.lightroom.is_none() && !self.tasks.is_running(LABEL) { + if now - self.ui.auto_import_at >= 3.0 + && self.import.is_none() + && self.sync_run.is_none() + && self.lightroom.is_none() + && !self.tasks.is_running(LABEL) + { self.ui.auto_import_at = now; let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder); let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result, String>| { diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index b60243ac1..244f4308c 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -172,6 +172,10 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { 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) { diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index dff960bc9..59fd041f5 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -217,3 +217,32 @@ fn a_stopped_synchronize_says_it_was_stopped_and_stays_at_most_one_step() { 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"); } + +#[test] +fn a_synchronize_and_an_import_never_run_at_once() { + let dir = Scratch::new("exclusive"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + let dlg = h.app.ui.dialog.take().expect("the dialog is open"); + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok()); + // dropping the same folder on the window while the run works would add its files twice + let r = crate::import::start_paths(&mut h.app, vec![dir.path("trip")]); + assert!(r.is_err(), "no import while synchronizing: {r:?}"); + assert!(h.step_until(T, |h| h.app.sync_run.is_none())); + // and the other way round: an import is running (no frame has run since it started) + let r = crate::import::start_paths(&mut h.app, vec![dir.path("trip/a.png")]); + assert!(r.is_ok(), "{r:?}"); + assert!(h.app.import.is_some()); + let d = Dialog::SynchronizeFolder { + path: dir.path("trip"), + name: "trip".into(), + disk: false, + counts: Some(Default::default()), + import_new: true, + relink_moved: true, + remove_missing: false, + read_metadata: false, + }; + let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &d); + assert!(r.is_err() && h.app.sync_run.is_none(), "no synchronize while importing: {r:?}"); +}