Issue #345: ask before quitting while tasks run

Quitting while an import, an export or another task that can be
cancelled is running asks first: Quit Anyway cancels them (then any
unsaved changes still get their own question), Cancel keeps working.
Tasks without a cross (the face scan, AI Denoise) pick up again on the
next launch and don't ask.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Andrey VyrodovandClaude Opus 5.5 committed 2026-10-10 08:47:58 +02:00
1 parent af9b1063ca
commit 98f65ff0d7
24 files changed
+135 -31

No files matched your search

+3 -1
View File
@@ -225,5 +225,7 @@
"{done} of {total}": "{done} von {total}",
"{done} of {total} MB": "{done} von {total} MB",
"{pct} %": "{pct} %",
"+{n} more": "+{n} weitere"
"+{n} more": "+{n} weitere",
"1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab."
}
+2 -1
View File
@@ -2017,5 +2017,6 @@
"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",
"Show less": "Weniger anzeigen"
"Show less": "Weniger anzeigen",
"Quit while tasks are running?": "Beenden, während Aufgaben laufen?"
}
+3 -1
View File
@@ -225,5 +225,7 @@
"{done} of {total}": "{done} de {total}",
"{done} of {total} MB": "{done} de {total} MB",
"{pct} %": "{pct} %",
"+{n} more": "+{n} más"
"+{n} more": "+{n} más",
"1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene."
}
+2 -1
View File
@@ -1862,5 +1862,6 @@
"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",
"Show less": "Mostrar menos"
"Show less": "Mostrar menos",
"Quit while tasks are running?": "¿Salir con tareas en curso?"
}
+3 -1
View File
@@ -225,5 +225,7 @@
"{done} of {total}": "{done} sur {total}",
"{done} of {total} MB": "{done} sur {total} Mo",
"{pct} %": "{pct} %",
"+{n} more": "+{n} autres"
"+{n} more": "+{n} autres",
"1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête."
}
+2 -1
View File
@@ -1862,5 +1862,6 @@
"Not saved": "Non enregistré",
"A folder is being synchronized": "Un dossier est en cours de synchronisation",
"Wait for the import to finish": "Attendez la fin de l'importation",
"Show less": "Afficher moins"
"Show less": "Afficher moins",
"Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?"
}
+3 -1
View File
@@ -225,5 +225,7 @@
"{done} of {total}": "{total}件中{done}件",
"{done} of {total} MB": "{done} / {total} MB",
"{pct} %": "{pct}%",
"+{n} more": "ほか{n}件"
"+{n} more": "ほか{n}件",
"1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。",
"{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。"
}
+2 -1
View File
@@ -1864,5 +1864,6 @@
"Not saved": "保存されていません",
"A folder is being synchronized": "フォルダを同期中です",
"Wait for the import to finish": "読み込みが終わるまでお待ちください",
"Show less": "表示を減らす"
"Show less": "表示を減らす",
"Quit while tasks are running?": "タスクの実行中に終了しますか?"
}
+3 -1
View File
@@ -225,5 +225,7 @@
"{done} of {total}": "{done} de {total}",
"{done} of {total} MB": "{done} de {total} MB",
"{pct} %": "{pct}%",
"+{n} more": "+{n} mais"
"+{n} more": "+{n} mais",
"1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe."
}
+2 -1
View File
@@ -1862,5 +1862,6 @@
"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",
"Show less": "Mostrar menos"
"Show less": "Mostrar menos",
"Quit while tasks are running?": "Sair com tarefas em andamento?"
}
+3 -1
View File
@@ -225,5 +225,7 @@
"{done} of {total}": "{done} из {total}",
"{done} of {total} MB": "{done} из {total} МБ",
"{pct} %": "{pct} %",
"+{n} more": "ещё {n}"
"+{n} more": "ещё {n}",
"1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.",
"{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их."
}
+2 -1
View File
@@ -1575,5 +1575,6 @@
"Not saved": "Не сохранено",
"A folder is being synchronized": "Идёт синхронизация папки",
"Wait for the import to finish": "Дождитесь окончания импорта",
"Show less": "Свернуть"
"Show less": "Свернуть",
"Quit while tasks are running?": "Выйти, пока выполняются задачи?"
}
+3 -1
View File
@@ -225,5 +225,7 @@
"{done} of {total}": "{done} з {total}",
"{done} of {total} MB": "{done} з {total} МБ",
"{pct} %": "{pct} %",
"+{n} more": "+{n} ще"
"+{n} more": "+{n} ще",
"1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.",
"{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх."
}
+2 -1
View File
@@ -2056,5 +2056,6 @@
"Not saved": "Не збережено",
"A folder is being synchronized": "Тека синхронізується",
"Wait for the import to finish": "Дочекайтеся завершення імпорту",
"Show less": "Показати менше"
"Show less": "Показати менше",
"Quit while tasks are running?": "Вийти, поки виконуються завдання?"
}
+3 -1
View File
@@ -225,5 +225,7 @@
"{done} of {total}": "{done}/{total}",
"{done} of {total} MB": "{done}/{total} MB",
"{pct} %": "{pct}%",
"+{n} more": "还有 {n} 项"
"+{n} more": "还有 {n} 项",
"1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。"
}
+2 -1
View File
@@ -1862,5 +1862,6 @@
"Not saved": "未保存",
"A folder is being synchronized": "正在同步文件夹",
"Wait for the import to finish": "请等待导入完成",
"Show less": "收起"
"Show less": "收起",
"Quit while tasks are running?": "要在任务运行时退出吗?"
}
+3 -1
View File
@@ -225,5 +225,7 @@
"{done} of {total}": "{done}/{total}",
"{done} of {total} MB": "{done}/{total} MB",
"{pct} %": "{pct}%",
"+{n} more": "還有 {n} 項"
"+{n} more": "還有 {n} 項",
"1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。"
}
+2 -1
View File
@@ -1862,5 +1862,6 @@
"Not saved": "未儲存",
"A folder is being synchronized": "正在同步資料夾",
"Wait for the import to finish": "請等待匯入完成",
"Show less": "收合"
"Show less": "收合",
"Quit while tasks are running?": "要在工作執行時結束嗎?"
}
+1 -1
View File
@@ -105,7 +105,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"status": app.ui.status,
"copied": app.copied,
"notices": app.notices,
"quitPrompt": app.quit_prompt,
"quitPrompt": app.quit_prompt.as_ref().map(crate::QuitPrompt::text),
// how hard the background face scan may work right now, and why it is judged so (see `panels::faces::scan_pace`)
"faceScan": {"pace": app.caches.faces_pace, "focused": app.caches.faces_in_front, "pending": app.caches.faces_pending, "indexed": app.caches.faces_indexed},
"unsaved": app.session.unsaved().map(|(n, e)| json!({"ops": n, "error": e})),
+20 -2
View File
@@ -191,6 +191,24 @@ pub struct Perf {
pub fps: f64,
}
/// Why quitting stopped to ask first (`panels::notices`).
#[derive(Clone, Debug, PartialEq)]
pub enum QuitPrompt {
/// Changes couldn't be written to disk (issue #103).
Unsaved(String),
/// Tasks that quitting would cut short are running: an import, an export… (issue #345).
Tasks(String),
}
impl QuitPrompt {
/// The prompt's message.
pub fn text(&self) -> &str {
match self {
QuitPrompt::Unsaved(text) | QuitPrompt::Tasks(text) => text,
}
}
}
pub struct LightcraftApp {
pub(crate) model_setup: model_setup::Pending,
/// Per-catalog-revision caches of library-wide results the panels show every frame
@@ -215,8 +233,8 @@ pub struct LightcraftApp {
pub headless_host: bool,
/// Warnings to show one at a time (damaged settings files…, issue #103).
pub notices: Vec<String>,
/// Quitting was stopped because changes couldn't be saved: the prompt's text.
pub quit_prompt: Option<String>,
/// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running.
pub quit_prompt: Option<QuitPrompt>,
/// Quit Anyway was chosen: the window may close with unsaved changes.
pub quit_confirmed: bool,
control_rx: Option<Receiver<ControlRequest>>,
+36 -8
View File
@@ -1,12 +1,12 @@
//! Things the user must not miss (issue #103): settings files that were damaged or unreadable when
//! the library opened (and host warnings such as a damaged `ui.json`), and quitting while changes
//! are only in memory.
//! are only in memory or while tasks that would be cut short are running (issue #345).
use egui::{Align2, RichText, vec2};
use crate::LightcraftApp;
use crate::theme::Tokens;
use crate::widgets::register;
use crate::{LightcraftApp, QuitPrompt};
/// Collect the library's settings-file warnings (each is shown once).
pub fn logic(app: &mut LightcraftApp) {
@@ -14,10 +14,25 @@ pub fn logic(app: &mut LightcraftApp) {
app.notices.extend(new);
}
/// The window may close now (`true`), or the quit prompt is shown (`false`): changes that
/// couldn't be written are retried first — the log, then a snapshot of everything in memory.
/// The window may close now (`true`), or the quit prompt is shown (`false`). Running tasks that
/// can be cancelled (an import, an export…) are asked about first; then changes that couldn't be
/// written are retried — the log, then a snapshot of everything in memory.
pub fn may_close(app: &mut LightcraftApp) -> bool {
if app.quit_confirmed || app.session.unsaved().is_none() {
if app.quit_confirmed {
return true;
}
// before the library is touched: a Cancel here keeps working with it
let running = app.session.activity.running_cancellable();
if !running.is_empty() {
let names = running.iter().map(|t| crate::i18n::tr(&t.label)).collect::<Vec<_>>().join(", ");
app.quit_prompt = Some(QuitPrompt::Tasks(if running.len() == 1 {
crate::i18n::tr_format!("1 task is still running: {names}. Quitting stops it.", names = names)
} else {
crate::i18n::tr_format!("{n} tasks are still running: {names}. Quitting stops them.", n = running.len(), names = names)
}));
return false;
}
if app.session.unsaved().is_none() {
return true;
}
let _ = app.session.persist();
@@ -29,13 +44,13 @@ pub fn may_close(app: &mut LightcraftApp) -> bool {
match app.session.unsaved() {
None => true,
Some((n, e)) => {
app.quit_prompt = Some(crate::i18n::tr_format!(
app.quit_prompt = Some(QuitPrompt::Unsaved(crate::i18n::tr_format!(
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.",
if n == 1 { "" } else { "s" },
if n == 1 { "it is" } else { "they are" },
n = n,
e = e
));
)));
false
}
}
@@ -85,7 +100,20 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s
}
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(text) = app.quit_prompt.clone() {
if let Some(QuitPrompt::Tasks(text)) = app.quit_prompt.clone() {
let buttons = [("quitAnyway", "Quit Anyway", Choice::QuitAnyway), ("quitCancel", "Cancel", Choice::Cancel)];
let Some(i) = window(ctx, "quit-tasks", "Quit while tasks are running?", &text, &buttons) else { return };
app.quit_prompt = None;
if matches!(buttons[i].2, Choice::QuitAnyway) {
app.session.activity.cancel_all();
// unsaved changes still get their own question
if may_close(app) {
app.ui.quit = true;
}
}
return;
}
if let Some(QuitPrompt::Unsaved(text)) = app.quit_prompt.clone() {
let buttons = [
("quitRetry", "Try Saving Again", Choice::Retry),
("quitAnyway", "Quit Anyway", Choice::QuitAnyway),
+31
View File
@@ -130,3 +130,34 @@ fn long_detail_stays_inside_the_stack() {
let row = rect(&h, &format!("activity:row:{}", g.id()));
assert!(row.width() <= WIDTH + 1.0, "{row:?}");
}
#[test]
fn quit_with_a_running_task_asks_and_quit_anyway_cancels() {
let mut h = demo();
let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes);
wait_visible(&mut h);
assert!(!crate::panels::notices::may_close(&mut h.app));
assert!(matches!(h.app.quit_prompt, Some(crate::QuitPrompt::Tasks(_))), "{:?}", h.app.quit_prompt);
h.step();
assert!(!has(&h, "button:quitRetry"));
click(&mut h, "button:quitAnyway");
assert!(g.is_cancelled() && h.quit_requested());
}
#[test]
fn quit_cancel_keeps_the_task_running() {
let mut h = demo();
let g = h.app.session.activity.start("import", "Importing", Cancel::Yes);
assert!(!crate::panels::notices::may_close(&mut h.app));
click(&mut h, "button:quitCancel");
assert!(!g.is_cancelled() && h.app.quit_prompt.is_none() && !h.app.quit_confirmed);
}
#[test]
fn cancelling_task_does_not_block_quit() {
let mut h = demo();
let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes);
h.app.session.activity.cancel(g.id()).unwrap();
let _f = h.app.session.activity.start("faces", "Finding faces", Cancel::No);
assert!(crate::panels::notices::may_close(&mut h.app));
}
+1 -1
View File
@@ -79,7 +79,7 @@ fn quitting_with_unsaved_changes_asks_first() {
assert_eq!(r["ok"], false, "{r}");
assert!(!crate::panels::notices::may_close(&mut h.app), "retried, still failing: ask");
h.step();
let prompt = h.app.quit_prompt.clone().unwrap();
let prompt = h.app.quit_prompt.clone().unwrap().text().to_string();
assert!(prompt.contains("1 change couldn't be written to disk") && prompt.contains("drive unplugged"), "{prompt}");
for b in ["button:quitRetry", "button:quitAnyway", "button:quitCancel"] {
assert!(has(&h, b), "{b}");
+1 -1
View File
@@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled |
| `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) |
| `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |
| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |
| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) |
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |