Issue #345: import progress in the activity stack

An import (and Auto Import, and reading a folder for Local) registers in
the activity stack with the files done of the total; its cross and
activity.cancel set the import's cancel flag, so nothing new starts and
the result still reads "Import cancelled · N added" as one undo step.
The separate Importing window is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Vyrodov 2026-10-09 23:28:22 +02:00
parent dbf570c711
commit 10e097abf6
13 changed files with 83 additions and 49 deletions

View File

@ -2019,5 +2019,6 @@
"Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist",
"Show less": "Weniger anzeigen",
"Quit while tasks are running?": "Beenden, während Aufgaben laufen?",
"Exporting contact sheet": "Kontaktabzug wird exportiert"
"Exporting contact sheet": "Kontaktabzug wird exportiert",
"Reading folder": "Ordner wird gelesen"
}

View File

@ -1864,5 +1864,6 @@
"Wait for the import to finish": "Espera a que termine la importación",
"Show less": "Mostrar menos",
"Quit while tasks are running?": "¿Salir con tareas en curso?",
"Exporting contact sheet": "Exportando hoja de contactos"
"Exporting contact sheet": "Exportando hoja de contactos",
"Reading folder": "Leyendo carpeta"
}

View File

@ -1864,5 +1864,6 @@
"Wait for the import to finish": "Attendez la fin de l'importation",
"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"
"Exporting contact sheet": "Exportation de la planche-contact",
"Reading folder": "Lecture du dossier"
}

View File

@ -1866,5 +1866,6 @@
"Wait for the import to finish": "読み込みが終わるまでお待ちください",
"Show less": "表示を減らす",
"Quit while tasks are running?": "タスクの実行中に終了しますか?",
"Exporting contact sheet": "コンタクトシートを書き出し中"
"Exporting contact sheet": "コンタクトシートを書き出し中",
"Reading folder": "フォルダーを読み込み中"
}

View File

@ -1864,5 +1864,6 @@
"Wait for the import to finish": "Aguarde a importação terminar",
"Show less": "Mostrar menos",
"Quit while tasks are running?": "Sair com tarefas em andamento?",
"Exporting contact sheet": "Exportando folha de contato"
"Exporting contact sheet": "Exportando folha de contato",
"Reading folder": "Lendo pasta"
}

View File

@ -1577,5 +1577,6 @@
"Wait for the import to finish": "Дождитесь окончания импорта",
"Show less": "Свернуть",
"Quit while tasks are running?": "Выйти, пока выполняются задачи?",
"Exporting contact sheet": "Экспорт контрольного листа"
"Exporting contact sheet": "Экспорт контрольного листа",
"Reading folder": "Чтение папки"
}

View File

@ -2058,5 +2058,6 @@
"Wait for the import to finish": "Дочекайтеся завершення імпорту",
"Show less": "Показати менше",
"Quit while tasks are running?": "Вийти, поки виконуються завдання?",
"Exporting contact sheet": "Експорт аркуша мініатюр"
"Exporting contact sheet": "Експорт аркуша мініатюр",
"Reading folder": "Читання папки"
}

View File

@ -1864,5 +1864,6 @@
"Wait for the import to finish": "请等待导入完成",
"Show less": "收起",
"Quit while tasks are running?": "要在任务运行时退出吗?",
"Exporting contact sheet": "正在导出联系表"
"Exporting contact sheet": "正在导出联系表",
"Reading folder": "正在读取文件夹"
}

View File

@ -1864,5 +1864,6 @@
"Wait for the import to finish": "請等待匯入完成",
"Show less": "收合",
"Quit while tasks are running?": "要在工作執行時結束嗎?",
"Exporting contact sheet": "正在匯出聯絡表"
"Exporting contact sheet": "正在匯出聯絡表",
"Reading folder": "正在讀取檔案夾"
}

View File

