From 1af0dd4fe3ec7b8c011df755aeceb753afd5a1aa Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 00:14:00 +0200 Subject: [PATCH] Issue #345: folder scan in the activity stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The import review's folder scan (and reading a folder for Local) is a row in the activity stack: "Looking for photos…" while the folders are listed, then the files read of the total. Its cross drops the scan at once, without waiting for the file being read, as the old Cancel did. The separate Scanning window is gone. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/import.rs | 3 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/headless.rs | 9 ++++- crates/ui-egui/src/import.rs | 58 ++++++++++++---------------- crates/ui-egui/src/lib.rs | 1 - crates/ui-egui/src/tests_activity.rs | 19 +++++++++ 14 files changed, 70 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ac5640a4..29ea013e 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -501,7 +501,8 @@ pub struct ScanProgress { /// Files to probe, set once the folders are expanded. pub total: std::sync::atomic::AtomicUsize, pub done: std::sync::atomic::AtomicUsize, - pub cancel: std::sync::atomic::AtomicBool, + /// Shared with the scan's row in the activity stack (its ✕ sets it). + pub cancel: std::sync::Arc, } /// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows. diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c515ccc9..ce6c51aa 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2020,5 +2020,6 @@ "Show less": "Weniger anzeigen", "Quit while tasks are running?": "Beenden, während Aufgaben laufen?", "Exporting contact sheet": "Kontaktabzug wird exportiert", - "Reading folder": "Ordner wird gelesen" + "Reading folder": "Ordner wird gelesen", + "Scanning folder": "Ordner wird durchsucht" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 75bb1b6e..b85268d3 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1865,5 +1865,6 @@ "Show less": "Mostrar menos", "Quit while tasks are running?": "¿Salir con tareas en curso?", "Exporting contact sheet": "Exportando hoja de contactos", - "Reading folder": "Leyendo carpeta" + "Reading folder": "Leyendo carpeta", + "Scanning folder": "Explorando carpeta" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 8ae407b2..cdbf6ee5 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1865,5 +1865,6 @@ "Show less": "Afficher moins", "Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?", "Exporting contact sheet": "Exportation de la planche-contact", - "Reading folder": "Lecture du dossier" + "Reading folder": "Lecture du dossier", + "Scanning folder": "Analyse du dossier" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 800383ec..60962fa5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1867,5 +1867,6 @@ "Show less": "表示を減らす", "Quit while tasks are running?": "タスクの実行中に終了しますか?", "Exporting contact sheet": "コンタクトシートを書き出し中", - "Reading folder": "フォルダーを読み込み中" + "Reading folder": "フォルダーを読み込み中", + "Scanning folder": "フォルダーをスキャン中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 3be7086e..d97e23dd 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1865,5 +1865,6 @@ "Show less": "Mostrar menos", "Quit while tasks are running?": "Sair com tarefas em andamento?", "Exporting contact sheet": "Exportando folha de contato", - "Reading folder": "Lendo pasta" + "Reading folder": "Lendo pasta", + "Scanning folder": "Verificando pasta" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 40ea9150..f98484d8 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1578,5 +1578,6 @@ "Show less": "Свернуть", "Quit while tasks are running?": "Выйти, пока выполняются задачи?", "Exporting contact sheet": "Экспорт контрольного листа", - "Reading folder": "Чтение папки" + "Reading folder": "Чтение папки", + "Scanning folder": "Сканирование папки" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 30671a82..2c43493f 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2059,5 +2059,6 @@ "Show less": "Показати менше", "Quit while tasks are running?": "Вийти, поки виконуються завдання?", "Exporting contact sheet": "Експорт аркуша мініатюр", - "Reading folder": "Читання папки" + "Reading folder": "Читання папки", + "Scanning folder": "Сканування папки" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index a993a5d0..7fe1e57f 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1865,5 +1865,6 @@ "Show less": "收起", "Quit while tasks are running?": "要在任务运行时退出吗?", "Exporting contact sheet": "正在导出联系表", - "Reading folder": "正在读取文件夹" + "Reading folder": "正在读取文件夹", + "Scanning folder": "正在扫描文件夹" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index e13a9486..9cf79141 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1865,5 +1865,6 @@ "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", "Exporting contact sheet": "正在匯出聯絡表", - "Reading folder": "正在讀取檔案夾" + "Reading folder": "正在讀取檔案夾", + "Scanning folder": "正在掃描檔案夾" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index f58fa7fe..d0104f7d 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2438,9 +2438,14 @@ mod tests { assert!(!h.app.scan.as_ref().unwrap().copy, "the running scan keeps its options"); let r = h.request("ui.inspect", json!({}), t); assert!(r["result"]["scan"].is_object(), "{r}"); - let r = h.request("ui.clickWidget", json!({"id": "button:scanCancel"}), t); + // Cancel is the ✕ of the scan's row in the activity stack (shown once it is half a second old) + let id = h.app.session.activity.list().first().map(|t| t.id).expect("a scan row"); + let cross = format!("activity:cancel:{id}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + let r = h.request("ui.clickWidget", json!({"id": cross}), t); assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.scan.is_none(), "Cancel closes the scan at once"); + h.step(); + assert!(h.app.scan.is_none(), "✕ closes the scan at once"); gate.store(true, std::sync::atomic::Ordering::Relaxed); h.settle(SETTLE); assert!(h.app.ui.dialog.is_none(), "a cancelled scan opens no review"); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 9589554f..af0a10ad 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -394,6 +394,14 @@ pub struct ScanTask { browse: bool, /// What is being scanned (the review's source). sources: Vec, + /// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`). + guard: lightcraft_engine::activity::TaskGuard, +} + +/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder"). +fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard { + let label = if browse { "Reading folder" } else { "Scanning folder" }; + app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone())) } /// An import and a Synchronize Folder never run at once: each readies its files against the @@ -419,7 +427,8 @@ pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result) -> std::thread::spawn(job); #[cfg(target_arch = "wasm32")] job(); - app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new() }); + let guard = scan_guard(app, &progress, true); + app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new(), guard }); app.renderer.forget_imports(); Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) } @@ -491,7 +501,18 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.request_repaint_after(std::time::Duration::from_millis(100)); let out = match task.rx.try_recv() { Ok(o) => o, - Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Empty) => { + if task.guard.is_cancelled() { + // ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while) + app.scan = None; + return; + } + let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed)); + task.guard.progress(done as u64, total as u64); + // while the folders are listed the total isn't known: say what is happening instead + task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" }); + return; + } Err(_) => { app.scan = None; app.toast(ctx, crate::i18n::tr("Scan failed")); @@ -534,37 +555,6 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); } -/// The progress window while a folder is being scanned. -pub fn scan_progress(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(task) = &app.scan else { return }; - let t = Tokens::get(ctx); - let total = task.progress.total.load(Ordering::Relaxed); - let done = task.progress.done.load(Ordering::Relaxed); - let text = if total == 0 { - if task.browse { "Reading folder…" } else { "Looking for photos…" }.to_string() - } else { - crate::i18n::tr_format!("Reading photos… {done} of {total}", done = done, total = total) - }; - let mut cancel = false; - egui::Window::new(crate::i18n::tr("Scanning")) - .title_bar(false) - .resizable(false) - .anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]) - .fixed_size([340.0, 80.0]) - .show(ctx, |ui| { - ui.label(egui::RichText::new(text).color(t.text)); - ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(320.0)); - let r = ui.button(crate::i18n::tr("Cancel")); - register(ui.ctx(), "button:scanCancel", r.rect); - cancel = r.clicked(); - }); - if cancel { - // the worker stops at its next file; don't wait for it (a NAS read can take a while) - task.progress.cancel.store(true, Ordering::Relaxed); - app.scan = None; - } -} - impl ScanTask { /// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed). pub fn status(&self) -> Value { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0e9da3ec..1b52ce0b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1073,7 +1073,6 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); sync::progress_window(self, &ctx); - import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); panels::activity::show(self, &ctx); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 39074161..5e8e967d 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -263,3 +263,22 @@ fn import_cancelled_by_command_reports_cancelled() { assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn scan_shows_a_row_and_cancel_closes_it() { + let (mut h, dir) = slow_import("scan", 120); + crate::import::open(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap(); + h.step(); + let tasks = h.app.session.activity.list(); + assert_eq!(tasks.first().map(|t| t.kind), Some("scan"), "{tasks:?}"); + assert!(tasks[0].cancellable); + h.app.session.activity.cancel(tasks[0].id).unwrap(); + // like the old Cancel: the scan is dropped at once, without waiting for the file being read + assert!(h.step_until(Duration::from_secs(10), |h| h.app.scan.is_none())); + assert!(h.app.session.activity.list().is_empty()); + for _ in 0..3 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no review opened"); + let _ = std::fs::remove_dir_all(&dir); +}