LRC-LIB-LIBFOLDERS: Synchronize Folder's scan can continue in the background

The scan already ran on a worker thread, but its dialog was modal and closing
it stopped the scan, so a large folder on a network share held the app for
minutes. While the folder is scanned the dialog's button is now Continue in
Background: the dialog closes and the scan goes on under its activity row.

When it is done nothing pops up. A notice in the activity stack says how many
changes were found (or that the folder isn't there); a click opens the dialog
with that result and what was ticked, without scanning again, and ✕ lets go of
it. A scan that found nothing to do only says so in a toast. Cancel and the
row's ✕ stop the scan as before.

One folder at a time: Synchronize Folder on another folder is refused while a
background scan runs or its notice waits, so minutes of scanning are not
thrown away unasked. Another library opened lets go of the scan. A result
looked at later can be stale: Synchronize already refuses a scan whose
folder's photos changed meanwhile and scans again.

The dialog's one button reads Continue in Background and then Synchronize, so
Synchronize is unavailable for 0.6 s after the scan ends: a click meant for
the first can't start the second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-11 16:46:10 -04:00
co-authored by Claude Opus 5.5
parent 76b5377868
commit 738a49aa47
25 changed files with 625 additions and 41 deletions
+4 -1
View File
@@ -244,5 +244,8 @@
"not added to Apple Photos: {e}": "nicht zu Apple Fotos hinzugefügt: {e}",
"{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt",
"{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt",
"{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt"
"{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt",
"Changes found: {n}": "Gefundene Änderungen: {n}",
"“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "„{name}“ wird noch durchsucht: Warten Sie darauf oder beenden Sie die Suche mit ✕ in ihrer Zeile",
"Look at the changes found in “{name}” first, or dismiss them": "Sehen Sie sich zuerst die in „{name}“ gefundenen Änderungen an oder verwerfen Sie sie"
}
+7 -1
View File
@@ -2109,5 +2109,11 @@
"Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…",
"not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)",
"The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.",
"Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt"
"Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt",
"Continue in Background": "Im Hintergrund fortsetzen",
"Folder not found": "Ordner nicht gefunden",
"Dismiss": "Verwerfen",
"Review…": "Ansehen…",
"Open Synchronize Folder with what the scan found": "„Ordner synchronisieren“ mit dem Ergebnis der Suche öffnen",
"You can go on working: a notice at the top left says when the scan is done.": "Sie können weiterarbeiten: Ein Hinweis oben links meldet, wenn die Suche abgeschlossen ist."
}
+4 -1
View File
@@ -244,5 +244,8 @@
"not added to Apple Photos: {e}": "no se añadieron a Fotos de Apple: {e}",
"{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple",
"{n} added to Apple Photos": "{n} añadidas a Fotos de Apple",
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original"
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original",
"Changes found: {n}": "Cambios encontrados: {n}",
"“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "“{name}” aún se está analizando: espera a que termine o detenlo con ✕ en su fila",
"Look at the changes found in “{name}” first, or dismiss them": "Revisa primero los cambios encontrados en “{name}” o descártalos"
}
+7 -1
View File
@@ -1951,5 +1951,11 @@
"Adding to Apple Photos…": "Añadiendo a Fotos de Apple…",
"not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)",
"The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.",
"Adding to Apple Photos": "Añadiendo a Fotos de Apple"
"Adding to Apple Photos": "Añadiendo a Fotos de Apple",
"Continue in Background": "Continuar en segundo plano",
"Folder not found": "Carpeta no encontrada",
"Dismiss": "Descartar",
"Review…": "Revisar…",
"Open Synchronize Folder with what the scan found": "Abrir Sincronizar carpeta con lo que encontró la búsqueda",
"You can go on working: a notice at the top left says when the scan is done.": "Puedes seguir trabajando: un aviso arriba a la izquierda indica cuándo termina la búsqueda."
}
+4 -1
View File
@@ -244,5 +244,8 @@
"not added to Apple Photos: {e}": "non ajouté à Apple Photos : {e}",
"{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »",
"{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos",
"{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original"
"{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original",
"Changes found: {n}": "Modifications trouvées : {n}",
"“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "« {name} » est encore en cours d'analyse : attendez la fin ou arrêtez-la avec ✕ sur sa ligne",
"Look at the changes found in “{name}” first, or dismiss them": "Examinez d'abord les modifications trouvées dans « {name} », ou ignorez-les"
}
+7 -1
View File
@@ -1951,5 +1951,11 @@
"Adding to Apple Photos…": "Ajout à Apple Photos…",
"not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)",
"The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.",
"Adding to Apple Photos": "Ajout à Apple Photos"
"Adding to Apple Photos": "Ajout à Apple Photos",
"Continue in Background": "Continuer en arrière-plan",
"Folder not found": "Dossier introuvable",
"Dismiss": "Ignorer",
"Review…": "Examiner…",
"Open Synchronize Folder with what the scan found": "Ouvrir Synchroniser le dossier avec ce que l'analyse a trouvé",
"You can go on working: a notice at the top left says when the scan is done.": "Vous pouvez continuer à travailler : un avis en haut à gauche signale la fin de l'analyse."
}
+4 -1
View File
@@ -244,5 +244,8 @@
"not added to Apple Photos: {e}": "Appleの写真に追加されませんでした:{e}",
"{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました",
"{n} added to Apple Photos": "{n}件をAppleの写真に追加しました",
"{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています"
"{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています",
"Changes found: {n}": "見つかった変更: {n}",
"“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "「{name}」はまだスキャン中です。終わるまで待つか、その行の ✕ で停止してください",
"Look at the changes found in “{name}” first, or dismiss them": "先に「{name}」で見つかった変更を確認するか、閉じてください"
}
+7 -1
View File
@@ -1953,5 +1953,11 @@
"Adding to Apple Photos…": "Appleの写真に追加中…",
"not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)",
"The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。",
"Adding to Apple Photos": "Appleの写真に追加中"
"Adding to Apple Photos": "Appleの写真に追加中",
"Continue in Background": "バックグラウンドで続行",
"Folder not found": "フォルダが見つかりません",
"Dismiss": "閉じる",
"Review…": "確認…",
"Open Synchronize Folder with what the scan found": "スキャンの結果を「フォルダを同期」で開く",
"You can go on working: a notice at the top left says when the scan is done.": "作業を続けられます。スキャンが終わると左上に通知が表示されます。"
}
+4 -1
View File
@@ -244,5 +244,8 @@
"not added to Apple Photos: {e}": "não adicionadas ao Fotos da Apple: {e}",
"{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple",
"{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple",
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original"
"{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original",
"Changes found: {n}": "Alterações encontradas: {n}",
"“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "“{name}” ainda está sendo verificada: aguarde ou interrompa com ✕ na linha dela",
"Look at the changes found in “{name}” first, or dismiss them": "Revise primeiro as alterações encontradas em “{name}” ou dispense-as"
}
+7 -1
View File
@@ -1951,5 +1951,11 @@
"Adding to Apple Photos…": "Adicionando ao Fotos da Apple…",
"not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)",
"The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.",
"Adding to Apple Photos": "Adicionando ao Fotos da Apple"
"Adding to Apple Photos": "Adicionando ao Fotos da Apple",
"Continue in Background": "Continuar em segundo plano",
"Folder not found": "Pasta não encontrada",
"Dismiss": "Dispensar",
"Review…": "Revisar…",
"Open Synchronize Folder with what the scan found": "Abrir Sincronizar pasta com o que a verificação encontrou",
"You can go on working: a notice at the top left says when the scan is done.": "Você pode continuar trabalhando: um aviso no canto superior esquerdo informa quando a verificação terminar."
}
+4 -1
View File
@@ -244,5 +244,8 @@
"not added to Apple Photos: {e}": "не добавлено в Apple Фото: {e}",
"{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}",
"{n} added to Apple Photos": "Добавлено в Apple Фото: {n}",
"{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом"
"{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом",
"Changes found: {n}": "Найдено изменений: {n}",
"“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "«{name}» ещё просматривается: дождитесь окончания или остановите поиск кнопкой ✕ в его строке",
"Look at the changes found in “{name}” first, or dismiss them": "Сначала просмотрите изменения, найденные в «{name}», или скройте их"
}
+7 -1
View File
@@ -2143,5 +2143,11 @@
"Adding to Apple Photos…": "Добавление в Apple Фото…",
"not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)",
"The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».",
"Adding to Apple Photos": "Добавление в Apple Фото"
"Adding to Apple Photos": "Добавление в Apple Фото",
"Continue in Background": "Продолжить в фоне",
"Folder not found": "Папка не найдена",
"Dismiss": "Скрыть",
"Review…": "Просмотреть…",
"Open Synchronize Folder with what the scan found": "Открыть «Синхронизировать папку» с результатами поиска",
"You can go on working: a notice at the top left says when the scan is done.": "Можно продолжать работу: уведомление слева вверху сообщит, когда поиск завершится."
}
+4 -1
View File
@@ -244,5 +244,8 @@
"not added to Apple Photos: {e}": "не додано до Apple Фото: {e}",
"{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}",
"{n} added to Apple Photos": "Додано до Apple Фото: {n}",
"{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом"
"{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом",
"Changes found: {n}": "Знайдено змін: {n}",
"“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "«{name}» ще переглядається: дочекайтеся завершення або зупиніть пошук кнопкою ✕ у його рядку",
"Look at the changes found in “{name}” first, or dismiss them": "Спершу перегляньте зміни, знайдені в «{name}», або сховайте їх"
}
+7 -1
View File
@@ -2145,5 +2145,11 @@
"Adding to Apple Photos…": "Додавання до Apple Фото…",
"not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)",
"The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».",
"Adding to Apple Photos": "Додавання до Apple Фото"
"Adding to Apple Photos": "Додавання до Apple Фото",
"Continue in Background": "Продовжити у фоні",
"Folder not found": "Теку не знайдено",
"Dismiss": "Сховати",
"Review…": "Переглянути…",
"Open Synchronize Folder with what the scan found": "Відкрити «Синхронізувати теку» з результатами пошуку",
"You can go on working: a notice at the top left says when the scan is done.": "Можна працювати далі: сповіщення вгорі ліворуч повідомить, коли пошук завершиться."
}
+4 -1
View File
@@ -244,5 +244,8 @@
"not added to Apple Photos: {e}": "未添加到 Apple 照片:{e}",
"{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”",
"{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片",
"{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起"
"{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起",
"Changes found: {n}": "发现的更改:{n}",
"“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "“{name}”仍在扫描:请等待完成,或用其所在行的 ✕ 停止",
"Look at the changes found in “{name}” first, or dismiss them": "请先查看在“{name}”中发现的更改,或将其忽略"
}
+7 -1
View File
@@ -1989,5 +1989,11 @@
"Adding to Apple Photos…": "正在添加到 Apple 照片…",
"not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)",
"The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。",
"Adding to Apple Photos": "正在添加到 Apple 照片"
"Adding to Apple Photos": "正在添加到 Apple 照片",
"Continue in Background": "在后台继续",
"Folder not found": "未找到文件夹",
"Dismiss": "忽略",
"Review…": "查看…",
"Open Synchronize Folder with what the scan found": "用扫描结果打开“同步文件夹”",
"You can go on working: a notice at the top left says when the scan is done.": "你可以继续工作:扫描完成时左上角会出现通知。"
}
+4 -1
View File
@@ -244,5 +244,8 @@
"not added to Apple Photos: {e}": "未加入 Apple 照片:{e}",
"{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」",
"{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片",
"{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起"
"{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起",
"Changes found: {n}": "找到的變更:{n}",
"“{name}” is still being scanned: wait for it, or stop it with ✕ on its row": "「{name}」仍在掃描:請等待完成,或用其所在列的 ✕ 停止",
"Look at the changes found in “{name}” first, or dismiss them": "請先檢視在「{name}」中找到的變更,或將其忽略"
}
+7 -1
View File
@@ -1951,5 +1951,11 @@
"Adding to Apple Photos…": "正在加入 Apple 照片…",
"not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)",
"The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。",
"Adding to Apple Photos": "正在加入 Apple 照片"
"Adding to Apple Photos": "正在加入 Apple 照片",
"Continue in Background": "在背景繼續",
"Folder not found": "找不到資料夾",
"Dismiss": "忽略",
"Review…": "檢視…",
"Open Synchronize Folder with what the scan found": "以掃描結果開啟「同步資料夾」",
"You can go on working: a notice at the top left says when the scan is done.": "你可以繼續工作:掃描完成時左上角會出現通知。"
}
+7
View File
@@ -356,6 +356,10 @@ pub struct LightcraftApp {
pub sync: Option<sync::SyncTask>,
/// `session.folder_changes` came from the Synchronize Folder dialog (and goes with it).
pub sync_owns_changes: bool,
/// When (app time, s) the open Synchronize Folder dialog got its scan ([`sync::settled`]).
pub sync_scanned_at: f64,
/// A Synchronize Folder scan that finished in the background and waits to be looked at.
pub sync_ready: Option<sync::SyncReady>,
/// A Synchronize Folder at work in the background.
pub sync_run: Option<sync::SyncRun>,
/// A Lightroom catalog inspect/import in progress.
@@ -449,6 +453,8 @@ impl LightcraftApp {
scan: None,
sync: None,
sync_owns_changes: false,
sync_scanned_at: f64::NEG_INFINITY,
sync_ready: None,
sync_run: None,
lightroom: None,
lightroom_last: None,
@@ -1254,6 +1260,7 @@ impl LightcraftApp {
return;
}
self.shown_library = Some(generation);
sync::library_changed(self);
if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) {
let path = l.dir.to_string_lossy().into_owned();
panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path);
+69 -4
View File
@@ -56,14 +56,19 @@ fn detail_line(t: &TaskInfo) -> String {
/// Draw the stack (called every frame, after the panels and dialogs).
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let all = app.session.activity.list();
// a scan that finished in the background and waits to be looked at
let ready = app.sync_ready.clone();
if all.is_empty() {
app.activity_expanded = false;
return;
if ready.is_none() {
return;
}
} else {
// look again soon: a young task becomes due, bars move
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// look again soon: a young task becomes due, bars move
ctx.request_repaint_after(std::time::Duration::from_millis(100));
let tasks: Vec<&TaskInfo> = all.iter().filter(|t| t.age_ms >= SHOW_AFTER_MS).collect();
if tasks.is_empty() {
if tasks.is_empty() && ready.is_none() {
return;
}
let t = Tokens::get(ctx);
@@ -72,6 +77,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
let max_height = (ctx.content_rect().height() - t.top_bar_h - 60.0).max(80.0);
let mut cancel = None;
let mut toggle = false;
let mut notice = None;
let frame = egui::Frame::NONE.fill(t.chrome).stroke(Stroke::new(1.0, t.field_border)).corner_radius(6).inner_margin(Margin::same(MARGIN));
egui::Area::new(egui::Id::new("activity-stack")).order(egui::Order::Middle).fixed_pos(pos2(8.0, t.top_bar_h + 8.0)).show(ctx, |ui| {
// An area lays out in the size it had last frame, and the scroll area would take that as all the room there
@@ -83,6 +89,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.set_width(inner);
ui.spacing_mut().item_spacing.y = 3.0;
egui::ScrollArea::vertical().max_height(max_height).auto_shrink([false, true]).show(ui, |ui| {
if let Some(ready) = &ready {
notice = scan_notice(ui, &t, ready, inner);
if !tasks.is_empty() {
ui.add_space(6.0);
}
}
for (i, task) in tasks.iter().take(shown).enumerate() {
if i > 0 {
ui.add_space(6.0);
@@ -104,6 +116,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
if let Some(id) = cancel {
self::cancel(app, id);
}
match notice {
Some(Notice::Review) => crate::sync::review(app),
Some(Notice::Dismiss) => crate::sync::dismiss(app),
None => {}
}
if toggle {
app.activity_expanded = !app.activity_expanded;
}
@@ -115,6 +132,54 @@ pub(crate) fn cancel(app: &mut LightcraftApp, id: u64) {
let _ = app.session.execute("activity.cancel", &json!({"id": id}));
}
/// What was done with a notice.
enum Notice {
Review,
Dismiss,
}
/// The notice of a Synchronize Folder scan that finished in the background (widgets
/// `activity:scanReady`, its `:review` and `:dismiss`): what it found and in which folder. A
/// click on it opens the dialog with the result; ✕ lets go of it. It stays until one of
/// the two: the person looks when ready.
fn scan_notice(ui: &mut egui::Ui, t: &Tokens, ready: &crate::sync::SyncReady, width: f32) -> Option<Notice> {
let mut dismissed = false;
let title = if ready.counts.offline { tr("Folder not found").to_string() } else { tr_format!("Changes found: {n}", n = ready.changes()) };
let mut reviewed = false;
let r = ui
.vertical(|ui| {
ui.set_width(width);
ui.horizontal(|ui| {
let label_w = width - CROSS - 6.0;
ui.allocate_ui_with_layout(vec2(label_w, 18.0), Layout::left_to_right(Align::Center), |ui| {
ui.set_width(label_w);
ui.add(egui::Label::new(RichText::new(title).font(t.font(12.5)).color(t.text)).truncate());
});
let (rect, resp) = ui.allocate_exact_size(vec2(CROSS, CROSS), Sense::click());
paint(ui.painter(), rect.shrink(2.0), Icon::Close, if resp.hovered() { t.text } else { t.icon });
register(ui.ctx(), "activity:scanReady:dismiss", rect);
dismissed = resp.on_hover_text(tr("Dismiss")).clicked();
});
// (the part below the ✕'s line takes the click, so the two never overlap)
let body = ui
.vertical(|ui| {
ui.set_width(width);
ui.add(egui::Label::new(RichText::new(ready.name.as_str()).font(t.font(11.0)).color(t.text_dim)).truncate());
ui.add(egui::Label::new(RichText::new(tr("Review…")).font(t.font(11.0)).color(t.accent)));
})
.response;
let click = ui.interact(body.rect, egui::Id::new("activity-scan-ready"), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
register(ui.ctx(), "activity:scanReady:review", body.rect);
reviewed = click.on_hover_text(tr("Open Synchronize Folder with what the scan found")).clicked();
})
.response;
register(ui.ctx(), "activity:scanReady", r.rect);
if dismissed {
return Some(Notice::Dismiss);
}
reviewed.then_some(Notice::Review)
}
/// One task: name and ✕, the bar, the detail line. True when ✕ was clicked.
fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool {
let mut clicked = false;
+5 -5
View File
@@ -1216,6 +1216,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::KeywordTag { .. } => "Save",
Dialog::RemoveFolder { .. } => "Remove",
Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…",
// the scan need not be waited for
Dialog::SynchronizeFolder { counts: None, .. } => "Continue in Background",
Dialog::SynchronizeFolder { .. } => "Synchronize",
Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue",
Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "",
@@ -1225,12 +1227,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
_ => "OK",
};
// installing waits for the licence to be accepted
// (and Synchronize waits for the scan)
let can_confirm = informational
|| !matches!(
&dlg,
Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. }
)
|| !matches!(&dlg, Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. })
// (Synchronize, just after its button stopped being Continue in Background)
&& !(matches!(&dlg, Dialog::SynchronizeFolder { counts: Some(_), .. }) && !crate::sync::settled(app, ui.ctx()))
// smart-album rules that can't mean anything wait to be fixed
&& !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty());
let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok))));
+153 -10
View File
@@ -3,6 +3,10 @@
//! changed and what to do about it. Synchronize runs `folder.synchronize`, which acts on exactly
//! the scan the dialog showed (see `lightcraft_engine::sync`). Both the scan and the run show a row in the activity
//! stack (issue #345); its ✕ stops them like the dialog's Cancel.
//!
//! The scan need not be waited for: Continue in Background closes the dialog and the scan goes
//! on under its row. When it is done, a notice in the activity stack says so ([`SyncReady`]);
//! a click on it opens the dialog with what was found, whenever the person is ready for it.
use std::sync::Arc;
use std::sync::atomic::Ordering;
@@ -18,16 +22,34 @@ use crate::theme::Tokens;
/// A running scan of the dialog's folder.
pub struct SyncTask {
path: String,
/// What the dialog calls the folder.
name: String,
disk: bool,
/// The dialog was closed with Continue in Background: the scan goes on without it.
background: bool,
/// What was ticked in the dialog when it was closed that way (a dialog that had scanned
/// before and was scanning again keeps its ticks): the dialog opens with them again.
choice: SyncChoice,
progress: Arc<SyncProgress>,
rx: std::sync::mpsc::Receiver<FolderChanges>,
/// Its row in the activity stack; ✕ cancels the scan and closes the dialog.
guard: TaskGuard,
}
/// Open the dialog for folder `path` (called `name` in it) and start scanning.
/// Open the dialog for folder `path` (called `name` in it) and start scanning. One folder at a
/// time: while another folder is scanned in the background, or what was found in it waits to
/// be looked at, this is refused (minutes of scanning are not thrown away unasked); the same
/// folder is scanned afresh.
pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Result<(), String> {
cancel(app);
if let Some(other) = app.sync.as_ref().filter(|t| t.background && t.path != path) {
return Err(crate::i18n::tr_format!("“{name}” is still being scanned: wait for it, or stop it with ✕ on its row", name = other.name));
}
if let Some(other) = app.sync_ready.as_ref().filter(|r| r.path != path) {
return Err(crate::i18n::tr_format!("Look at the changes found in “{name}” first, or dismiss them", name = other.name));
}
// (a folder that can't be synchronized leaves what there is alone)
let input = SyncInput::new(&mut app.session, path, disk).map_err(|e| e.to_string())?;
cancel(app);
let progress = Arc::new(SyncProgress::default());
let (tx, rx) = std::sync::mpsc::channel();
let p = progress.clone();
@@ -40,7 +62,16 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu
job();
let guard = app.session.activity.start("sync", "Looking for changes", Cancel::Flag(progress.files.cancel.clone()));
guard.detail(name);
app.sync = Some(SyncTask { path: path.to_string(), progress, rx, guard });
app.sync = Some(SyncTask {
path: path.to_string(),
name: name.to_string(),
disk,
background: false,
choice: SyncChoice::default(),
progress,
rx,
guard,
});
let d = SyncChoice::default();
app.ui.dialog = Some(Dialog::SynchronizeFolder {
path: path.to_string(),
@@ -55,12 +86,72 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu
Ok(())
}
/// A scan that finished in the background, waiting to be looked at: the notice in the activity
/// stack. Its changes are `session.folder_changes`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SyncReady {
pub path: String,
/// What the dialog calls the folder.
pub name: String,
pub disk: bool,
pub counts: SyncCounts,
/// What was ticked when the dialog was closed.
pub choice: SyncChoice,
}
impl SyncReady {
/// How many changes there are to look at.
pub fn changes(&self) -> usize {
let c = &self.counts;
c.new + c.moved + c.missing + c.metadata
}
}
/// A click on the notice: open the dialog with what the background scan found.
pub fn review(app: &mut LightcraftApp) {
// (a dialog that is open is not taken away; the notice waits)
if app.ui.dialog.is_some() {
return;
}
let Some(ready) = app.sync_ready.take() else { return };
let d = ready.choice;
app.ui.dialog = Some(Dialog::SynchronizeFolder {
path: ready.path,
name: ready.name,
disk: ready.disk,
counts: Some(ready.counts),
import_new: d.import_new,
relink_moved: d.relink_moved,
remove_missing: d.remove_missing,
read_metadata: d.read_metadata,
});
}
/// ✕ on the notice: what the scan found is let go of.
pub fn dismiss(app: &mut LightcraftApp) {
if app.sync_ready.is_some() {
cancel(app);
}
}
/// Another library was opened: a scan of the one before, running or waiting to be looked at,
/// says nothing about this one.
pub(crate) fn library_changed(app: &mut LightcraftApp) {
if app.sync.is_some() || app.sync_ready.is_some() || app.sync_owns_changes {
cancel(app);
if matches!(app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })) {
app.ui.dialog = None;
}
}
}
/// Stop a running scan and let go of a finished one the dialog made (one an agent made with
/// `folder.scanChanges` is the agent's).
fn cancel(app: &mut LightcraftApp) {
if let Some(t) = app.sync.take() {
t.progress.files.cancel.store(true, Ordering::Relaxed);
}
app.sync_ready = None;
if std::mem::take(&mut app.sync_owns_changes) {
app.session.folder_changes = None;
}
@@ -73,21 +164,25 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
Some(Dialog::SynchronizeFolder { path, .. }) => Some(path.clone()),
_ => None,
};
let Some(open_for) = open_for else {
if app.sync.is_some() || app.sync_owns_changes {
let background = app.sync.as_ref().is_some_and(|t| t.background);
if open_for.is_none() && !background {
// (what a background scan found is kept for its notice)
if app.sync.is_some() || (app.sync_owns_changes && app.sync_ready.is_none()) {
cancel(app);
}
return;
};
}
let Some(task) = app.sync.as_ref() else { return };
if task.path != open_for {
if !background && open_for.as_deref() != Some(task.path.as_str()) {
cancel(app);
return;
}
if task.guard.is_cancelled() {
// ✕ on its row: a scan stopped part-way has nothing to show
cancel(app);
app.ui.dialog = None;
if !background {
app.ui.dialog = None;
}
return;
}
let (done, total) = task.progress.counts();
@@ -98,12 +193,14 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(_) => {
app.sync = None;
app.ui.dialog = None;
if !background {
app.ui.dialog = None;
}
app.toast(ctx, crate::i18n::tr("Scan failed"));
return;
}
};
app.sync = None;
let Some(task) = app.sync.take() else { return };
let c = SyncCounts {
offline: changes.offline,
new: changes.new.len(),
@@ -113,13 +210,38 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
metadata: changes.metadata.len(),
moved: changes.moved.len(),
};
if background {
let ready = SyncReady { path: task.path.clone(), name: task.name.clone(), disk: task.disk, counts: c, choice: task.choice };
if !ready.counts.offline && ready.changes() == 0 {
// nothing to look at: said and done
app.toast(ctx, format!("{}: {}", ready.name, crate::i18n::tr("The library is up to date with this folder.")));
return;
}
app.session.folder_changes = Some(changes);
app.sync_owns_changes = true;
app.sync_ready = Some(ready);
return;
}
app.session.folder_changes = Some(changes);
app.sync_owns_changes = true;
if let Some(Dialog::SynchronizeFolder { counts, .. }) = &mut app.ui.dialog {
*counts = Some(c);
// the button was Continue in Background until now: Synchronize waits a moment
app.sync_scanned_at = ctx.input(|i| i.time);
ctx.request_repaint_after(std::time::Duration::from_secs_f64(SETTLE_SECONDS));
}
}
/// How long Synchronize stays unavailable after the scan ended (s). The dialog's one button
/// reads Continue in Background while the folder is scanned and Synchronize after: a click meant
/// for the first, landing as the scan ends, must not start the second.
pub const SETTLE_SECONDS: f64 = 0.6;
/// Whether the dialog's Synchronize can be pressed yet ([`SETTLE_SECONDS`]).
pub fn settled(app: &LightcraftApp, ctx: &egui::Context) -> bool {
ctx.input(|i| i.time) - app.sync_scanned_at >= SETTLE_SECONDS
}
/// How far the scan is (files probed, of how many).
fn progress(app: &LightcraftApp) -> Option<(usize, usize)> {
app.sync.as_ref().map(|t| t.progress.counts())
@@ -142,6 +264,9 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
_ => ui.label(crate::i18n::tr("Scanning…")),
}
});
ui.label(
egui::RichText::new(crate::i18n::tr("You can go on working: a notice at the top left says when the scan is done.")).color(t.text_dim),
);
return;
};
if c.offline {
@@ -180,6 +305,24 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
/// thread at once ([`SyncRun`]); nothing is read from disk on the UI thread. A scan that went
/// stale (the folder's photos changed meanwhile) is refused and made again, keeping the choices.
pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {
if matches!(dlg, Dialog::SynchronizeFolder { counts: None, .. }) {
// Continue in Background: the scan goes on without the dialog, which the caller closes
return match &mut app.sync {
Some(task) => {
task.background = true;
if let Dialog::SynchronizeFolder { import_new, relink_moved, remove_missing, read_metadata, .. } = dlg {
task.choice = SyncChoice {
import_new: *import_new,
relink_moved: *relink_moved,
remove_missing: *remove_missing,
read_metadata: *read_metadata,
};
}
Ok(json!({"background": true}))
}
None => Err("the folder is still being scanned".into()),
};
}
let Dialog::SynchronizeFolder { path, name, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else {
return Err("the folder is still being scanned".into());
};
+281
View File
@@ -302,3 +302,284 @@ fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() {
h.settle(SETTLE);
assert!(h.app.ui.dialog.is_none(), "a stopped scan opens nothing later");
}
// ---- the scan need not be waited for ----
//
// * While the folder is scanned, the dialog's button is Continue in Background: the dialog
// closes, the scan goes on under its row, and the app is free for other things.
// * When the scan is done nothing pops up: a notice in the activity stack says what was found.
// A click on it opens the dialog with that result; ✕ lets go of it.
// * A scan that found nothing to do only says so.
// * ✕ on the scan's row stops a background scan too, and no notice follows.
/// A gate for the scan of `h`: it can't finish until the returned flag is set (reading the new
/// file waits on it).
fn gated(h: &mut Headless) -> std::sync::Arc<std::sync::atomic::AtomicBool> {
let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let g = gate.clone();
h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| {
while !g.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(5));
}
Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() })
}));
gate
}
/// Open the dialog on `trip` and press Continue in Background while the scan is held.
fn scan_in_background(h: &mut Headless, dir: &Scratch) {
crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap();
let hidden = |h: &Headless| h.app.ui.dialog.is_none();
for _ in 0..5 {
h.step();
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], true, "{r}");
if h.step_until(Duration::from_secs(3), hidden) {
break;
}
}
assert!(h.app.ui.dialog.is_none(), "the dialog closes: {:?}", h.app.ui.dialog);
assert!(h.app.sync.is_some(), "and the scan goes on");
}
fn has_widget(h: &Headless, id: &str) -> bool {
h.app.widgets.iter().any(|(w, _)| w == id)
}
fn click_widget(h: &mut Headless, id: &str, landed: impl Fn(&Headless) -> bool) {
for _ in 0..5 {
h.step();
let r = h.request("ui.clickWidget", json!({"id": id}), T);
assert_eq!(r["ok"], true, "{id}: {r}");
if h.step_until(Duration::from_secs(3), &landed) {
return;
}
}
panic!("the click on {id} never landed: {:?}", h.app.ui.dialog);
}
#[test]
fn a_scan_continued_in_the_background_ends_in_a_notice_that_opens_its_result() {
let dir = Scratch::new("background");
let mut h = changed_folder(&dir);
let gate = gated(&mut h);
scan_in_background(&mut h, &dir);
let rows = h.app.session.activity.list();
assert_eq!((rows.len(), rows[0].label.as_str()), (1, "Looking for changes"), "its row stays: {rows:?}");
// meanwhile the app is free: a photo is rated, frames go by
let id = h.app.session.catalog.photos().find(|p| p.file_name == "a.png").map(|p| p.id.0).unwrap();
assert!(h.app.run("library.select", json!({"ids": [id]})).is_ok());
assert!(h.app.run("photo.rate", json!({"rating": 3})).is_ok());
for _ in 0..5 {
h.step();
}
assert!(h.app.sync.is_some() && h.app.sync_ready.is_none(), "still scanning");
gate.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(h.step_until(T, |h| h.app.sync.is_none()), "the scan ends");
h.step();
h.step();
assert!(h.app.ui.dialog.is_none(), "nothing pops up: {:?}", h.app.ui.dialog);
let ready = h.app.sync_ready.clone().expect("a notice waits");
assert_eq!((ready.counts.new, ready.counts.missing, ready.changes()), (1, 1, 2), "{ready:?}");
assert!(has_widget(&h, "activity:scanReady"), "shown in the activity stack");
assert!(h.app.session.activity.list().is_empty(), "the scan's own row is gone");
// it stays until it is looked at
for _ in 0..20 {
h.step();
}
assert!(h.app.sync_ready.is_some() && h.app.session.folder_changes.is_some());
click_widget(&mut h, "activity:scanReady:review", |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. })));
let c = counts(&h);
assert_eq!((c.new, c.missing), (1, 1), "the dialog shows what the scan found: {c:?}");
assert!(h.app.sync_ready.is_none() && h.app.sync.is_none(), "without scanning again");
// (with the rating undone the folder's photos are as scanned: the scan is still current)
h.app.session.undo_step().unwrap();
synchronize(&mut h);
assert_eq!(in_library(&h), vec!["a.png", "b.png", "c.png"]);
}
#[test]
fn dismissing_the_notice_lets_go_of_what_the_scan_found() {
let dir = Scratch::new("dismiss");
let mut h = changed_folder(&dir);
let gate = gated(&mut h);
scan_in_background(&mut h, &dir);
gate.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(h.step_until(T, |h| h.app.sync_ready.is_some()), "a notice waits");
click_widget(&mut h, "activity:scanReady:dismiss", |h| h.app.sync_ready.is_none());
assert!(h.app.session.folder_changes.is_none(), "the scan is let go of");
assert!(h.app.ui.dialog.is_none());
h.step();
assert!(!has_widget(&h, "activity:scanReady"));
assert_eq!(in_library(&h), vec!["a.png", "b.png"], "nothing was changed");
}
#[test]
fn a_background_scan_that_found_nothing_only_says_so() {
let dir = Scratch::new("background-none");
let mut h = changed_folder(&dir);
// bring the library up to date first
open_and_scan(&mut h, &dir.path("trip"));
tick_remove_missing(&mut h);
synchronize(&mut h);
h.settle(SETTLE);
crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap();
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
// (pressed before the scan can have ended: no frame has run since it started)
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg);
assert!(r.is_ok(), "{r:?}");
assert!(h.step_until(T, |h| h.app.sync.is_none()), "the scan ends");
assert!(h.app.sync_ready.is_none(), "no notice: there is nothing to look at");
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(toast.contains("up to date"), "{toast:?}");
assert!(h.app.session.folder_changes.is_none());
}
#[test]
fn the_cross_on_its_row_stops_a_background_scan_without_a_notice() {
let dir = Scratch::new("background-stop");
let mut h = changed_folder(&dir);
let gate = gated(&mut h);
scan_in_background(&mut h, &dir);
// another dialog is open meanwhile: it is not closed by the scan ending
h.app.ui.dialog = Some(Dialog::About);
let rows = h.app.session.activity.list();
h.app.session.activity.cancel(rows[0].id).unwrap();
h.step();
assert!(h.app.sync.is_none(), "✕ stops the scan");
assert!(matches!(h.app.ui.dialog, Some(Dialog::About)), "{:?}", h.app.ui.dialog);
gate.store(true, std::sync::atomic::Ordering::Relaxed);
h.app.ui.dialog = None;
h.settle(SETTLE);
assert!(h.app.sync_ready.is_none() && h.app.ui.dialog.is_none(), "a stopped scan says nothing later");
}
#[test]
fn opening_the_dialog_again_replaces_a_waiting_notice() {
let dir = Scratch::new("background-again");
let mut h = changed_folder(&dir);
let gate = gated(&mut h);
scan_in_background(&mut h, &dir);
gate.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(h.step_until(T, |h| h.app.sync_ready.is_some()));
// Synchronize Folder… from the menu once more: a fresh scan, not two answers
open_and_scan(&mut h, &dir.path("trip"));
assert!(h.app.sync_ready.is_none(), "the old notice is gone");
let c = counts(&h);
assert_eq!((c.new, c.missing), (1, 1), "{c:?}");
}
#[test]
fn a_result_looked_at_after_the_folder_changed_is_scanned_again_not_acted_on() {
let dir = Scratch::new("background-stale");
let mut h = changed_folder(&dir);
let gate = gated(&mut h);
scan_in_background(&mut h, &dir);
gate.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(h.step_until(T, |h| h.app.sync_ready.is_some()));
// the person went on working in the folder before looking
let id = h.app.session.catalog.photos().find(|p| p.file_name == "a.png").map(|p| p.id.0).unwrap();
h.app.run("library.select", json!({"ids": [id]})).unwrap();
h.app.run("photo.rate", json!({"rating": 3})).unwrap();
crate::sync::review(&mut h.app);
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
let before = in_library(&h);
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg);
assert!(r.is_err() && h.app.sync_run.is_none(), "what was shown is no longer so: {r:?}");
assert_eq!(in_library(&h), before);
assert!(h.app.sync.is_some(), "it is scanned again");
}
#[test]
fn another_folder_is_not_scanned_over_a_background_scan_or_its_notice() {
let dir = Scratch::new("background-other");
write_png(&dir.path("other/x.png"), 9);
let mut h = changed_folder(&dir);
h.app.session.execute("library.import", &json!({"paths": [dir.path("other")]})).unwrap();
let gate = gated(&mut h);
scan_in_background(&mut h, &dir);
let e = crate::sync::open(&mut h.app, &dir.path("other"), "other", false).unwrap_err();
assert!(e.contains("still being scanned") && e.contains("trip"), "{e}");
assert!(h.app.sync.is_some() && h.app.ui.dialog.is_none(), "the scan goes on");
gate.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(h.step_until(T, |h| h.app.sync_ready.is_some()));
let e = crate::sync::open(&mut h.app, &dir.path("other"), "other", false).unwrap_err();
assert!(e.contains("changes found in") && e.contains("trip"), "{e}");
// and a folder that can't be synchronized at all takes nothing away either
assert!(crate::sync::open(&mut h.app, &dir.path("nowhere"), "nowhere", false).is_err());
assert!(h.app.sync_ready.is_some() && h.app.session.folder_changes.is_some(), "the notice still waits");
// once it is dismissed the other folder can be scanned
crate::sync::dismiss(&mut h.app);
assert!(crate::sync::open(&mut h.app, &dir.path("other"), "other", false).is_ok());
}
#[test]
fn what_was_ticked_comes_back_with_the_result() {
let dir = Scratch::new("background-ticks");
let mut h = changed_folder(&dir);
let gate = gated(&mut h);
crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap();
// a dialog that had scanned before and scans again still has its ticks
let mut dlg = h.app.ui.dialog.take().expect("the dialog is open");
if let Dialog::SynchronizeFolder { import_new, remove_missing, .. } = &mut dlg {
(*import_new, *remove_missing) = (false, true);
}
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
gate.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(h.step_until(T, |h| h.app.sync_ready.is_some()));
// another dialog is open: the notice waits rather than take it away
h.app.ui.dialog = Some(Dialog::About);
crate::sync::review(&mut h.app);
assert!(matches!(h.app.ui.dialog, Some(Dialog::About)) && h.app.sync_ready.is_some());
h.app.ui.dialog = None;
crate::sync::review(&mut h.app);
assert!(
matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { import_new: false, remove_missing: true, counts: Some(_), .. })),
"{:?}",
h.app.ui.dialog
);
// closing the dialog lets go of the scan, as for one that was never in the background
h.app.ui.dialog = None;
h.step();
assert!(h.app.session.folder_changes.is_none());
}
#[test]
fn a_folder_that_isnt_there_is_a_notice_too_and_another_library_gets_none() {
let dir = Scratch::new("background-offline");
let mut h = changed_folder(&dir);
std::fs::rename(dir.0.join("trip"), dir.0.join("trip-elsewhere")).unwrap();
crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap();
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
assert!(h.step_until(T, |h| h.app.sync_ready.is_some()), "a notice waits");
assert!(h.app.sync_ready.as_ref().is_some_and(|r| r.counts.offline));
// File ▸ Open Library: the notice was about the library before
crate::sync::library_changed(&mut h.app);
assert!(h.app.sync_ready.is_none() && h.app.session.folder_changes.is_none());
}
#[test]
fn synchronize_waits_a_moment_after_its_button_stopped_being_continue_in_background() {
let dir = Scratch::new("settle");
let mut h = changed_folder(&dir);
open_and_scan(&mut h, &dir.path("trip"));
// a click meant for Continue in Background, landing as the scan ends
let before = in_library(&h);
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.sync_run.is_none() && matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "nothing was started");
assert_eq!(in_library(&h), before);
assert!(!crate::sync::settled(&h.app, &h.view.ctx));
}
File diff suppressed because one or more lines are too long
+7
View File
@@ -2,6 +2,13 @@
## 2026-10-11
### Synchronize Folder: no need to wait for the scan
- While Synchronize Folder… scans a folder (a large one on a network share can take minutes), its button is
**Continue in Background**: the dialog closes, the scan goes on under its row in the activity stack, and you can
go on working. When it is done nothing pops up: a notice in the activity stack says how many changes were found.
Click it to open the dialog with the result, or ✕ to let go of it. A scan that found nothing to do only says so.
Cancel and the row's ✕ stop the scan as before. One folder is scanned at a time.
### JPEG XL export
- Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI
(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a