Issue #345: folder scan in the activity stack

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 <[email protected]>
This commit is contained in:
Andrey VyrodovandClaude Opus 5.5 committed 2026-10-10 08:49:34 +02:00
1 parent 63d9b70356
commit 1af0dd4fe3
14 files changed
+70 -47

No files matched your search

+2 -1
View File
@@ -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<std::sync::atomic::AtomicBool>,
}
/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows.
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -1867,5 +1867,6 @@
"Show less": "表示を減らす",
"Quit while tasks are running?": "タスクの実行中に終了しますか?",
"Exporting contact sheet": "コンタクトシートを書き出し中",
"Reading folder": "フォルダーを読み込み中"
"Reading folder": "フォルダーを読み込み中",
"Scanning folder": "フォルダーをスキャン中"
}
+2 -1
View File
@@ -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"
}
+2 -1
View File
@@ -1578,5 +1578,6 @@
"Show less": "Свернуть",
"Quit while tasks are running?": "Выйти, пока выполняются задачи?",
"Exporting contact sheet": "Экспорт контрольного листа",
"Reading folder": "Чтение папки"
"Reading folder": "Чтение папки",
"Scanning folder": "Сканирование папки"
}
+2 -1
View File
@@ -2059,5 +2059,6 @@
"Show less": "Показати менше",
"Quit while tasks are running?": "Вийти, поки виконуються завдання?",
"Exporting contact sheet": "Експорт аркуша мініатюр",
"Reading folder": "Читання папки"
"Reading folder": "Читання папки",
"Scanning folder": "Сканування папки"
}
+2 -1
View File
@@ -1865,5 +1865,6 @@
"Show less": "收起",
"Quit while tasks are running?": "要在任务运行时退出吗?",
"Exporting contact sheet": "正在导出联系表",
"Reading folder": "正在读取文件夹"
"Reading folder": "正在读取文件夹",
"Scanning folder": "正在扫描文件夹"
}
+2 -1
View File
@@ -1865,5 +1865,6 @@
"Show less": "收合",
"Quit while tasks are running?": "要在工作執行時結束嗎?",
"Exporting contact sheet": "正在匯出聯絡表",
"Reading folder": "正在讀取檔案夾"
"Reading folder": "正在讀取檔案夾",
"Scanning folder": "正在掃描檔案夾"
}
+7 -2
View File
@@ -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");
+24 -34
View File
@@ -394,6 +394,14 @@ pub struct ScanTask {
browse: bool,
/// What is being scanned (the review's source).
sources: Vec<String>,
/// 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<String>) -> Result<Value, String
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false, sources });
let guard = scan_guard(app, &progress, false);
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false, sources, guard });
Ok(json!({"scanning": true}))
}
@@ -480,7 +489,8 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) ->
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 {
-1
View File
@@ -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);
+19
View File
@@ -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);
}