From 56dfc8d0275697b9bb73071ee555ee2747b3a216 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 09:17:45 +0200 Subject: [PATCH] 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 --- crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/fr.json | 4 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/uk.json | 4 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/export_task.rs | 20 ++++++++++ crates/ui-egui/src/lib.rs | 1 - crates/ui-egui/src/sync.rs | 58 +++++++++++++---------------- crates/ui-egui/src/tests_sync.rs | 52 +++++++++++++++++++++++++- docs/parity.md | 4 +- docs/whats-new.md | 4 +- 15 files changed, 127 insertions(+), 48 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 85800315f..aa24e0f10 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 457287d63..c4f2e8357 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index e69c34700..f3422bc0a 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -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" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 036a222fa..a7a3a32ef 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1876,5 +1876,7 @@ "Reading Lightroom catalog": "Lightroom カタログを読み取り中", "Merging": "合成中", "Downloading model": "モデルをダウンロード中", - "Finding faces": "顔を検索中" + "Finding faces": "顔を検索中", + "Synchronizing folder": "フォルダを同期中", + "Looking for changes": "変更を確認中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 112a5e8a4..c3d9a8d78 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index e3c07e206..bf9974740 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1588,5 +1588,7 @@ "Merging": "Слияние", "Downloading model": "Загрузка модели", "Finding faces": "Поиск лиц", - "Quit Anyway": "Всё равно выйти" + "Quit Anyway": "Всё равно выйти", + "Synchronizing folder": "Синхронизация папки", + "Looking for changes": "Поиск изменений" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 1c5e86488..c73c05b93 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2068,5 +2068,7 @@ "Reading Lightroom catalog": "Читання каталогу Lightroom", "Merging": "Об’єднання", "Downloading model": "Завантаження моделі", - "Finding faces": "Пошук облич" + "Finding faces": "Пошук облич", + "Synchronizing folder": "Синхронізація папки", + "Looking for changes": "Пошук змін" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 578637f03..21fa4e4e9 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1874,5 +1874,7 @@ "Reading Lightroom catalog": "正在读取 Lightroom 目录", "Merging": "正在合并", "Downloading model": "正在下载模型", - "Finding faces": "正在查找人脸" + "Finding faces": "正在查找人脸", + "Synchronizing folder": "正在同步文件夹", + "Looking for changes": "正在查找更改" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 09a648c7a..d2e0a30c2 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1874,5 +1874,7 @@ "Reading Lightroom catalog": "正在讀取 Lightroom 目錄", "Merging": "正在合併", "Downloading model": "正在下載模型", - "Finding faces": "正在尋找臉孔" + "Finding faces": "正在尋找臉孔", + "Synchronizing folder": "正在同步資料夾", + "Looking for changes": "正在尋找變更" } diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index b717313ba..a54d1718f 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -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( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f2ba2f258..6b5033e0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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); diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 244f4308c..cd4a267af 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -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, rx: std::sync::mpsc::Receiver, + /// 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 { 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, /// 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(); - } -} diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index 0fa430891..b25ef6070 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -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"); +} diff --git a/docs/parity.md b/docs/parity.md index 247e995cb..4c05ae08a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -516,7 +516,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | -| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports, Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); no tiling beyond the buffer limit (CPU) | @@ -746,7 +746,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames under a progress window with Cancel, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 5372401c4..11f01b07e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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