From 1c4a00c35d429b51e10df321a1c5e4412eb7caff Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:34:24 -0400 Subject: [PATCH] LRC-LIB-FOLDERS: expandable folder tree in Local Each place in Local (Pictures, Desktop, Downloads, Home, the browsed folder) has a disclosure triangle listing its subfolders (hidden ones skipped; listed lazily, at most every 2 s), to any depth; folders on the way to the one in view open by themselves; clicking a folder browses it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/left.rs | 68 ++++++++++++++++++++++++++--- crates/ui-egui/src/tests_masking.rs | 27 ++++++++++++ docs/parity.md | 2 +- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 34bf9475..326c3243 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -185,13 +185,9 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { let name = std::path::Path::new(&b.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| b.path.clone()); places.push((name, b.path.clone())); } + let current = browsing.as_ref().map(|b| b.path.clone()); for (name, path) in places { - let sel = browsing.as_ref().is_some_and(|b| b.path == path); - if row(app, ui, &format!("local:{path}"), Icon::Folder, &name, None, sel, 0.0).on_hover_text(&path).clicked() - && let Err(e) = app.run("library.browse", json!({"path": path})) - { - app.toast(ui.ctx(), e); - } + folder_tree(app, ui, &name, &path, 0.0, current.as_deref()); } if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, "Browse Folder…", None, false, 0.0).clicked() { let picked = app.services.pick_folder.as_mut().and_then(|f| f()); @@ -204,6 +200,66 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.add_space(10.0); } +/// The subfolders of `path` (not hidden ones), sorted; listed at most every 2 s per folder. +fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> { + let id = egui::Id::new(("subfolders", path.to_string())); + let now = ui.input(|i| i.time); + if let Some((t, v)) = ui.data(|d| d.get_temp::<(f64, Vec<(String, String)>)>(id)) + && now - t < 2.0 + { + return v; + } + let mut v: Vec<(String, String)> = std::fs::read_dir(path) + .map(|rd| { + rd.flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + (!name.starts_with('.')).then(|| (name, e.path().to_string_lossy().to_string())) + }) + .collect() + }) + .unwrap_or_default(); + v.sort_by_key(|(n, _)| n.to_lowercase()); + ui.data_mut(|d| d.insert_temp(id, (now, v.clone()))); + v +} + +/// A folder on disk with a disclosure triangle: click browses it, the triangle lists its +/// subfolders (expanded on the way to the folder being browsed). +fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &str, indent: f32, current: Option<&str>) { + let t = Tokens::get(ui.ctx()); + let open_id = egui::Id::new(("folder-open", path.to_string())); + let on_the_way = current.is_some_and(|c| c != path && std::path::Path::new(c).starts_with(path)); + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(on_the_way); + let sel = current == Some(path); + let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent + 12.0).on_hover_text(path); + let c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y); + let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + let tr = ui.interact(tri, egui::Id::new(("folder-tri", path.to_string())), Sense::click()); + register(ui.ctx(), format!("folderToggle:{path}"), tri); + let col = if tr.hovered() { t.text } else { t.text_dim }; + let pts = if open { + vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] + } else { + vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE)); + if tr.clicked() { + open = !open; + ui.data_mut(|d| d.insert_temp(open_id, open)); + } else if resp.clicked() + && let Err(e) = app.run("library.browse", json!({"path": path})) + { + app.toast(ui.ctx(), e); + } + if open && indent < 12.0 * 8.0 { + for (n, p) in subfolders(ui, path) { + folder_tree(app, ui, &n, &p, indent + 12.0, current); + } + } +} + /// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open. fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 5aee602b..e7a15a5e 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -345,3 +345,30 @@ fn b_adds_to_quick_collection_in_the_grid_and_brushes_in_edit() { assert_eq!(h.app.session.catalog.album_count(q), 1); h.settle(SETTLE); } + +#[test] +fn local_folder_tree_expands_and_browses() { + let base = std::env::temp_dir().join(format!("lc-ui-tree-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("Trip/Day 1")).unwrap(); + std::fs::create_dir_all(base.join(".hidden")).unwrap(); + let mut h = detail("panel.edit"); + exec(&mut h, "view.leftPanel", json!({"show": true})); + exec(&mut h, "library.browse", json!({"path": base.to_string_lossy()})); + h.settle(SETTLE); + let base_s = base.to_string_lossy().to_string(); + let r = h.request("ui.clickWidget", json!({"id": format!("folderToggle:{base_s}")}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..3 { + h.step(); + } + let trip = base.join("Trip").to_string_lossy().to_string(); + let r = h.request("ui.clickWidget", json!({"id": format!("source:local:{trip}")}), T); + assert_eq!(r["ok"], true, "the subfolder is listed: {r}"); + assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(trip.clone()), "clicking it browses it"); + let hidden = base.join(".hidden").to_string_lossy().to_string(); + let r = h.request("ui.clickWidget", json!({"id": format!("source:local:{hidden}")}), T); + assert_ne!(r["ok"], true, "hidden folders are not listed"); + let _ = std::fs::remove_dir_all(&base); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index e2f4968c..cd8cfd7d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -693,7 +693,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-FOLDERS | Disk folder tree | P1 | 🟡 | `cmd:library.browse`, `crates/ui-egui/src/panels/left.rs` (`local_section`) | standard places + the browsed folder + Browse Folder…, breadcrumb navigation upwards; no expandable folder tree, no folder rename/move | +| LRC-LIB-FOLDERS | Disk folder tree | P1 | 🟡 | `cmd:library.browse`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders (opened on the way to the folder in view), breadcrumb navigation; no folder rename / move on disk | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |