LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date, with one Close

When the scan finds nothing to import, relink, remove or read, the dialog
showed four greyed choices (two still ticked) and a Synchronize button
that started an empty job with an activity row. It now shows the folder,
"The library is up to date with this folder." and any Skipped lines, with
one Close and no Cancel (`only_shows`). `sync::confirm` answers
`{"upToDate": true}` and starts nothing, so `ui.dialog.confirm` and agents
get the same result.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-21 13:17:02 -04:00
co-authored by Claude
parent e142e2ffa4
commit 35cc05ef64
7 changed files with 98 additions and 19 deletions
+4 -2
View File
@@ -1366,6 +1366,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::KeywordTag { .. } => "Save",
Dialog::RemoveFolder { .. } => "Remove",
Dialog::LightroomCatalog { .. } => "Import Lightroom Catalog…",
Dialog::SynchronizeFolder { .. } if informational => "Close",
Dialog::SynchronizeFolder { .. } => "Synchronize",
Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue",
Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "",
@@ -2304,7 +2305,8 @@ pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle {
}
}
/// A dialog that only shows something: one Close, nothing to confirm or cancel.
/// A dialog that only shows something: one Close, nothing to confirm or cancel. (Synchronize
/// Folder is one when its folder turned out up to date.)
fn only_shows(dlg: &Dialog) -> bool {
matches!(
dlg,
@@ -2315,5 +2317,5 @@ fn only_shows(dlg: &Dialog) -> bool {
| Dialog::AllMetadata { .. }
| Dialog::WhatsNew
| Dialog::ManageEffects { .. }
)
) || matches!(dlg, Dialog::SynchronizeFolder { counts: Some(c), .. } if c.up_to_date())
}
+8
View File
@@ -1261,6 +1261,14 @@ pub struct SyncCounts {
pub moved: usize,
}
impl SyncCounts {
/// The folder is there and the scan found nothing to bring in, relink, remove or read: the
/// dialog only says so (files it skipped are not changes).
pub fn up_to_date(&self) -> bool {
!self.offline && self.new + self.moved + self.missing + self.metadata == 0
}
}
impl Default for UiState {
fn default() -> Self {
UiState {
+26 -11
View File
@@ -153,36 +153,51 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
crate::widgets::register(ui.ctx(), widget, r.rect);
};
let count = |text: &str, n: usize| format!("{} ({n})", crate::i18n::tr(text));
let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim));
let skipped = |ui: &mut egui::Ui| {
if c.duplicates > 0 {
dim(ui, &count("Skipped: files already in the library elsewhere", c.duplicates));
}
if c.unreadable > 0 {
dim(ui, &count("Skipped: files that can't be read", c.unreadable));
}
};
if c.up_to_date() {
// nothing to choose: the answer takes the choices' place, and the one button closes
// (`only_shows`)
ui.label(crate::i18n::tr("The library is up to date with this folder."));
skipped(ui);
return;
}
choice(ui, import_new, c.new, count("Import new photos", c.new), "syncImportNew");
choice(ui, relink_moved, c.moved, count("Relink photos whose file was renamed or moved", c.moved), "syncRelinkMoved");
choice(ui, remove_missing, c.missing, count("Remove missing photos from the library", c.missing), "syncRemoveMissing");
choice(ui, read_metadata, c.metadata, count("Read metadata updates from XMP sidecars", c.metadata), "syncReadMetadata");
ui.add_space(6.0);
let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim));
if c.new + c.moved + c.missing + c.metadata == 0 {
dim(ui, crate::i18n::tr("The library is up to date with this folder."));
}
if c.missing > 0 {
dim(ui, crate::i18n::tr("Missing photos move to Recently Deleted and can be restored; no file on disk is touched."));
}
if c.metadata > 0 {
dim(ui, crate::i18n::tr("Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says."));
}
if c.duplicates > 0 {
dim(ui, &count("Skipped: files already in the library elsewhere", c.duplicates));
}
if c.unreadable > 0 {
dim(ui, &count("Skipped: files that can't be read", c.unreadable));
}
skipped(ui);
}
/// Synchronize with the dialog's choices: the scan the dialog showed is handed to a worker
/// 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.
/// A folder that is up to date has nothing to hand over: confirming it only closes the dialog.
pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> {
let Dialog::SynchronizeFolder { path, name, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else {
let Dialog::SynchronizeFolder { path, name, disk, counts: Some(counts), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg
else {
return Err("the folder is still being scanned".into());
};
// (before the busy checks: nothing is imported or written, so a running import or
// synchronize can't clash with it, and the dialog closes either way. Like every result the
// dialog shows, it is the scan's: a file added since is found by the next Synchronize)
if counts.up_to_date() {
return Ok(json!({"upToDate": true}));
}
if app.sync_run.is_some() {
return Err(crate::i18n::tr("A folder is already being synchronized").to_string());
}
+48 -3
View File
@@ -10,6 +10,8 @@
//! * Synchronize with the defaults imports the new photos and leaves missing ones alone.
//! * Ticking "Remove missing photos" also moves those to Recently Deleted.
//! * Cancel leaves the library as it was.
//! * A folder that has nothing to bring in only says so: the dialog has one Close and no
//! choices, and closing it starts nothing.
//! * 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 row in the activity stack, as one undo
//! step.
@@ -59,12 +61,19 @@ fn write_png(path: &str, seed: u8) {
/// The app with `trip/a.png` and `trip/b.png` imported, then `c.png` added and `b.png` deleted
/// on disk behind its back.
fn changed_folder(dir: &Scratch) -> Headless {
imported_folder(dir, || {
write_png(&dir.path("trip/c.png"), 3);
std::fs::remove_file(dir.path("trip/b.png")).unwrap();
})
}
/// The app with `trip/a.png` and `trip/b.png` imported, and `then` done on disk after.
fn imported_folder(dir: &Scratch, then: impl FnOnce()) -> Headless {
write_png(&dir.path("trip/a.png"), 1);
write_png(&dir.path("trip/b.png"), 2);
let mut session = lightcraft_engine::Session::new().with_fs();
session.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap();
write_png(&dir.path("trip/c.png"), 3);
std::fs::remove_file(dir.path("trip/b.png")).unwrap();
then();
let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T);
@@ -179,6 +188,42 @@ fn cancel_leaves_the_library_as_it_was() {
assert!(h.app.session.folder_changes.is_none(), "the scan is let go");
}
fn shown(h: &Headless, widget: &str) -> bool {
h.app.widgets.iter().any(|(w, _)| w == widget)
}
#[test]
fn a_folder_with_nothing_to_bring_in_is_only_reported() {
let dir = Scratch::new("uptodate");
let mut h = imported_folder(&dir, || {});
open_and_scan(&mut h, &dir.path("trip"));
h.step();
assert!(shown(&h, "button:dialogOk"), "one button, to close it");
for gone in ["button:dialogCancel", "syncImportNew", "syncRelinkMoved", "syncRemoveMissing", "syncReadMetadata"] {
assert!(!shown(&h, gone), "nothing to choose: {gone}");
}
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.step_until(T, |h| h.app.ui.dialog.is_none()), "the button closes it");
assert!(h.app.sync_run.is_none() && h.app.session.activity.list().is_empty(), "and starts nothing");
assert!(h.app.session.folder_changes.is_none(), "the scan is let go");
assert_eq!(in_library(&h), vec!["a.png", "b.png"]);
}
#[test]
fn confirming_a_folder_with_nothing_to_bring_in_starts_nothing() {
let dir = Scratch::new("uptodate-agent");
let mut h = imported_folder(&dir, || {});
open_and_scan(&mut h, &dir.path("trip"));
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(r["result"]["upToDate"], true, "{r}");
assert!(h.app.ui.dialog.is_none());
assert!(h.app.sync_run.is_none() && h.app.session.activity.list().is_empty(), "no job that does nothing");
h.step();
assert!(h.app.session.folder_changes.is_none(), "the scan is let go");
}
#[test]
fn an_agents_scan_is_kept_between_frames() {
let dir = Scratch::new("agent");
@@ -247,7 +292,7 @@ fn a_synchronize_and_an_import_never_run_at_once() {
path: dir.path("trip"),
name: "trip".into(),
disk: false,
counts: Some(Default::default()),
counts: Some(crate::state::SyncCounts { new: 1, ..Default::default() }),
import_new: true,
relink_moved: true,
remove_missing: false,
+1 -1
View File
@@ -76,7 +76,7 @@ filmstrip is visible.
| `ui.scroll` | `{dx, dy, cmd?, ctrl?, shift?, alt?}` | Wheel / two-finger scroll at the current pointer; pans over the image, modifier-scroll zooms |
| `ui.zoom` | `{factor}` | Pinch zoom at the current pointer (positive scale multiplier; 1 = unchanged). Position it first with `ui.move` or `ui.hoverWidget` |
| `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state |
| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog |
| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog (confirm runs its action; Synchronize Folder on a folder that is up to date answers `{"upToDate": true}` and starts nothing) |
| `ui.resize` | `{width, height}` | Resize the window |
| `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) |
| `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) |
File diff suppressed because one or more lines are too long
+8
View File
@@ -2,6 +2,14 @@
## 2026-10-19
### Synchronize Folder on a folder that is up to date
- When the scan finds nothing to import, relink, remove or read, the Synchronize Folder dialog says the library is up
to date with the folder and has one Close button. It used to show four greyed choices, two of them still ticked,
and a Synchronize button that started a job with nothing to do. Files it skipped (already in the library elsewhere,
or unreadable) are still listed.
## 2026-10-19
### Hidden Local locations come back one at a time
- With several locations removed from Local, "Show N hidden locations" now lists them, so each comes back on its own
(Restore All is there too). The row used to bring every hidden location back at once (issue #2114).