mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 19:00:31 +00:00
Merge PR #2366: LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date, with one Close
docs/parity-checklist.md: header and revision rows from both sides; LRC-LIB-LIBFOLDERS row takes both wording changes
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -187,36 +187,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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -62,12 +64,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);
|
||||
@@ -182,6 +191,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");
|
||||
@@ -250,7 +295,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,
|
||||
|
||||
@@ -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
@@ -2,6 +2,12 @@
|
||||
|
||||
## 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.
|
||||
|
||||
### 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).
|
||||
|
||||
Reference in New Issue
Block a user