mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 12:08:10 -06:00
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:
co-authored by
Claude Opus 5.5
parent
76b5377868
commit
738a49aa47
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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}」で見つかった変更を確認するか、閉じてください"
|
||||
}
|
||||
|
||||
@@ -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.": "作業を続けられます。スキャンが終わると左上に通知が表示されます。"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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}», или скройте их"
|
||||
}
|
||||
|
||||
@@ -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.": "Можно продолжать работу: уведомление слева вверху сообщит, когда поиск завершится."
|
||||
}
|
||||
|
||||
@@ -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}», або сховайте їх"
|
||||
}
|
||||
|
||||
@@ -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.": "Можна працювати далі: сповіщення вгорі ліворуч повідомить, коли пошук завершиться."
|
||||
}
|
||||
|
||||
@@ -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}”中发现的更改,或将其忽略"
|
||||
}
|
||||
|
||||
@@ -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.": "你可以继续工作:扫描完成时左上角会出现通知。"
|
||||
}
|
||||
|
||||
@@ -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}」中找到的變更,或將其忽略"
|
||||
}
|
||||
|
||||
@@ -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.": "你可以繼續工作:掃描完成時左上角會出現通知。"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user