feat(ui): add Photo > Show in Folders (#2437)

Selects the active photo's folder in the sidebar's Folders section,
opens the rows above it and scrolls it into view. Also on the photo
right-click menu. Adds the parity row and the label in all locales.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Arekatla Nishanth Chowdary
2026-10-21 13:02:31 +05:30
co-authored by Claude Sonnet 5.5
parent abb49bdd76
commit 4ab5dcc319
21 changed files with 149 additions and 4 deletions
+1
View File
@@ -1606,6 +1606,7 @@
"Show in Explorer": "Zobrazit v Průzkumníku",
"Show in File Manager": "Zobrazit ve správci souborů",
"Show in Finder": "Zobrazit ve Finderu",
"Show in Folders": "Zobrazit ve složkách",
"Show less": "Zobrazit méně",
"Show on Map": "Zobrazit na mapě",
"Show overlay (O)": "Zobrazit překrytí (O)",
+1
View File
@@ -1428,6 +1428,7 @@
"Show in Explorer": "Im Explorer anzeigen",
"Show in File Manager": "Im Dateimanager anzeigen",
"Show in Finder": "Im Finder anzeigen",
"Show in Folders": "In Ordnern anzeigen",
"Show on Map": "Auf Karte anzeigen",
"Show overlay (O)": "Überlagerung anzeigen (O)",
"Show ratings, flags and edit badges": "Bewertungen, Markierungen und Bearbeitungssymbole anzeigen",
+1
View File
@@ -1441,6 +1441,7 @@
"Show in Explorer": "Mostrar en el Explorador",
"Show in File Manager": "Mostrar en el gestor de archivos",
"Show in Finder": "Mostrar en el Finder",
"Show in Folders": "Mostrar en Carpetas",
"Show on Map": "Mostrar en el mapa",
"Show overlay (O)": "Mostrar superposición (O)",
"Show ratings, flags and edit badges": "Mostrar valoraciones, marcas e indicadores de edición",
+1
View File
@@ -1364,6 +1364,7 @@
"Show in Explorer": "Afficher dans l'Explorateur",
"Show in File Manager": "Afficher dans le gestionnaire de fichiers",
"Show in Finder": "Afficher dans le Finder",
"Show in Folders": "Afficher dans les dossiers",
"Show on Map": "Afficher sur la carte",
"Show overlay (O)": "Afficher l'incrustation (O)",
"Show ratings, flags and edit badges": "Afficher les notes, les drapeaux et les pastilles de retouche",
+1
View File
@@ -1341,6 +1341,7 @@
"Show in Explorer": "Mostra in Esplora file",
"Show in File Manager": "Mostra nel gestore file",
"Show in Finder": "Mostra nel Finder",
"Show in Folders": "Mostra in Cartelle",
"Show on Map": "Mostra sulla mappa",
"Show overlay (O)": "Mostra sovrapposizione (O)",
"Show ratings, flags and edit badges": "Mostra valutazioni, contrassegni e indicatori di modifica",
+1
View File
@@ -1310,6 +1310,7 @@
"Show in Explorer": "エクスプローラーで表示",
"Show in File Manager": "ファイルマネージャーで表示",
"Show in Finder": "Finderで表示",
"Show in Folders": "フォルダで表示",
"Show on Map": "地図で表示",
"Show overlay (O)": "重ね表示(O)",
"Show ratings, flags and edit badges": "評価・フラグ・編集済みマークを表示",
+1
View File
@@ -1927,6 +1927,7 @@
"Show in Explorer": "Explorer-ში ჩვენება",
"Show in File Manager": "ფაილების მენეჯერში ჩვენება",
"Show in Finder": "Finder-ში ჩვენება",
"Show in Folders": "საქაღალდეებში ჩვენება",
"Show less": "ნაკლების ჩვენება",
"Show on Map": "რუკაზე ჩვენება",
"Show overlay (O)": "ზედდების ჩვენება (O)",
+1
View File
@@ -1338,6 +1338,7 @@
"Show in Explorer": "Pokaż w Eksploratorze",
"Show in File Manager": "Pokaż w menedżerze plików",
"Show in Finder": "Pokaż w Finderze",
"Show in Folders": "Pokaż w folderach",
"Show on Map": "Pokaż na mapie",
"Show overlay (O)": "Pokaż nakładkę (O)",
"Show ratings, flags and edit badges": "Pokaż oceny, flagi i znaczniki edycji",
+1
View File
@@ -1387,6 +1387,7 @@
"Show in Explorer": "Mostrar no Explorador de Arquivos",
"Show in File Manager": "Mostrar no gerenciador de arquivos",
"Show in Finder": "Mostrar no Finder",
"Show in Folders": "Mostrar em Pastas",
"Show on Map": "Mostrar no mapa",
"Show overlay (O)": "Mostrar sobreposição (O)",
"Show ratings, flags and edit badges": "Mostrar avaliações, marcações e indicadores de edição",
+1
View File
@@ -1155,6 +1155,7 @@
"Show in Explorer": "Показать в «Проводнике»",
"Show in File Manager": "Показать в файловом менеджере",
"Show in Finder": "Показать в Finder",
"Show in Folders": "Показать в папках",
"Show on Map": "Показать на карте",
"Show overlay (O)": "Показать наложение (O)",
"Show ratings, flags and edit badges": "Показывать оценки, флаги и значки правок",
+1
View File
@@ -1379,6 +1379,7 @@
"Show in Explorer": "Показати в Провіднику",
"Show in File Manager": "Показати у файловому менеджері",
"Show in Finder": "Показати у Finder",
"Show in Folders": "Показати в теках",
"Show on Map": "Показати на карті",
"Show overlay (O)": "Показати накладання (O)",
"Show ratings, flags and edit badges": "Показати оцінки, прапорці й позначки змін",
+1
View File
@@ -1372,6 +1372,7 @@
"Show in Explorer": "在文件资源管理器中显示",
"Show in File Manager": "在文件管理器中显示",
"Show in Finder": "在 Finder 中显示",
"Show in Folders": "在文件夹中显示",
"Show on Map": "在地图上显示",
"Show overlay (O)": "显示叠加 (O)",
"Show ratings, flags and edit badges": "显示星级、旗标和编辑标记",
+1
View File
@@ -1372,6 +1372,7 @@
"Show in Explorer": "在檔案總管中顯示",
"Show in File Manager": "在檔案管理員中顯示",
"Show in Finder": "在 Finder 中顯示",
"Show in Folders": "在資料夾中顯示",
"Show on Map": "在地圖上顯示",
"Show overlay (O)": "顯示疊加(O)",
"Show ratings, flags and edit badges": "顯示評分、旗標與編輯標記",
+3
View File
@@ -334,6 +334,9 @@ pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool {
pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> {
let panel = if id == "panel.presets" && app.ui.presets {
Some(Panel::Presets)
} else if id == "app.showInFolders" {
// the sidebar it scrolls, also when it is a tab behind another panel
Some(Panel::Library)
} else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets"
|| id.starts_with("section.")
|| id.starts_with("tool.") && id != "tool.none")
+1
View File
@@ -212,6 +212,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"photo.reload",
"photo.readFocusPoints",
"app.showInFinder",
"app.showInFolders",
"dialog.rename",
"dialog.captureTime",
"photo.tagFromTracklog",
+27
View File
@@ -257,6 +257,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""),
("app.export", "Export Now", None, ""),
("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"),
("app.showInFolders", "Show in Folders", None, "Photo"),
("app.openSamModelFolder", "Show in File Manager", None, ""),
("dialog.rename", "Rename Photos…", Some("F2"), "Photo"),
("dialog.labelNames", "Edit Color Label Names…", None, ""),
@@ -2106,6 +2107,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
"app.export" => crate::control::export_active(app, p),
"app.showInFinder" => show_in_finder(app),
"app.showInFolders" => show_in_folders(app),
"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);
@@ -2160,6 +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.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(),
@@ -2289,6 +2292,30 @@ fn show_in_finder(app: &mut LightcraftApp) -> Result<Value, String> {
Ok(json!({"path": path}))
}
/// 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<String> {
let photo = 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<Value, String> {
let dir = active_folder(app).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")?;
app.run("library.source", json!({"kind": "libraryFolder", "path": path}))?;
app.ui.left_panel = true;
app.ui.reveal_folder = Some(path.clone());
Ok(json!({"path": path}))
}
/// Photo ▸ Edit In: render a copy of each selected photo with its edits (stacked on its original)
/// as Settings ▸ External Editing asks for that editor, then open them there. `p` may override the
/// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first,
+4
View File
@@ -1230,6 +1230,10 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if ui.add_enabled(crate::menus::ui_enabled(app, "app.showInFinder"), egui::Button::new(crate::i18n::tr(crate::menus::reveal_label()))).clicked() {
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!({}));
}
if ui.button(crate::i18n::tr("Export…")).clicked() {
let _ = app.run("dialog.export", json!({}));
}
+23 -1
View File
@@ -28,6 +28,10 @@ const ROW_HEIGHT: f32 = 29.0;
/// this frame: set before the sections are drawn, cleared after.
const SCROLL_TO_ROW: &str = "left-scroll-to-row";
/// Temp-data key of the Folders choice whose rows above were last opened ([`reveal_chosen`]):
/// removed, they open again.
const LIBFOLDER_REVEALED: &str = "libfolder-revealed";
/// A row says how wide it needs to be.
fn note_width(ui: &egui::Ui, w: f32) {
ui.data_mut(|d| {
@@ -670,6 +674,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// …and the sidebar scrolls to it
ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("keyword:{path}").to_lowercase()));
}
// the folder Show in Folders went to: Folders is shown and opened, and so are the
// folders above it, also when it was already the one shown and one was folded by hand…
if let Some(path) = app.ui.reveal_folder.take() {
app.ui.sidebar.reveal(SidebarSection::Folders);
ui.data_mut(|d| d.remove::<Option<String>>(egui::Id::new(LIBFOLDER_REVEALED)));
// …and the sidebar scrolls to it
ui.data_mut(|d| d.insert_temp(egui::Id::new(SCROLL_TO_ROW), format!("libfolder:{path}").to_lowercase()));
}
// Local keeps track of the folder being browsed whether or not it is drawn
let local = local_now(app, ui);
let shown: Vec<SidebarSection> = app.ui.sidebar.shown().collect();
@@ -1713,7 +1725,7 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig
fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) {
let shown = app.session.library_folder.clone().filter(|_| app.session.source == LibrarySource::LibraryFolder);
let chosen = shown.filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty());
let seen = egui::Id::new("libfolder-revealed");
let seen = egui::Id::new(LIBFOLDER_REVEALED);
let now = chosen.as_deref().map(lightcraft_catalog::query::folder_key);
if ui.data(|d| d.get_temp::<Option<String>>(seen)) == Some(now.clone()) {
return;
@@ -1732,6 +1744,16 @@ fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) {
open_above(ui, tree, &chosen);
}
/// The Folders row that stands for folder `dir`, however it is spelled. `None` when no row is that
/// folder.
pub(crate) fn folder_row_of<'a>(tree: &'a [FolderNode], dir: &str) -> Option<&'a FolderNode> {
// only the rows that hold `dir` are opened (the startup disk's holds the other disks' paths
// too, so more than one may), and the tree is at most `MAX_DEPTH` deep
tree.iter()
.filter(|n| lightcraft_catalog::query::folder_within(dir, &n.path))
.find_map(|n| if same_folder(&n.path, dir) { Some(n) } else { folder_row_of(&n.children, dir) })
}
fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) {
for n in nodes {
let key = lightcraft_catalog::query::folder_key(&n.path);
+5
View File
@@ -796,6 +796,10 @@ pub struct UiState {
/// A keyword just made from the sidebar: Keywords opens down to it, once.
#[serde(skip)]
pub reveal_keyword: Option<String>,
/// A folder Photo ▸ Show in Folders went to (its Folders row's path): the sidebar's Folders
/// section opens down to it and scrolls to it, once.
#[serde(skip)]
pub reveal_folder: Option<String>,
/// An album or folder row being dragged in the sidebar (dropped on a folder to move it there).
#[serde(skip)]
pub dragging_album: Option<u64>,
@@ -1337,6 +1341,7 @@ impl Default for UiState {
dragging_photos: None,
reveal_album: None,
reveal_keyword: None,
reveal_folder: None,
dragging_album: None,
dragging_section: None,
curve_channel: "parametric".into(),
+69
View File
@@ -1652,6 +1652,75 @@ fn the_chosen_folder_is_always_in_view() {
assert!(!has(&h, "source:libfolder:/pics/trip/day1"));
}
/// Make the photo imported from `path` the active one.
fn activate(h: &mut Headless, path: &str) {
let id = h.app.session.catalog.photos().find(|p| matches!(&p.source, lightcraft_catalog::Source::File { path: f } if f == path)).unwrap().id;
h.app.run("library.select", json!({"ids": [id.0], "active": id.0})).unwrap();
h.step();
}
/// Photo ▸ Show in Folders (Lightroom's Go to Folder in Library) shows the active photo's folder
/// as a click on its Folders row does: the sidebar, the section and the folders above the row open,
/// also when the folder was already shown and a row above it was folded by hand.
#[test]
fn show_in_folders_chooses_the_photos_folder_and_opens_the_rows_above_it() {
use crate::sidebar::SidebarSection;
let mut h = folders_app(&["/pics/trip/day1/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"]);
activate(&mut h, "/pics/trip/day1/a.jpg");
assert!(crate::menus::ui_enabled(&h.app, "app.showInFolders"));
click(&mut h, "sidebarSection:folders");
h.app.run("view.leftPanel", json!({})).unwrap();
h.step();
assert!(!h.app.ui.left_panel && !has(&h, "sidebarTitle"), "the sidebar is hidden");
let r = h.request("ui.menu.invoke", json!({"id": "app.showInFolders"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::LibraryFolder);
assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/trip/day1"));
assert_eq!(h.app.session.visible().len(), 1, "the folder's photos fill the grid");
assert_eq!(h.app.session.active(), h.app.session.visible().first().copied(), "the photo stays the active one");
assert!(h.app.ui.left_panel && !h.app.ui.sidebar.is_collapsed(SidebarSection::Folders), "the sidebar and Folders opened");
assert!(has(&h, "source:libfolder:/pics/trip/day1") && has(&h, "highlight:libfolder:/pics/trip/day1"), "its row is shown chosen");
// folded by hand, then asked again: the rows above open again
click(&mut h, "libraryFolderToggle:/pics/trip");
assert!(!has(&h, "source:libfolder:/pics/trip/day1"));
h.app.run("app.showInFolders", json!({})).unwrap();
h.step();
h.step();
assert!(has(&h, "source:libfolder:/pics/trip/day1"), "opened again");
}
/// A folder far down the list is scrolled into view.
#[test]
fn show_in_folders_scrolls_the_folder_into_view() {
let paths: Vec<String> = (0..60).map(|i| format!("/pics/f{i:02}/a.jpg")).collect();
let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
let mut h = folders_app_sized(&refs, [1400.0, 500.0]);
activate(&mut h, "/pics/f59/a.jpg");
h.app.run("app.showInFolders", json!({})).unwrap();
h.settle(SETTLE);
let row = widget(&h, "source:libfolder:/pics/f59");
assert!(row.top() >= widget(&h, "sidebarTitle").bottom() && row.bottom() <= 500.0, "in view: {row:?}");
}
/// Without an active photo in a library folder there is nothing to show: no photo, a demo scene.
#[test]
fn show_in_folders_needs_a_photo_in_a_library_folder() {
let mut h = folders_app(&["/pics/trip/a.jpg"]);
h.app.run("library.select", json!({"ids": []})).unwrap();
assert_eq!(h.app.session.active(), None);
assert!(!crate::menus::ui_enabled(&h.app, "app.showInFolders"));
let entry = crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "app.showInFolders").unwrap();
assert!(!entry.enabled && entry.menu == ["Photo"], "listed in Photo, disabled");
assert!(h.app.run("app.showInFolders", json!({})).is_err());
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All, "nothing changed");
let mut demo = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let scene = demo.session.visible().first().copied().unwrap();
demo.run("library.select", json!({"ids": [scene.0]})).unwrap();
assert!(!crate::menus::ui_enabled(&demo, "app.showInFolders"), "a demo scene has no folder");
}
#[test]
fn a_renamed_folder_keeps_its_place_in_the_tree() {
/// Removes the folder however the test ends.
+4 -3
View File
@@ -50,12 +50,12 @@ cited here still exists. Formerly docs/parity.md.
| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) |
| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) |
| Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) |
| **Total** | 419 | 61 | 59 | 35 | 194/201 (97%) | 148/161 (92%) |
| **Total** | 420 | 61 | 59 | 35 | 194/201 (97%) | 148/161 (92%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.4%** of 539 in-scope rows — P0 98.3% of 201 · P1 95.0% of 161 · P2 55.7% of 176.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.4%** of 540 in-scope rows — P0 98.3% of 201 · P1 95.0% of 161 · P2 55.9% of 177.
<!-- /parity:summary -->
## Top gaps and shortcut notes
@@ -515,6 +515,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-FILE-SHARE | Share / get link / invite | OOS | 🚫 | | |
| MENU-FILE-PHOTOSHOP | Edit in external editor | P2 | ✅ | `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal` | Photo ▸ Edit In ▸ Edit in PhotoCraft / Edit in Other Editor / Open Original in Other Editor (RAW, no copy), and Edit In on a photo's right-click menu |
| MENU-FILE-SHOWFINDER | Show in Finder | P0 | ✅ | `cmd:app.showInFinder` | ⌘R; Explorer on Windows, the folder on Linux; disabled for demo scenes and on the web |
| MENU-PHOTO-SHOWFOLDERS | Show in Folders | P2 | ✅ | `cmd:app.showInFolders`, `crates/ui-egui/src/panels/left.rs` (`folder_row_of`), tests `show_in_folders_*` in `tests_panels.rs` | Photo ▸ Show in Folders and the photo right-click menu: selects the active photo's folder in the sidebar's Folders section, opens the rows above it and scrolls to it; opens the Library sidebar if it is behind another tab; disabled for demo scenes and photos outside a library folder |
| MENU-FILE-OFFLINE | Store album locally | P2 | 🚫 | | not applicable: local-first |
| MENU-FILE-CLOSE | Close Window | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | the system's Close Window item at the end of File (⌘W) |
| MENU-EDIT-UNDO | Undo | P0 | ✅ | `cmd:edit.undo` | label does not name the step |