Merge pull request #64 from storytold/fix/issue-22-local-path-identity

Issue #22: Local: recognise equivalent folder paths
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-04 13:58:47 -04:00
commit 9d756b145d
5 files changed
+145 -19

No files matched your search

+46
View File
@@ -351,6 +351,52 @@ pub fn in_folder(path: &str, dir: &str, deep: bool) -> bool {
!rest.is_empty() && (deep || !rest.contains(['/', '\\']))
}
/// A folder path's identity, for telling whether two spellings name the same folder: `/` and
/// `\\` are both separators, repeated separators, `.` and a trailing separator are dropped,
/// `..` is resolved lexically, a Windows verbatim prefix (`\\?\`) is removed and the drive
/// letter lower-cased; on Windows (case-insensitive file names) the whole path is lower-cased.
/// No file-system access: the paths compared should already be absolute.
pub fn folder_key(path: &str) -> String {
let mut s = path.replace('\\', "/");
if let Some(rest) = s.strip_prefix("//?/").or_else(|| s.strip_prefix("//./")) {
s = match rest.strip_prefix("UNC/") {
Some(unc) => format!("//{unc}"),
None => rest.to_string(),
};
}
// the part `..` can't climb above: `//server/share`, `c:` or `/`
let (prefix, rest) = if let Some(unc) = s.strip_prefix("//").filter(|u| !u.is_empty() && !u.starts_with('/')) {
let mut it = unc.splitn(3, '/');
let (server, share, rest) = (it.next().unwrap_or(""), it.next().unwrap_or(""), it.next().unwrap_or(""));
(format!("//{server}/{share}"), rest.to_string())
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
(s[..2].to_ascii_lowercase(), s[2..].to_string())
} else {
(String::new(), s.clone())
};
let absolute = rest.starts_with('/') || !prefix.is_empty();
let mut parts: Vec<&str> = Vec::new();
for c in rest.split('/') {
match c {
"" | "." => {}
".." if parts.last().is_some_and(|p| *p != "..") => {
parts.pop();
}
".." if absolute => {}
c => parts.push(c),
}
}
let key = if absolute { format!("{prefix}/{}", parts.join("/")) } else { parts.join("/") };
if cfg!(windows) { key.to_lowercase() } else { key }
}
/// Whether `path` is folder `root` or lies somewhere inside it, however either is spelled
/// (see [`folder_key`]).
pub fn folder_within(path: &str, root: &str) -> bool {
let (p, r) = (folder_key(path), folder_key(root));
p == r || (p.starts_with(&r) && (r.ends_with('/') || p[r.len()..].starts_with('/')))
}
/// The kind of Photo Merge result a file is, from the name merges give it (`IMG_1-HDR.dng`,
/// `IMG_1-Pano.dng`, `IMG_1-HDR-Pano.dng` — also the names other raw editors use):
/// `hdr`, `panorama` or `hdrPanorama`.
+33
View File
@@ -264,3 +264,36 @@ fn preview_only_survives_serde_and_set_content_undo() {
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
assert_eq!(c.photo(a).unwrap().width, 6000);
}
/// Equivalent spellings of one folder (separators, trailing or doubled separators, `.`/`..`,
/// drive-letter case, verbatim and UNC prefixes) share an identity; different folders don't.
#[test]
fn folder_identity_ignores_spelling() {
use crate::query::{folder_key, folder_within};
let same = |a: &str, b: &str| assert_eq!(folder_key(a), folder_key(b), "{a} vs {b}");
let differ = |a: &str, b: &str| assert_ne!(folder_key(a), folder_key(b), "{a} vs {b}");
for (a, b) in [
("D:/Example/Photos", "D:\\Example\\Photos"),
("D:/Example/Photos", "d:\\Example\\Photos\\"),
("D:\\Example/Photos", "D:/Example//Photos/."),
("D:/Example/Photos", "D:/Example/Other/../Photos"),
("D:/Example/Photos", "\\\\?\\D:\\Example\\Photos"),
("\\\\server\\share\\Photos", "//server/share/Photos/"),
("\\\\?\\UNC\\server\\share\\Photos", "//server/share/Photos"),
("/home/me/Photos", "/home/me/Photos/"),
("/home/me/Photos", "/home//me/./Photos"),
("/", "//"),
("C:\\", "c:/"),
] {
same(a, b);
}
differ("D:/Example/Photos", "D:/Example/Photos2");
differ("D:/Example/Photos", "E:/Example/Photos");
differ("/home/me/Photos", "/home/me");
assert_eq!(folder_key("/a/../../b"), "/b", "`..` stops at the root");
assert!(folder_within("D:\\Example\\Photos\\2026", "D:/Example/Photos/"));
assert!(folder_within("D:/Example/Photos", "d:\\Example\\Photos"));
assert!(!folder_within("D:/Example/Photos2", "D:/Example/Photos"));
assert!(folder_within("/a/b", "/"));
assert!(folder_within("C:\\x", "c:\\"));
}
+2 -2
View File
@@ -557,7 +557,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
"local.hide" => match p.get("path").and_then(Value::as_str) {
Some(path) => {
if !app.ui.hidden_locations.iter().any(|h| h == path) {
if !app.ui.hidden_locations.iter().any(|h| crate::panels::left::same_folder(h, path)) {
app.ui.hidden_locations.push(path.to_string());
}
Ok(json!({"hidden": app.ui.hidden_locations}))
@@ -567,7 +567,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"local.restoreHidden" => {
// one path, or (no path) every hidden location
match p.get("path").and_then(Value::as_str) {
Some(path) => app.ui.hidden_locations.retain(|h| h != path),
Some(path) => app.ui.hidden_locations.retain(|h| !crate::panels::left::same_folder(h, path)),
None => app.ui.hidden_locations.clear(),
}
Ok(json!({"hidden": app.ui.hidden_locations}))
+63 -16
View File
@@ -196,27 +196,19 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let (lr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(lr.left() + 18.0, lr.center().y), Align2::LEFT_CENTER, "Local", t.semibold(13.5), t.text_label);
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
let mut places: Vec<(String, String)> = Vec::new();
let mut builtin: Vec<(String, String)> = Vec::new();
if !home.is_empty() {
for (name, sub) in [("Pictures", "Pictures"), ("Desktop", "Desktop"), ("Downloads", "Downloads"), ("Home", "")] {
let p = if sub.is_empty() { home.clone() } else { format!("{home}/{sub}") };
// joined with the platform's separator, like the paths browsing gives back
let p = if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() };
if std::path::Path::new(&p).is_dir() {
places.push((name.to_string(), p));
builtin.push((name.to_string(), p));
}
}
}
let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder);
// picked folders stay listed; the folder being browsed is listed even when it wasn't picked
let roots = app.ui.local_roots.iter().cloned().chain(browsing.iter().map(|b| b.path.clone()));
for path in roots {
if !places.iter().any(|(_, p)| *p == path) {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
places.push((name, path));
}
}
let current = browsing.as_ref().map(|b| b.path.clone());
// hidden locations stay reachable: breadcrumbs, Browse Folder…, and the row below
places.retain(|(_, p)| !app.ui.hidden_locations.contains(p));
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());
}
@@ -228,7 +220,7 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
match app.run("library.browse", json!({"path": path})) {
Ok(r) => {
let dir = r["path"].as_str().unwrap_or(&path).to_string();
if !app.ui.local_roots.contains(&dir) {
if !app.ui.local_roots.iter().any(|p| same_folder(p, &dir)) {
app.ui.local_roots.push(dir);
}
}
@@ -249,6 +241,28 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.add_space(10.0);
}
/// Whether two paths name the same folder, however they are spelled (separators, trailing
/// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`).
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
/// 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)> {
let mut places = builtin;
for path in saved.iter().map(String::as_str).chain(browsing) {
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.retain(|(_, p)| !hidden.iter().any(|h| same_folder(h, p)));
places
}
/// 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()));
@@ -279,9 +293,9 @@ fn subfolders(ui: &egui::Ui, path: &str) -> Vec<(String, String)> {
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 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 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));
@@ -592,3 +606,36 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode
}
}
}
#[cfg(test)]
mod tests {
use super::local_places;
fn names(v: &[(String, String)]) -> Vec<&str> {
v.iter().map(|(n, _)| n.as_str()).collect()
}
/// A built-in location browsed (or picked) under another spelling of its path is the same
/// row, not a second root — for every built-in, not just named ones.
#[test]
fn equivalent_paths_are_one_local_location() {
for (home, sep) in [("D:\\Example", '\\'), ("D:/Example", '/'), ("/home/example", '/')] {
let builtin: Vec<(String, String)> =
["Pictures", "Desktop", "Downloads"].iter().map(|s| (s.to_string(), format!("{home}{sep}{s}"))).collect();
for (_, p) in builtin.clone() {
// 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}");
}
// 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}");
}
}
// 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"]);
}
}
+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 |
| 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-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 |