@ -5,7 +5,8 @@
//! chosen folder, filed by day, by month, into one folder or by a custom folder template,
//! optionally renamed), an album (existing or new), a preset and keywords to apply. Importing
//! runs on a worker thread (files are probed, copied or moved there) and its batches join the
//! catalog between frames, with a progress window and Cancel; the whole import is one undo step.
//! catalog between frames, with a row and ✕ in the activity stack (issue #345); the whole import is
//! one undo step.
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use lightcraft_engine::import::{ImportCandidate, ScanInput, ScanOutput, ScanProgress, scan_with};
@ -235,6 +236,8 @@ pub struct ImportTask {
/// Auto Import: the selection to keep.
keep_selection: Option<lightcraft_engine::Selection>,
run: Option<ImportRun>,
/// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag).
guard: Option<lightcraft_engine::activity::TaskGuard>,
}
impl ImportTask {
@ -648,7 +651,11 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
task.keep_selection = Some(app.session.selection.clone());
}
match started {
Ok(run) => task.run = Some(run),
Ok(run) => {
let label = if task.browse { "Reading folder" } else { "Importing" };
task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone())));
task.run = Some(run);
}
Err(e) => {
log::warn!("import: {e}");
app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e));
@ -671,6 +678,13 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
task.total = run.total.load(Ordering::Relaxed).max(task.total);
task.done = run.done.load(Ordering::Relaxed);
}
if let Some(guard) = &task.guard {
guard.progress(task.done as u64, task.total as u64);
// ✕ in the stack (or `activity.cancel`): no further files are started
if guard.is_cancelled() {
task.cancelled = true;
}
}
if !finished {
app.import = Some(task);
return;
@ -812,40 +826,6 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64])
true
}
/// The progress window while an import runs, with Cancel (files already copied or added stay;
/// nothing new is started).
pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(task) = &app.import else { return };
let t = Tokens::get(ctx);
let frac = task.done as f32 / task.total.max(1) as f32;
let text = if task.cancelled {
crate::i18n::tr("Stopping…").to_string()
} else if task.browse {
crate::i18n::tr_format!("Reading photos… {} of {}", task.done, task.total)
} else {
crate::i18n::tr_format!("Adding photos… {} of {}", task.done, task.total)
};
let mut cancel = false;
egui::Window::new(crate::i18n::tr("Importing"))
.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(frac).desired_width(320.0));
let r = ui.add_enabled(!task.cancelled, egui::Button::new(crate::i18n::tr("Cancel")));
register(ui.ctx(), "button:importCancel", r.rect);
cancel = r.clicked();
});
if cancel && let Some(task) = app.import.as_mut() {
task.cancelled = true;
if let Some(run) = &task.run {
run.cancel.store(true, Ordering::Relaxed);
}
}
}
/// The dialog body: options, then the candidate grid.
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
let t = Tokens::get(ui.ctx());

View File

@ -1072,7 +1072,6 @@ impl LightcraftApp {
panels::notices::show(self, &ctx);
panels::dialogs::show(self, &ctx);
panels::library_problem::show(self, &ctx);
import::progress(self, &ctx);
sync::progress_window(self, &ctx);
import::scan_progress(self, &ctx);
lightroom_import::progress(self, &ctx);

View File

@ -211,3 +211,45 @@ fn dead_export_worker_leaves_no_row() {
assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none()));
assert!(h.app.session.activity.list().is_empty());
}
/// A folder of `n` stand-in JPEGs and an app whose file probe takes 100 ms each (a slow drive).
fn slow_import(tag: &str, n: usize) -> (Headless, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("lc-activity-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..n {
std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap();
}
let mut s = lightcraft_engine::Session::new();
s.media.file_probe = Some(std::sync::Arc::new(|p: &str| {
std::thread::sleep(Duration::from_millis(100));
Ok(lightcraft_engine::media::ProbeInfo {
width: 60,
height: 40,
format: "JPEG".into(),
content_hash: Some(p.to_string()),
..Default::default()
})
}));
let mut h = Headless::new(LightcraftApp::new(s, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0);
h.step();
(h, dir)
}
#[test]
fn import_cancelled_by_command_reports_cancelled() {
let n = 40;
let (mut h, dir) = slow_import("cancel", n);
let undo0 = h.app.session.undo.len();
crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();
assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.as_ref().is_some_and(|t| t.imported >= 2)));
let tasks = h.app.session.activity.list();
assert_eq!(tasks.first().map(|t| t.kind), Some("import"), "{tasks:?}");
h.app.session.activity.cancel(tasks[0].id).unwrap();
assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none()));
assert!(h.app.session.activity.list().is_empty());
assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("Import cancelled")), "{:?}", h.app.ui.toast);
assert!(h.app.session.catalog.len() < n, "stopped part-way");
assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step");
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -123,7 +123,8 @@ fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() {
let dir = std::env::temp_dir().join(format!("lc-ui-slow-import-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let n = 40;
// enough files that the import is still running when its activity row shows (after 0.5 s)
let n = 120;
for i in 0..n {
std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap();
}
@ -163,8 +164,11 @@ fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() {
assert!(h.app.import.is_some(), "still importing");
let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default();
assert!(status["total"].as_u64().unwrap_or(0) >= 8, "{status}");
// Cancel: the files being read finish, nothing new starts
let r = h.request("ui.clickWidget", json!({"id": "button:importCancel"}), Duration::from_secs(10));
// Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts
let id = h.app.session.activity.list().first().map(|t| t.id).expect("an import row");
let cross = format!("activity:cancel:{id}");
assert!(h.step_until(Duration::from_secs(10), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows");
let r = h.request("ui.clickWidget", json!({"id": cross}), Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
let t0 = Instant::now();
while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(60) {