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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 22:34:24 -04:00
parent b163d0e16e
commit 1c4a00c35d
3 changed files with 90 additions and 7 deletions

View File

@ -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());

View File

@ -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);
}

View File

@ -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 | ⬜ | | |