diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..db18d87cd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1749,6 +1749,10 @@ impl Caches { self.folder_tree = Some((cat.revision, t.clone())); t } + /// The folder tree as last built, when it still holds (a read-only check cannot rebuild it). + pub(crate) fn folder_tree_if_fresh(&self, cat: &lightcraft_catalog::Catalog) -> Option>> { + self.folder_tree.as_ref().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t.clone()) + } /// The library's keyword tree. pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c7828fd4b..d8f899cb3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2107,7 +2107,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::control::export_active(app, p), "app.showInFinder" => show_in_finder(app), - "app.showInFolders" => show_in_folders(app), + "app.showInFolders" => show_in_folders(app, p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId)), "app.openLogFolder" => open_log_folder(app), "app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => { let url = crate::links::url_of(id).unwrap_or(crate::links::WEBSITE); @@ -2162,7 +2162,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { .and_then(|id| app.session.catalog.photo(id)) .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) } - "app.showInFolders" => active_folder(app).is_some(), + "app.showInFolders" => show_in_folders_enabled(app, None), "app.openSamModelFolder" => app.session.segmenter.dir.is_some() && app.services.open_with.is_some(), "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), "file.exportMaskEffects" => !app.session.mask_effects.is_empty(), @@ -2295,18 +2295,27 @@ fn show_in_finder(app: &mut LightcraftApp) -> Result { /// The folder the active photo was imported from, as the sidebar's Folders section counts it (a /// photo straight in a disk's root is in the root). `None` for a demo scene, a photo only browsed /// in Local or in Recently Deleted, and one on the startup disk's root, which has no row to show. -fn active_folder(app: &LightcraftApp) -> Option { - let photo = app.session.active().and_then(|id| app.session.catalog.photo(id)).filter(|p| p.in_library())?; +fn photo_folder(app: &LightcraftApp, id: Option) -> Option { + let photo = id.or_else(|| app.session.active()).and_then(|id| app.session.catalog.photo(id)).filter(|p| p.in_library())?; let lightcraft_engine::catalog::Source::File { path } = &photo.source else { return None }; let dir = path.rfind(['/', '\\']).and_then(|i| path.get(..i.max(1)))?; (!lightcraft_catalog::folders::is_startup_disk(dir)).then(|| dir.to_string()) } -/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library): show the active photo's folder -/// as a click on its row in the sidebar's Folders section does, and bring the row into view: the -/// sidebar, the section and the folders above it open, and it scrolls there (`reveal_folder`). -fn show_in_folders(app: &mut LightcraftApp) -> Result { - let dir = active_folder(app).ok_or("this photo is not in a folder of the library")?; +/// Whether Show in Folders has a row to show for photo `id` (the active one when `None`): its +/// folder must be a row of the tree, which stops `MAX_DEPTH` levels down. With no fresh tree to +/// look in, the folder alone decides, and the command still checks. +pub fn show_in_folders_enabled(app: &LightcraftApp, id: Option) -> bool { + let Some(dir) = photo_folder(app, id) else { return false }; + app.caches.folder_tree_if_fresh(&app.session.catalog).is_none_or(|tree| crate::panels::left::folder_row_of(&tree, &dir).is_some()) +} + +/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library): show the folder of photo `id` +/// (the active one when `None`; the grid's menu passes the photo right-clicked) as a click on its +/// row in the sidebar's Folders section does, and bring the row into view: the sidebar, the +/// section and the folders above it open, and it scrolls there (`reveal_folder`). +fn show_in_folders(app: &mut LightcraftApp, id: Option) -> Result { + let dir = photo_folder(app, id).ok_or("this photo is not in a folder of the library")?; let tree = app.caches.folder_tree(&app.session.catalog); // the row's own spelling (forward slashes): what its click hands `library.source`, and its id let path = crate::panels::left::folder_row_of(&tree, &dir).map(|n| n.path.clone()).ok_or("this photo's folder is not in Folders")?; diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 8fee0d10a..9433a134b 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1231,8 +1231,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("app.showInFinder", json!({})); } // the photo's folder in the sidebar's Folders section, as Lightroom's Go to Folder in Library - if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFolders"), egui::Button::new(crate::i18n::tr("Show in Folders"))).clicked() { - let _ = app.run("app.showInFolders", json!({})); + // (the photo right-clicked, which a multi-selection may leave other than the active one) + if ui.add_enabled(crate::menus::show_in_folders_enabled(app, Some(id)), egui::Button::new(crate::i18n::tr("Show in Folders"))).clicked() { + let _ = app.run("app.showInFolders", json!({"id": id.0})); } if ui.button(crate::i18n::tr("Export…")).clicked() { let _ = app.run("dialog.export", json!({})); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 855843a10..46ef28922 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -1721,6 +1721,34 @@ fn show_in_folders_needs_a_photo_in_a_library_folder() { assert!(!crate::menus::ui_enabled(&demo, "app.showInFolders"), "a demo scene has no folder"); } +/// The grid's menu reveals the photo right-clicked, not the active one a multi-selection keeps. +#[test] +fn show_in_folders_reveals_the_photo_asked_for_and_follows_to_its_folder() { + let mut h = folders_app(&["/pics/trip/a.jpg", "/pics/home/c.jpg"]); + let id_of = |h: &Headless, path: &str| { + h.app.session.catalog.photos().find(|p| matches!(&p.source, lightcraft_catalog::Source::File { path: f } if f == path)).unwrap().id + }; + let (a, c) = (id_of(&h, "/pics/trip/a.jpg"), id_of(&h, "/pics/home/c.jpg")); + h.app.run("library.select", json!({"ids": [a.0, c.0], "active": a.0})).unwrap(); + h.step(); + let r = h.app.run("app.showInFolders", json!({"id": c.0})).unwrap(); + assert_eq!(r["path"], "/pics/home"); + assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/home")); + assert_eq!(h.app.session.active(), Some(c), "the view of that folder shows the photo asked for"); + assert!(crate::menus::show_in_folders_enabled(&h.app, Some(c))); +} + +/// A folder deeper than the tree goes has no row: the entry is disabled rather than failing. +#[test] +fn show_in_folders_is_disabled_for_a_folder_below_the_trees_depth() { + let deep = format!("/pics{}/a.jpg", "/d".repeat(lightcraft_catalog::folders::MAX_DEPTH + 4)); + let mut h = folders_app(&[deep.as_str()]); + activate(&mut h, &deep); + assert!(!h.app.caches.folder_tree(&h.app.session.catalog).is_empty()); + assert!(!crate::menus::ui_enabled(&h.app, "app.showInFolders")); + assert!(h.app.run("app.showInFolders", json!({})).is_err()); +} + #[test] fn a_renamed_folder_keeps_its_place_in_the_tree() { /// Removes the folder however the test ends.