diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 108a57db0..d8cd4eb1b 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1024,6 +1024,9 @@ fn folder_tree( app.toast(ui.ctx(), e); } resp.context_menu(|ui| { + // the library actions of the Folders tree, when this folder (or one inside it) holds library photos + let library = app.caches.folder_tree(&app.session.catalog); + let held = library_node_for(&library, path); if (transient || indent > 0.0) && ui .button(if transient { "Keep in Local" } else { "Add to Local" }) @@ -1067,6 +1070,9 @@ fn folder_tree( { let _ = f(path); } + if let Some(held) = &held { + synchronize_item(app, ui, &held.0, &held.1); + } }); if open && indent < 12.0 * 8.0 { for (n, p) in subfolders(ui, path) { @@ -1721,6 +1727,38 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { } } +/// The node Synchronize Folder… of a Local folder `path` works on, with the name its dialog shows: +/// the Folders row for that very folder, else (a folder above rows) the folder itself, as a folder +/// holding other folders. `None` when no library photo lies in `path` or inside it. Reads the +/// cached Folders tree only; the disk is not touched. +fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, String)> { + use lightcraft_catalog::query::folder_within; + fn exact<'a>(tree: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> { + tree.iter().find_map(|n| if same_folder(&n.path, path) { Some(n) } else { exact(&n.children, path) }) + } + fn any_inside(tree: &[FolderNode], path: &str) -> bool { + tree.iter().any(|n| (n.count > 0 && folder_within(&n.path, path)) || any_inside(&n.children, path)) + } + if let Some(n) = exact(tree, path).filter(|n| n.count > 0) { + return Some((n.clone(), lightcraft_catalog::folders::folder_label(&n.path))); + } + if !any_inside(tree, path) { + return None; + } + let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + let node = FolderNode { + name: name.clone(), + path: path.to_string(), + count: 0, + own: 0, + volume: false, + selectable: false, + label: None, + children: Vec::new(), + }; + Some((node, name)) +} + /// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which /// scans the folder in the background. fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) { diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index b25ef6070..9e0db5297 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -5,6 +5,8 @@ //! * Right-click a folder in the sidebar's Folders section ▸ Synchronize Folder…: a dialog opens //! at once and scans the folder without holding up the app, then says how many photos are new, //! missing, or have metadata updates. +//! * Right-click a folder in the sidebar's Local section: Synchronize Folder… is there when the +//! folder holds library photos (or has a folder with some inside), and not when it holds none. //! * 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. @@ -302,3 +304,42 @@ fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() { h.settle(SETTLE); assert!(h.app.ui.dialog.is_none(), "a stopped scan opens nothing later"); } + +/// Right-click the Local row of `folder`; whether Synchronize Folder… is in its menu. +fn local_menu_offers_synchronize(h: &mut Headless, folder: &str) -> bool { + let row = format!("source:local:{folder}"); + assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "no row {row}"); + let rect = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap(); + let c = rect.center(); + let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + h.app.widgets.iter().any(|(w, _)| *w == "folderSynchronize") +} + +#[test] +fn a_local_folder_with_library_photos_offers_synchronize() { + let dir = Scratch::new("local-menu"); + let mut h = changed_folder(&dir); + std::fs::create_dir_all(dir.path("empty")).unwrap(); + // Local writes paths the way the OS does + let native = |f: &str| dir.0.join(f).to_string_lossy().to_string(); + for f in ["trip", "empty"] { + let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": native(f)}}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.step(); + assert!(!local_menu_offers_synchronize(&mut h, &native("empty")), "no library photos in it: nothing to synchronize"); + // close that menu, then open the other folder's + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(local_menu_offers_synchronize(&mut h, &native("trip")), "the folder holds library photos"); + let r = h.request("ui.clickWidget", json!({"id": "folderSynchronize"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog opens at once"); + assert!(h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. })))); +}