From 9c9f87e589f21fca95b77f90c9401a73f990626f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 02:55:43 +0000 Subject: [PATCH] Import: a deferred review belongs to the library that scanned it A scan can finish while Settings is open, and Settings can then open another library. The review records the library generation it was made in: a review waiting for a dialog to close is dropped once its library is gone, and confirming one in another library imports nothing. --- crates/ui-egui/src/import.rs | 22 +++++++++++++++++++- crates/ui-egui/src/tests_drop_import.rs | 27 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index c49f82914..2a48d0a8c 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -91,6 +91,10 @@ pub struct ImportDialog { /// The candidate clicked last: where a Shift-click range starts ([`ImportDialog::click`]). #[serde(skip)] pub last_clicked: Option, + /// The library the scan was made in ([`lightcraft_engine::Session::library_generation`]): its duplicate + /// decisions and album ids belong to that catalog, so the review is not shown or confirmed in another. + #[serde(skip)] + pub library: Option, } /// A candidate's file type, as the review groups them: its format, or its extension when the @@ -114,6 +118,11 @@ impl ImportDialog { let checked = candidates.iter().map(|c| c.duplicate.is_none() && c.error.is_none()).collect(); ImportDialog { candidates, checked, on_deleted: "restore".into(), ..Default::default() } } + + /// Whether the library the scan was made in is still the open one (a review that names none counts). + fn in_library(&self, app: &LightcraftApp) -> bool { + self.library.is_none_or(|g| g == app.session.library_generation()) + } /// Mark the candidates whose photo is in Recently Deleted. They are checked when the choice /// for them (`on_deleted`) imports them — by default it restores them, so a photo deleted by /// mistake comes back by importing its file again (issue #706). @@ -627,16 +636,24 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { // only an album the review offers (it may have been deleted while the scan ran) d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); d.sources = task.sources; + d.library = Some(app.session.library_generation()); app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) }); show_pending_review(app, ctx); } /// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings… -/// opened while the scan ran stays), checked again shortly while it waits. +/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was +/// closed meanwhile (Settings can open another) is dropped. fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) { if app.review_pending.is_none() { return; } + if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending + && !opts.in_library(app) + { + app.review_pending = None; + return; + } if app.ui.dialog.is_none() { app.ui.dialog = app.review_pending.take(); } else { @@ -676,6 +693,9 @@ impl ScanTask { /// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result { busy_synchronizing(app)?; + if !d.in_library(app) { + return Err("another library was opened since the photos were scanned: nothing was imported".into()); + } let queue = d.selected_paths(); if queue.is_empty() { return Err("no photos selected".into()); diff --git a/crates/ui-egui/src/tests_drop_import.rs b/crates/ui-egui/src/tests_drop_import.rs index 9751546df..56f72fdb6 100644 --- a/crates/ui-egui/src/tests_drop_import.rs +++ b/crates/ui-egui/src/tests_drop_import.rs @@ -166,6 +166,33 @@ fn a_finished_scan_waits_for_the_open_dialog_before_opening_the_review() { assert_eq!(opts.candidates.len(), 2, "the scan's photos were kept"); } +/// A scan that finishes behind Settings, which then opens another library: the review of the first +/// library's scan doesn't open over the second one once Settings closes, and nothing is imported into it. +#[test] +fn a_waiting_review_is_dropped_when_another_library_opens() { + let dir = Scratch::new("switch"); + let mut h = app(&dir); + crate::import::open(&mut h.app, vec![dir.path("trip")]).unwrap(); + h.app.ui.dialog = Some(Dialog::Settings { tab: "general".into() }); + assert!(h.step_until(SETTLE, |h| h.app.scan.is_none()), "the scan ends"); + h.step(); + let stale = h.app.review_pending.clone().expect("the review waits behind Settings"); + h.app.run("app.openLibrary", json!({"path": dir.path("other")})).unwrap(); + h.app.ui.dialog = None; + for _ in 0..10 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no review from the other library opens"); + assert!(h.app.review_pending.is_none()); + // a review confirmed after the switch (an agent holding it) imports nothing either + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &stale).is_err()); + for _ in 0..10 { + h.step(); + } + assert!(h.app.import.is_none(), "nothing is imported"); + assert_eq!(photos(&h), 0); +} + /// A drop while an import runs would open a review whose OK replaces the running import: it is /// refused instead, and the card says so. #[test]