Merge pull request #65 from storytold/fix/issue-21-local-roots

Issue #21: Local: kept folders stay roots while browsing below them
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-04 13:59:00 -04:00
commit f1dce86609
5 files changed
+211 -28

No files matched your search

+48
View File
@@ -827,6 +827,54 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Local: a kept folder stays a root while its subfolders (and other locations) are
/// browsed — the subfolder is highlighted inside its tree, its siblings stay listed — and
/// the kept folders survive a save/load of the UI state (a restart).
#[test]
fn kept_local_root_stays_while_browsing_below_and_elsewhere() {
let mut h = demo([1300.0, 1400.0]);
let t = Duration::from_secs(10);
let base = std::env::temp_dir().join(format!("lc-ui-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["Photos/2026/20260101", "Photos/2026/20260114", "Other"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
let (photos, day1, day2, other) =
(s(base.join("Photos")), s(base.join("Photos/2026/20260101")), s(base.join("Photos/2026/20260114")), s(base.join("Other")));
let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t);
let rects = |h: &mut Headless| -> std::collections::HashMap<String, f64> {
let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t);
w["result"].as_array().unwrap().iter().map(|x| (x["id"].as_str().unwrap().to_string(), x["rect"][0].as_f64().unwrap_or(0.0))).collect()
};
h.request("ui.set", json!({"leftPanel": true}), t);
assert_eq!(exec(&mut h, "local.addRoot", json!({"path": photos}))["ok"], true);
assert_eq!(exec(&mut h, "local.addRoot", json!({"path": format!("{photos}/")}))["result"]["roots"].as_array().map(Vec::len), Some(1));
exec(&mut h, "library.browse", json!({"path": day1}));
h.settle(SETTLE);
h.step();
let r = rects(&mut h);
assert!(r.contains_key(&format!("source:local:{photos}")), "the kept root stays: {r:?}");
assert!(r.contains_key(&format!("source:local:{day2}")), "the sibling stays reachable: {r:?}");
let (root_x, child_x) = (r[&format!("folderToggle:{photos}")], r[&format!("folderToggle:{day1}")]);
assert!(child_x > root_x, "the browsed folder is inside the root's tree, not a root of its own ({child_x} vs {root_x})");
assert_eq!(h.request("ui.clickWidget", json!({"id": format!("source:local:{day2}")}), t)["ok"], true);
h.settle(SETTLE);
assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(day2.clone()), "the sibling is browsed");
// another location: the kept root stays listed
exec(&mut h, "library.browse", json!({"path": other}));
h.settle(SETTLE);
h.step();
let r = rects(&mut h);
assert!(r.contains_key(&format!("source:local:{photos}")) && r.contains_key(&format!("source:local:{other}")), "{r:?}");
// restart: the kept roots come back with the saved UI state
let saved = serde_json::to_string(&h.app.ui).unwrap();
let back: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(back.local_roots, vec![photos.clone()]);
let _ = std::fs::remove_dir_all(&base);
h.settle(SETTLE);
}
/// Versions panel: resting on a version previews it in the loupe without changing the photo;
/// a click restores it.
#[test]
+16
View File
@@ -53,6 +53,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.back", "Back to Grid", Some("Escape"), ""),
("tool.done", "Done", Some("Enter"), ""),
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
("local.addRoot", "Add Folder to Local", None, ""),
("local.hide", "Remove from Local", None, ""),
("local.restoreHidden", "Show Hidden Local Locations", None, ""),
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
@@ -555,6 +556,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.crop_overlay_orient = (app.ui.crop_overlay_orient + 1) % 4;
Ok(json!({"orientation": app.ui.crop_overlay_orient}))
}
"local.addRoot" => {
// a folder kept in Local's sidebar (saved with the UI state); nothing on disk changes
let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(Err("local.addRoot needs a path".into())) };
let abs = std::path::absolute(path).map(|a| a.to_string_lossy().trim_end_matches(['/', '\\']).to_string()).unwrap_or(path.into());
let abs = if abs.is_empty() { path.to_string() } else { abs };
if !std::path::Path::new(&abs).is_dir() {
return Some(Err(format!("{path}: not a folder")));
}
use crate::panels::left::same_folder;
if !app.ui.local_roots.iter().any(|r| same_folder(r, &abs)) {
app.ui.local_roots.push(abs.clone());
}
app.ui.hidden_locations.retain(|h| !same_folder(h, &abs));
Ok(json!({"roots": app.ui.local_roots}))
}
"local.hide" => match p.get("path").and_then(Value::as_str) {
Some(path) => {
if !app.ui.hidden_locations.iter().any(|h| crate::panels::left::same_folder(h, path)) {
+139 -26
View File
@@ -208,21 +208,23 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder);
let current = browsing.as_ref().map(|b| b.path.clone());
let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), &app.ui.hidden_locations);
for (name, path) in places {
folder_tree(app, ui, &name, &path, 0.0, current.as_deref());
let local = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations);
if current.is_some() {
app.ui.local_browse_root = local.browse_root.clone();
}
for (i, (name, path)) in local.places.iter().enumerate() {
let transient = local.browse_root.as_deref() == Some(path.as_str());
let reveal = local.owner == Some(i);
folder_tree(app, ui, name, path, 0.0, current.as_deref(), reveal, transient);
}
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());
if let Some(path) = picked {
// choosing a folder again puts it back in the list
let _ = app.run("local.restoreHidden", json!({"path": path}));
match app.run("library.browse", json!({"path": path})) {
// the picked folder stays in Local (and comes back if it was hidden)
Ok(r) => {
let dir = r["path"].as_str().unwrap_or(&path).to_string();
if !app.ui.local_roots.iter().any(|p| same_folder(p, &dir)) {
app.ui.local_roots.push(dir);
}
let _ = app.run("local.addRoot", json!({"path": dir}));
}
Err(e) => app.toast(ui.ctx(), e),
}
@@ -247,20 +249,61 @@ pub(crate) fn same_folder(a: &str, b: &str) -> bool {
a == b || lightcraft_catalog::query::folder_key(a) == lightcraft_catalog::query::folder_key(b)
}
/// Local's top-level folders, in order: the built-in places, the folders picked with Browse
/// Folder…, then the folder being browsed when it isn't one of those. Each folder is listed
/// Local's top-level folders and how the folder being browsed sits among them.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct LocalPlaces {
/// (label, path) of each top-level folder, in order.
pub places: Vec<(String, String)>,
/// The top-level folder the browsed folder lies in (the innermost one): its tree opens on
/// the way down to it.
pub owner: Option<usize>,
/// A folder listed only for this session because the browsed folder is in no saved
/// location (browsed from a breadcrumb, the CLI…); it stays while browsing below it.
pub browse_root: Option<String>,
}
fn folder_label(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
/// Local's top-level folders, in order: the built-in places, the folders kept with Browse
/// Folder… / Keep in Local (`saved`), then — only when the browsed folder lies in none of those —
/// a session root for it: the previous one (`browse_root`) while browsing stays below it, else
/// the browsed folder itself. A folder inside a listed one is shown inside that one's tree (the
/// root stays; its siblings stay reachable), never as a root of its own. Each folder is listed
/// once however its path is spelled (the first spelling wins), and hidden ones are left out —
/// they stay reachable through breadcrumbs and Browse Folder….
pub(crate) fn local_places(builtin: Vec<(String, String)>, saved: &[String], browsing: Option<&str>, hidden: &[String]) -> Vec<(String, String)> {
pub(crate) fn local_places(
builtin: Vec<(String, String)>,
saved: &[String],
browsing: Option<&str>,
browse_root: Option<&str>,
hidden: &[String],
) -> LocalPlaces {
use lightcraft_catalog::query::{folder_key, folder_within};
let is_hidden = |p: &str| hidden.iter().any(|h| same_folder(h, p));
let mut places = builtin;
for path in saved.iter().map(String::as_str).chain(browsing) {
for path in saved {
if !places.iter().any(|(_, p)| same_folder(p, path)) {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
places.push((name, path.to_string()));
places.push((folder_label(path), path.clone()));
}
}
places.retain(|(_, p)| !hidden.iter().any(|h| same_folder(h, p)));
places
places.retain(|(_, p)| !is_hidden(p));
let mut out = LocalPlaces::default();
if let Some(c) = browsing
&& !places.iter().any(|(_, p)| folder_within(c, p))
{
let root = browse_root.filter(|r| folder_within(c, r)).unwrap_or(c);
if !is_hidden(root) {
places.push((folder_label(root), root.to_string()));
out.browse_root = Some(root.to_string());
}
}
if let Some(c) = browsing {
out.owner = places.iter().enumerate().filter(|(_, (_, p))| folder_within(c, p)).max_by_key(|(_, (_, p))| folder_key(p).len()).map(|(i, _)| i);
}
out.places = places;
out
}
/// The subfolders of `path` (not hidden ones), sorted; listed at most every 2 s per folder.
@@ -289,13 +332,37 @@ fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> {
}
/// 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>) {
/// subfolders. In the tree that holds the folder being browsed (`reveal`), the folders on the
/// way down to it open whenever the browsed folder changes, so it shows highlighted in place.
/// `transient`: a top-level row listed for this session only (it offers Keep in Local).
#[allow(clippy::too_many_arguments)]
fn folder_tree(
app: &mut LightcraftApp,
ui: &mut egui::Ui,
name: &str,
path: &str,
indent: f32,
current: Option<&str>,
reveal: bool,
transient: bool,
) {
let t = Tokens::get(ui.ctx());
let open_id = egui::Id::new(("folder-open", path.to_string()));
let sel = current.is_some_and(|c| same_folder(c, path));
let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path));
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(on_the_way);
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false);
if reveal && on_the_way {
// opened once per browsed folder: collapsing it again afterwards sticks
let revealed_id = egui::Id::new(("folder-revealed", path.to_string()));
let target = current.map(str::to_string);
if ui.data(|d| d.get_temp::<Option<String>>(revealed_id)) != Some(target.clone()) {
open = true;
ui.data_mut(|d| {
d.insert_temp(open_id, true);
d.insert_temp(revealed_id, target);
});
}
}
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));
@@ -317,6 +384,12 @@ fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &st
app.toast(ui.ctx(), e);
}
resp.context_menu(|ui| {
if (transient || indent > 0.0)
&& ui.button(if transient { "Keep in Local" } else { "Add to Local" }).on_hover_text("List this folder in Local from now on").clicked()
{
let _ = app.run("local.addRoot", json!({"path": path}));
ui.close();
}
if indent == 0.0
&& ui.button("Remove from Local").on_hover_text("Hides this shortcut only; the folder and its photos stay as they are").clicked()
{
@@ -351,7 +424,7 @@ fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &st
});
if open && indent < 12.0 * 8.0 {
for (n, p) in subfolders(ui, path) {
folder_tree(app, ui, &n, &p, indent + 12.0, current);
folder_tree(app, ui, &n, &p, indent + 12.0, current, reveal, false);
}
}
}
@@ -626,16 +699,56 @@ mod tests {
// the browsed spelling: other separators, a trailing one, a different drive-letter case
let flipped = p.replace(['/', '\\'], if sep == '/' { "\\" } else { "/" });
for browsing in [flipped.clone(), format!("{p}{sep}"), p.replacen("D:", "d:", 1)] {
let places = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), &[]);
assert_eq!(names(&places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}");
let l = local_places(builtin.clone(), std::slice::from_ref(&browsing), Some(&browsing), None, &[]);
assert_eq!(names(&l.places), ["Pictures", "Desktop", "Downloads"], "{p} browsed as {browsing}");
assert_eq!(l.places[l.owner.unwrap()].1, p, "the built-in row is the one highlighted");
}
// hiding under one spelling hides the other
let places = local_places(builtin.clone(), &[], None, std::slice::from_ref(&flipped));
assert_eq!(places.len(), 2, "hidden {flipped}");
let l = local_places(builtin.clone(), &[], None, None, std::slice::from_ref(&flipped));
assert_eq!(l.places.len(), 2, "hidden {flipped}");
}
}
// a different folder is still added
let places = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), &[]);
assert_eq!(names(&places), ["Pictures", "Pictures2"]);
let l = local_places(vec![("Pictures".into(), "/home/example/Pictures".into())], &[], Some("/home/example/Pictures2"), None, &[]);
assert_eq!(names(&l.places), ["Pictures", "Pictures2"]);
}
/// A folder inside a kept root is shown inside that root's tree: the root stays (its other
/// subfolders stay reachable) and no second root appears; other kept roots stay too.
#[test]
fn browsing_below_a_kept_root_keeps_the_root() {
let photos = "/data/Photos".to_string();
let other = "/data/Scans".to_string();
let saved = [photos.clone(), other.clone()];
let builtin = || vec![("Home".to_string(), "/home/example".to_string())];
for browsing in ["/data/Photos/2026/20260101", "/data/Photos/2026", "/data/Photos", "D:\\x"] {
let l = local_places(builtin(), &saved, Some(browsing), None, &[]);
let roots: Vec<&str> = l.places.iter().map(|(_, p)| p.as_str()).collect();
if browsing.starts_with("/data") {
assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans"], "{browsing}");
assert_eq!(l.owner, Some(1), "the Photos tree opens down to {browsing}");
assert_eq!(l.browse_root, None);
} else {
assert_eq!(roots, ["/home/example", "/data/Photos", "/data/Scans", "D:\\x"], "a folder outside them gets a row");
}
}
// the innermost containing root is the one that opens
let l = local_places(builtin(), &["/home/example/Pictures".into()], Some("/home/example/Pictures/Trip"), None, &[]);
assert_eq!(l.owner, Some(1));
}
/// A browsed folder outside every kept root gets a session row, which stays while browsing
/// below it and gives way when browsing moves elsewhere.
#[test]
fn session_root_stays_while_browsing_below_it() {
let l = local_places(Vec::new(), &[], Some("/t/base"), None, &[]);
assert_eq!(l.browse_root.as_deref(), Some("/t/base"));
let l = local_places(Vec::new(), &[], Some("/t/base/Trip/Day 1"), l.browse_root.as_deref(), &[]);
assert_eq!((l.places.len(), l.browse_root.as_deref(), l.owner), (1, Some("/t/base"), Some(0)), "{l:?}");
let l = local_places(Vec::new(), &[], Some("/elsewhere"), l.browse_root.as_deref(), &[]);
assert_eq!(l.browse_root.as_deref(), Some("/elsewhere"));
// hidden: no row at all
let l = local_places(Vec::new(), &[], Some("/t/base"), None, &["/t/base/".into()]);
assert!(l.places.is_empty() && l.browse_root.is_none());
}
}
+7 -1
View File
@@ -289,8 +289,13 @@ pub struct UiState {
pub spots_threshold: f32,
/// The library filter bar above the grid.
pub filter_bar: bool,
/// Folders picked with Local → Browse Folder…, kept in the sidebar after browsing elsewhere.
/// Folders kept in Local (Browse Folder…, Keep in Local / Add to Local; `local.addRoot`):
/// listed after the built-in locations, across restarts, whatever is browsed.
pub local_roots: Vec<String>,
/// The top-level Local row listed for this session only, for a browsed folder outside every
/// kept location; it stays while browsing below it (see `panels::left::local_places`).
#[serde(skip)]
pub local_browse_root: Option<String>,
/// Culling: after a rating, flag or colour-label key, move to the next photo.
pub auto_advance: bool,
/// Full-screen preview (F): the photo alone on black, no chrome.
@@ -544,6 +549,7 @@ impl Default for UiState {
window_fullscreen: None,
filter_bar: false,
local_roots: Vec::new(),
local_browse_root: None,
compare: None,
reference: None,
toast: None,
+1 -1
View File
@@ -108,7 +108,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it |
| LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | |
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`) |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2, ARW, NEF, RAF, RW2, PEF, ORF. Missing: CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |