mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Issue #345: Synchronize Folder and the contact sheet in the activity stack
Synchronize Folder (#604) shows its scan ("Looking for changes", while its dialog is open) and its run ("Synchronizing folder") as rows: the scan's cross stops it and closes the dialog, the run's stops it like Cancel did (what was done stays, as one undo step; the end says Stopped). Its progress window is gone. The contact-sheet PDF export (#563), which went through the export task already, gets a test for its row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8c3c586b80
commit
56dfc8d027
@@ -2029,5 +2029,7 @@
|
||||
"Reading Lightroom catalog": "Lightroom-Katalog wird gelesen",
|
||||
"Merging": "Zusammenfügen",
|
||||
"Downloading model": "Modell wird heruntergeladen",
|
||||
"Finding faces": "Gesichter werden gesucht"
|
||||
"Finding faces": "Gesichter werden gesucht",
|
||||
"Synchronizing folder": "Ordner wird synchronisiert",
|
||||
"Looking for changes": "Änderungen werden gesucht"
|
||||
}
|
||||
|
||||
@@ -1874,5 +1874,7 @@
|
||||
"Reading Lightroom catalog": "Leyendo catálogo de Lightroom",
|
||||
"Merging": "Combinando",
|
||||
"Downloading model": "Descargando modelo",
|
||||
"Finding faces": "Buscando caras"
|
||||
"Finding faces": "Buscando caras",
|
||||
"Synchronizing folder": "Sincronizando carpeta",
|
||||
"Looking for changes": "Buscando cambios"
|
||||
}
|
||||
|
||||
@@ -1874,5 +1874,7 @@
|
||||
"Reading Lightroom catalog": "Lecture du catalogue Lightroom",
|
||||
"Merging": "Fusion en cours",
|
||||
"Downloading model": "Téléchargement du modèle",
|
||||
"Finding faces": "Recherche des visages"
|
||||
"Finding faces": "Recherche des visages",
|
||||
"Synchronizing folder": "Synchronisation du dossier",
|
||||
"Looking for changes": "Recherche des modifications"
|
||||
}
|
||||
|
||||
@@ -1876,5 +1876,7 @@
|
||||
"Reading Lightroom catalog": "Lightroom カタログを読み取り中",
|
||||
"Merging": "合成中",
|
||||
"Downloading model": "モデルをダウンロード中",
|
||||
"Finding faces": "顔を検索中"
|
||||
"Finding faces": "顔を検索中",
|
||||
"Synchronizing folder": "フォルダを同期中",
|
||||
"Looking for changes": "変更を確認中"
|
||||
}
|
||||
|
||||
@@ -1874,5 +1874,7 @@
|
||||
"Reading Lightroom catalog": "Lendo catálogo do Lightroom",
|
||||
"Merging": "Mesclando",
|
||||
"Downloading model": "Baixando modelo",
|
||||
"Finding faces": "Procurando rostos"
|
||||
"Finding faces": "Procurando rostos",
|
||||
"Synchronizing folder": "Sincronizando pasta",
|
||||
"Looking for changes": "Procurando alterações"
|
||||
}
|
||||
|
||||
@@ -1588,5 +1588,7 @@
|
||||
"Merging": "Слияние",
|
||||
"Downloading model": "Загрузка модели",
|
||||
"Finding faces": "Поиск лиц",
|
||||
"Quit Anyway": "Всё равно выйти"
|
||||
"Quit Anyway": "Всё равно выйти",
|
||||
"Synchronizing folder": "Синхронизация папки",
|
||||
"Looking for changes": "Поиск изменений"
|
||||
}
|
||||
|
||||
@@ -2068,5 +2068,7 @@
|
||||
"Reading Lightroom catalog": "Читання каталогу Lightroom",
|
||||
"Merging": "Об’єднання",
|
||||
"Downloading model": "Завантаження моделі",
|
||||
"Finding faces": "Пошук облич"
|
||||
"Finding faces": "Пошук облич",
|
||||
"Synchronizing folder": "Синхронізація папки",
|
||||
"Looking for changes": "Пошук змін"
|
||||
}
|
||||
|
||||
@@ -1874,5 +1874,7 @@
|
||||
"Reading Lightroom catalog": "正在读取 Lightroom 目录",
|
||||
"Merging": "正在合并",
|
||||
"Downloading model": "正在下载模型",
|
||||
"Finding faces": "正在查找人脸"
|
||||
"Finding faces": "正在查找人脸",
|
||||
"Synchronizing folder": "正在同步文件夹",
|
||||
"Looking for changes": "正在查找更改"
|
||||
}
|
||||
|
||||
@@ -1874,5 +1874,7 @@
|
||||
"Reading Lightroom catalog": "正在讀取 Lightroom 目錄",
|
||||
"Merging": "正在合併",
|
||||
"Downloading model": "正在下載模型",
|
||||
"Finding faces": "正在尋找臉孔"
|
||||
"Finding faces": "正在尋找臉孔",
|
||||
"Synchronizing folder": "正在同步資料夾",
|
||||
"Looking for changes": "正在尋找變更"
|
||||
}
|
||||
|
||||
@@ -215,6 +215,26 @@ mod contact_sheet_tests {
|
||||
assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["photos"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_sheet_export_shows_a_row_and_its_cross_stops_it() {
|
||||
let mut app = LightcraftApp::new(
|
||||
lightcraft_engine::Session::with_demo(),
|
||||
crate::Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() },
|
||||
);
|
||||
let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect();
|
||||
start_contact_sheet(&mut app, &json!({"path": "Sheet.pdf", "ids": ids})).unwrap();
|
||||
let rows = app.session.activity.list();
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].total), ("export", "Exporting contact sheet", 24));
|
||||
assert!(rows[0].cancellable);
|
||||
app.session.activity.cancel(rows[0].id).unwrap();
|
||||
assert!(app.export.as_ref().unwrap().cancel.load(Ordering::Relaxed), "the stack's ✕ stops the sheet");
|
||||
let task = app.export.take().unwrap();
|
||||
assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err(), "a stopped sheet is not written");
|
||||
drop(task);
|
||||
assert!(app.session.activity.list().is_empty(), "the row goes with the export");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_contact_sheet_never_calls_writer() {
|
||||
let mut app = LightcraftApp::new(
|
||||
|
||||
@@ -1066,7 +1066,6 @@ impl LightcraftApp {
|
||||
panels::notices::show(self, &ctx);
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::library_problem::show(self, &ctx);
|
||||
sync::progress_window(self, &ctx);
|
||||
export_task::poll(self, &ctx);
|
||||
panels::activity::show(self, &ctx);
|
||||
pick::poll(self, &ctx);
|
||||
|
||||
+26
-32
@@ -1,11 +1,13 @@
|
||||
//! Synchronize Folder in the app: the folder is scanned on a worker thread (a network share can
|
||||
//! take minutes; no frame waits for it) while its dialog is open, and the dialog then says what
|
||||
//! changed and what to do about it. Synchronize runs `folder.synchronize`, which acts on exactly
|
||||
//! the scan the dialog showed (see `lightcraft_engine::sync`).
|
||||
//! 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.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use lightcraft_engine::activity::{Cancel, TaskGuard};
|
||||
use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncCommit, SyncInput, SyncJob, SyncProgress, SyncStep, scan_with};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -18,6 +20,8 @@ pub struct SyncTask {
|
||||
path: String,
|
||||
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.
|
||||
@@ -34,7 +38,9 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu
|
||||
std::thread::spawn(job);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
job();
|
||||
app.sync = Some(SyncTask { path: path.to_string(), progress, rx });
|
||||
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 });
|
||||
let d = SyncChoice::default();
|
||||
app.ui.dialog = Some(Dialog::SynchronizeFolder {
|
||||
path: path.to_string(),
|
||||
@@ -78,6 +84,14 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
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;
|
||||
return;
|
||||
}
|
||||
let (done, total) = task.progress.counts();
|
||||
task.guard.progress(done as u64, total as u64);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
let changes = match task.rx.try_recv() {
|
||||
Ok(c) => c,
|
||||
@@ -210,7 +224,9 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {
|
||||
std::thread::spawn(job);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
job();
|
||||
app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new() });
|
||||
let guard = app.session.activity.start("sync", "Synchronizing folder", Cancel::Flag(progress.files.cancel.clone()));
|
||||
guard.detail(name);
|
||||
app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new(), guard });
|
||||
Ok(json!({"started": true}))
|
||||
}
|
||||
|
||||
@@ -227,6 +243,8 @@ pub struct SyncRun {
|
||||
commit: Option<SyncCommit>,
|
||||
/// Cancel was pressed: nothing new is started; what was readied is still committed.
|
||||
stopping: bool,
|
||||
/// Its row in the activity stack (its ✕ is Cancel).
|
||||
guard: TaskGuard,
|
||||
}
|
||||
|
||||
impl SyncRun {
|
||||
@@ -253,6 +271,11 @@ impl Drop for SyncRun {
|
||||
pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(run) = app.sync_run.as_mut() else { return };
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
if run.guard.is_cancelled() {
|
||||
run.stopping = true;
|
||||
}
|
||||
let (done, total) = run.counts();
|
||||
run.guard.progress(done as u64, total as u64);
|
||||
let mut steps = Vec::new();
|
||||
let finished = loop {
|
||||
match run.rx.try_recv() {
|
||||
@@ -317,32 +340,3 @@ pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
app.toast_for(ctx, format!("{name}: {text}"), 12.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The progress window while a synchronize runs, with Cancel (what was done stays, as one undo
|
||||
/// step; nothing new is started).
|
||||
pub fn progress_window(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(run) = &app.sync_run else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let (done, total) = run.counts();
|
||||
let text =
|
||||
if run.stopping { crate::i18n::tr("Stopping…").to_string() } else { format!("{} {done} / {total}", crate::i18n::tr("Synchronizing…")) };
|
||||
let frac = if total == 0 { 0.0 } else { done as f32 / total as f32 };
|
||||
let stopping = run.stopping;
|
||||
let mut cancel = false;
|
||||
egui::Window::new(crate::i18n::tr("Synchronize Folder"))
|
||||
.id(egui::Id::new("sync-progress"))
|
||||
.title_bar(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_BOTTOM, [0.0, -80.0])
|
||||
.fixed_size([340.0, 80.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.label(egui::RichText::new(text).color(t.text));
|
||||
ui.add(egui::ProgressBar::new(frac).desired_width(320.0));
|
||||
let r = ui.add_enabled(!stopping, egui::Button::new(crate::i18n::tr("Cancel")));
|
||||
crate::widgets::register(ui.ctx(), "button:syncCancel", r.rect);
|
||||
cancel = r.clicked();
|
||||
});
|
||||
if cancel && let Some(run) = app.sync_run.as_mut() {
|
||||
run.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! * Ticking "Remove missing photos" also moves those to Recently Deleted.
|
||||
//! * Cancel leaves the library as it was.
|
||||
//! * Synchronize hands the work to the background at once: the dialog closes, the app keeps
|
||||
//! answering, and the photos arrive over the next frames under a progress window, as one undo
|
||||
//! answering, and the photos arrive over the next frames under a row in the activity stack, as one undo
|
||||
//! step.
|
||||
|
||||
use std::time::Duration;
|
||||
@@ -218,7 +218,7 @@ fn a_stopped_synchronize_says_it_was_stopped_and_stays_at_most_one_step() {
|
||||
let steps = h.app.session.undo.len();
|
||||
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
|
||||
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
|
||||
// the progress window's Cancel, before any frame committed anything
|
||||
// Cancel (what the row's ✕ does), before any frame committed anything
|
||||
h.app.sync_run.as_mut().expect("running").cancel();
|
||||
assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends");
|
||||
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
||||
@@ -254,3 +254,51 @@ fn a_synchronize_and_an_import_never_run_at_once() {
|
||||
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &d);
|
||||
assert!(r.is_err() && h.app.sync_run.is_none(), "no synchronize while importing: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_synchronize_shows_a_row_and_its_cross_stops_it() {
|
||||
let dir = Scratch::new("row");
|
||||
let mut h = changed_folder(&dir);
|
||||
open_and_scan(&mut h, &dir.path("trip"));
|
||||
let dlg = h.app.ui.dialog.take().expect("the dialog is open");
|
||||
assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok());
|
||||
let rows = h.app.session.activity.list();
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
assert_eq!((rows[0].kind, rows[0].label.as_str()), ("sync", "Synchronizing folder"));
|
||||
assert!(rows[0].detail.ends_with("trip"), "the folder as the dialog named it: {:?}", rows[0].detail);
|
||||
assert!(rows[0].cancellable);
|
||||
// the row's ✕ (as `activity.cancel`): what was done stays, and the end says it was stopped
|
||||
h.app.session.activity.cancel(rows[0].id).unwrap();
|
||||
assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends");
|
||||
let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
||||
assert!(toast.contains("Stopped"), "{toast:?}");
|
||||
assert!(h.app.session.activity.list().is_empty(), "the row goes with the run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() {
|
||||
let dir = Scratch::new("scanrow");
|
||||
let mut h = changed_folder(&dir);
|
||||
// a scan that can't finish until the test lets it: reading the new file waits on a flag
|
||||
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() })
|
||||
}));
|
||||
crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap();
|
||||
let rows = h.app.session.activity.list();
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("sync", "Looking for changes", "trip"));
|
||||
assert!(rows[0].cancellable);
|
||||
h.app.session.activity.cancel(rows[0].id).unwrap();
|
||||
h.step();
|
||||
assert!(h.app.sync.is_none(), "✕ stops the scan");
|
||||
assert!(h.app.ui.dialog.is_none(), "and closes its dialog: there is nothing to show");
|
||||
gate.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(h.app.session.activity.list().is_empty());
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.ui.dialog.is_none(), "a stopped scan opens nothing later");
|
||||
}
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
### Activity stack
|
||||
- Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and
|
||||
folder scans, exports, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,
|
||||
folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,
|
||||
AI Denoise and Find Missing Photos. Each row has the task's name, a progress bar, the count and what it is working
|
||||
on; ✕ stops the tasks that can stop. Three rows show, then "+N more". The separate export panel and the progress
|
||||
windows and toasts are gone.
|
||||
@@ -101,7 +101,7 @@
|
||||
file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then
|
||||
choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently
|
||||
Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing
|
||||
runs in the background with a progress window and Cancel; everything it does is one undo step, and no file on disk
|
||||
runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk
|
||||
is touched. Agents use `folder.scanChanges` and `folder.synchronize`.
|
||||
|
||||
### Folder colour labels
|
||||
|
||||
Reference in New Issue
Block a user