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:
parent
dbf570c711
commit
10e097abf6
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "フォルダーを読み込み中"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Чтение папки"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Читання папки"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "正在读取文件夹"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "正在讀取檔案夾"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue