diff --git a/crates/catalog/src/folders.rs b/crates/catalog/src/folders.rs index 3861fd5f..61a8f8c4 100644 --- a/crates/catalog/src/folders.rs +++ b/crates/catalog/src/folders.rs @@ -5,10 +5,10 @@ //! ones above them, never a folder that holds nothing of the library, with how many photos each //! holds — its subfolders included, like a year in By Date counts its months. //! -//! Folders above the imported ones that hold nothing themselves and lead to a single folder fold -//! into one row (`/Users/me/Pictures`) instead of a staircase of single-child levels; the row -//! stands for the deepest folder it names. A folder spelled two ways (`/` or `\`, repeated or -//! trailing separators, `.`) is one folder (see [`crate::query::folder_key`]). +//! Every folder is a row of its own, the ones above the imported photos included +//! (`/Users` → `me` → `Pictures`), so a row is exactly the folder it names. A folder spelled two +//! ways (`/` or `\`, repeated or trailing separators, `.`) is one folder (see +//! [`crate::query::folder_key`]). //! //! Each disk is a *volume*, a top-level row of its own: a drive letter (`D:`), a Windows share //! (`//server/share`), a mounted disk or share (macOS `/Volumes/tokyo`, Linux `/media/me/usb`, @@ -41,8 +41,8 @@ pub const MAX_DEPTH: usize = 64; /// One row of the library's folder tree. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct FolderNode { - /// What the row says: the folder's name, or the whole run (`Users/me/Pictures`) of folded - /// levels; for a volume, its name (`nas`, `D:`, `//server/share`, `/` for the startup disk). + /// What the row says: the folder's name; for a volume, its name (`nas`, `D:`, + /// `//server/share`, `/` for the startup disk). pub name: String, /// The folder the row stands for, written out from the names of the photos' paths (forward /// slashes, no verbatim prefix); hand it back to [`crate::Filter::library_folder`] to see its @@ -205,17 +205,12 @@ impl Catalog { } } -/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep), folding a level -/// that holds nothing and leads to one folder into that folder (never a volume). +/// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep). fn build(key: &str, levels: &HashMap) -> Option { let l = levels.get(key)?; let mut children: Vec = l.children.iter().filter_map(|k| build(k, levels)).collect(); sort(&mut children); let count = l.own + children.iter().map(|c| c.count).sum::(); - if !l.volume && l.own == 0 && children.len() == 1 { - let c = children.remove(0); - return Some(FolderNode { name: format!("{}/{}", l.name, c.name), ..c }); - } Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children }) } diff --git a/crates/catalog/src/tests_folders.rs b/crates/catalog/src/tests_folders.rs index 6c7c9ab7..1a5a6128 100644 --- a/crates/catalog/src/tests_folders.rs +++ b/crates/catalog/src/tests_folders.rs @@ -5,8 +5,7 @@ //! * Given photos imported from several folders, the tree lists those folders and no others, //! each with how many photos it holds, under the disk (volume) they are on. //! * A folder's count includes the folders inside it. -//! * Folders above the imported ones that hold nothing themselves fold into one row, until the -//! library branches. +//! * Every folder is a row of its own, the ones above the imported photos included. //! * Photos only browsed in Local, deleted photos and demo scenes are not "imported from" anywhere. //! * However a folder is spelled, it is one folder. //! * Choosing a folder shows what was imported from it and from the folders inside it — exactly @@ -71,18 +70,27 @@ fn only_folders_with_imported_photos_are_listed_with_their_counts() { #[test] fn a_folders_count_includes_the_folders_inside_it() { let r = rows(&["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip/day1/c.jpg", "/pics/trip/day2/d.jpg"]); - assert_eq!(r, vec![row("/", 4, 0), row("pics/trip", 4, 1), row("day1", 2, 2), row("day2", 1, 1)]); + assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 1), row("day1", 2, 2), row("day2", 1, 1)]); } #[test] -fn folders_that_hold_nothing_fold_into_one_row_until_the_library_branches() { +fn every_folder_is_a_row_of_its_own_even_when_it_holds_nothing_itself() { let c = library(&["/Users/me/Pictures/2024/a.jpg", "/Users/me/Pictures/2025/b.jpg", "/Volumes/nas/photos/c.jpg"]); let tree = c.folder_tree(); assert_eq!( outline(&tree), - vec![row("/", 2, 0), row("Users/me/Pictures", 2, 0), row("2024", 1, 1), row("2025", 1, 1), row("nas", 1, 0), row("photos", 1, 1)] + vec![ + row("/", 2, 0), + row("Users", 2, 0), + row("me", 2, 0), + row("Pictures", 2, 0), + row("2024", 1, 1), + row("2025", 1, 1), + row("nas", 1, 0), + row("photos", 1, 1) + ] ); - assert_eq!(tree[0].children[0].path, "/Users/me/Pictures", "the folded row stands for the deepest folder it names"); + assert_eq!(tree[0].children[0].children[0].children[0].path, "/Users/me/Pictures", "a row is exactly the folder it names"); } #[test] @@ -99,13 +107,13 @@ fn browsed_deleted_and_demo_photos_are_not_imported_from_anywhere() { let id = c.alloc_photo_id(); let demo = Photo::new(id, Source::Demo { scene: 1 }, "demo", "JPEG", 60, 40, "2026-01-01T10:00:00"); c.apply(Op::AddPhoto { photo: Box::new(demo) }).unwrap(); - assert_eq!(outline(&c.folder_tree()), vec![row("/", 1, 0), row("pics/kept", 1, 1)]); + assert_eq!(outline(&c.folder_tree()), vec![row("/", 1, 0), row("pics", 1, 0), row("kept", 1, 1)]); } #[test] fn however_a_folder_is_spelled_it_is_one_folder() { let r = rows(&["/pics/trip/a.jpg", "/pics//trip/./b.jpg", "/pics\\trip\\c.jpg", "/pics/x/../trip/d.jpg"]); - assert_eq!(r, vec![row("/", 4, 0), row("pics/trip", 4, 4)]); + assert_eq!(r, vec![row("/", 4, 0), row("pics", 4, 0), row("trip", 4, 4)]); } #[test] @@ -159,7 +167,14 @@ fn shares_the_other_systems_mount_are_volumes_too() { let r = rows(&[r"\\tokyo.local\photos\a.jpg", r"\\192.168.1.5\scans\b.jpg", r"\\wsl$\Ubuntu\home\me\c.jpg", r"Z:\d.jpg"]); assert_eq!( r, - vec![row("//192.168.1.5/scans", 1, 1), row("//tokyo.local/photos", 1, 1), row("//wsl$/Ubuntu", 1, 0), row("home/me", 1, 1), row("Z:", 1, 1)] + vec![ + row("//192.168.1.5/scans", 1, 1), + row("//tokyo.local/photos", 1, 1), + row("//wsl$/Ubuntu", 1, 0), + row("home", 1, 0), + row("me", 1, 1), + row("Z:", 1, 1) + ] ); } diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 338dff69..26d6130b 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -251,17 +251,9 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result /// After a folder moved from `from` to `to`: a browsed folder at or below it follows. pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { - // a chosen library folder at or below it follows too (compared the way the filter does, by - // folder identity, so a differently spelled choice is not widened to the folder itself) - if let Some(chosen) = s.filter.library_folder.clone() { - let (ck, fk) = (lightcraft_catalog::query::folder_key(&chosen), lightcraft_catalog::query::folder_key(from)); - if !fk.is_empty() - && let Some(rest) = ck.strip_prefix(fk.as_str()).filter(|r| r.is_empty() || r.starts_with('/') || fk.ends_with('/')) - { - let rest = rest.trim_start_matches('/'); - s.filter.library_folder = Some(if rest.is_empty() { to.to_string() } else { format!("{}/{rest}", to.trim_end_matches(['/', '\\'])) }); - } - } + // a library folder chosen at or below it follows too, as a source or as a filter + follow_library_folder(&mut s.library_folder, from, to); + follow_library_folder(&mut s.filter.library_folder, from, to); if let Some(b) = &mut s.browse && let Ok(rel) = Path::new(&b.path).strip_prefix(from) { @@ -271,6 +263,21 @@ pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { } } +/// A chosen library folder at or below `from` becomes the same place below `to`. Compared the +/// way the filter does, by folder identity, so a differently spelled choice is not widened to +/// the folder itself. +fn follow_library_folder(chosen: &mut Option, from: &str, to: &str) { + let Some(c) = chosen.as_deref() else { return }; + let (ck, fk) = (lightcraft_catalog::query::folder_key(c), lightcraft_catalog::query::folder_key(from)); + if fk.is_empty() { + return; + } + if let Some(rest) = ck.strip_prefix(fk.as_str()).filter(|r| r.is_empty() || r.starts_with('/') || fk.ends_with('/')) { + let rest = rest.trim_start_matches('/'); + *chosen = Some(if rest.is_empty() { to.to_string() } else { format!("{}/{rest}", to.trim_end_matches(['/', '\\'])) }); + } +} + /// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink /// the photos inside, as one undo step: undo renames the folder back and restores the photos' /// paths, redo repeats the move — each refused (and reported) when the destination is taken. diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 688d84b3..844bae89 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -251,7 +251,7 @@ pub fn specs() -> Vec { "Show Source", [], None, - "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing, id?: albumId}", + "{kind: all|recentlyAdded|album|recentlyDeleted|picks|missing|libraryFolder, id?: albumId, path?: a path from library.folders (for libraryFolder)}", always, |s, p| { let kind = str_param(p, "kind").unwrap_or("all"); @@ -268,6 +268,12 @@ pub fn specs() -> Vec { } LibrarySource::Album(a) } + "libraryFolder" => { + let path = str_param(p, "path").map(str::trim).filter(|d| !lightcraft_catalog::query::folder_key(d).is_empty()); + let path = path.ok_or_else(|| bad("library.source", "libraryFolder needs the `path` of a folder from library.folders"))?; + s.library_folder = Some(path.to_string()); + LibrarySource::LibraryFolder + } other => return Err(bad("library.source", format!("unknown source `{other}`"))), }; let vis = s.visible_cloned(); @@ -334,6 +340,13 @@ pub fn specs() -> Vec { if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { s.filter.library_folder = None; } + // the folder being shown is gone: back to everything, as for a deleted album + if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { + s.library_folder = None; + if s.source == LibrarySource::LibraryFolder { + s.source = LibrarySource::All; + } + } let vis = s.visible_cloned(); s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); Ok(json!({"removed": ids.len()})) diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index e7ffde14..9b0777b3 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -5,7 +5,7 @@ use lightcraft_develop::{CONTROLS, controls}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, has_active}; -use crate::Session; +use crate::{LibrarySource, Session}; pub fn photo_summary(p: &Photo) -> Value { json!({ @@ -81,10 +81,14 @@ pub fn specs() -> Vec { })) }), cmd!(query "library.state", "Library State", [], None, "{}", always, |s, _| { - let label = s.source.label(&s.catalog); + let label = match (s.source, s.library_folder.as_deref()) { + (LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path), + _ => s.source.label(&s.catalog), + }; let n = s.visible().len(); Ok(json!({ "source": s.source, + "libraryFolder": s.library_folder, "sourceLabel": label, "filter": s.filter, "sort": s.sort, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cbbbbd63..bfc198dc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -134,6 +134,9 @@ pub struct Session { pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. pub browse: Option, + /// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported + /// from it and from the folders inside it. + pub library_folder: Option, /// Copied metadata (`photo.copyMetadata`): photo.setMeta params. pub meta_clipboard: Option, /// The photo that was active before the current one (Paste Settings from Previous). @@ -234,6 +237,7 @@ impl Session { clipboard: None, meta_clipboard: None, browse: None, + library_folder: None, previous_active: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), presets: presets::builtin(), @@ -590,7 +594,8 @@ impl Session { /// Photos shown in the grid/filmstrip for the current source, filter and sort. pub fn visible(&mut self) -> &[PhotoId] { - let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse)); + let mut key = + (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder)); if self.source == LibrarySource::Missing && self.media.availability.is_background() { // the view fills in as the background checks find files gone key.1.push_str(&format!("|{}", self.media.availability.generation())); @@ -605,6 +610,10 @@ impl Session { f.folder = Some(b.path); f.subfolders = b.subfolders; } + if self.source == LibrarySource::LibraryFolder { + // no folder chosen: nothing (an empty path names no folder) + f.library_folder = Some(self.library_folder.clone().unwrap_or_default()); + } let mut visible = self.catalog.query(&f, &self.sort); if matches!(self.source, LibrarySource::Album(_)) && self.sort.key == lightcraft_catalog::SortKey::CaptureDate @@ -648,7 +657,7 @@ impl Session { if self.source == LibrarySource::Missing { return None; } - let key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse)); + let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder)); if self.total.as_ref().map(|t| &t.0) != Some(&key) { let mut f = self.source.to_filter(&Filter::default(), &self.catalog); if self.source == LibrarySource::Folder { @@ -656,6 +665,9 @@ impl Session { f.folder = Some(b.path); f.subfolders = b.subfolders; } + if self.source == LibrarySource::LibraryFolder { + f.library_folder = Some(self.library_folder.clone().unwrap_or_default()); + } let n = self.catalog.query(&f, &self.sort).len(); self.total = Some((key, n)); } diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 6a63e378..b6448380 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -102,6 +102,8 @@ struct PresetsFile { struct ViewFile { source: LibrarySource, browse: Option, + /// The folder a `libraryFolder` source shows. + library_folder: Option, // No filter: a library opens unfiltered. A date, keyword or person left over from the last session // would silently hide photos, with only a small badge to say so. sort: lightcraft_catalog::Sort, @@ -309,6 +311,7 @@ impl Session { self.pending_log.clear(); self.selection = Selection::default(); self.source = LibrarySource::All; + self.library_folder = None; if report.created && seed_demo { crate::demo::load(self); journal.snapshot(&self.catalog)?; @@ -353,6 +356,11 @@ impl Session { if let Some(v) = settings.read::(files.as_mut(), "view.json") { self.source = v.source; self.browse = v.browse; + self.library_folder = v.library_folder.filter(|f| !f.trim().is_empty()); + if self.source == LibrarySource::LibraryFolder && self.library_folder.is_none() { + // no folder to show: everything, not an empty grid + self.source = LibrarySource::All; + } self.sort = v.sort; self.selection = v.selection; self.selection.ids.retain(|id| self.catalog.photo(*id).is_some()); @@ -500,7 +508,13 @@ impl Session { } fn view_json(&self) -> Vec { - let view = ViewFile { source: self.source, browse: self.browse.clone(), sort: self.sort, selection: self.selection.clone() }; + let view = ViewFile { + source: self.source, + browse: self.browse.clone(), + library_folder: self.library_folder.clone(), + sort: self.sort, + selection: self.selection.clone(), + }; serde_json::to_vec_pretty(&view).unwrap_or_default() } diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs index 887870b5..e9577443 100644 --- a/crates/engine/src/tests_folders.rs +++ b/crates/engine/src/tests_folders.rs @@ -14,6 +14,10 @@ use serde_json::json; use crate::{LibrarySource, Session, filter_chips}; +fn source_folder(s: &mut Session, path: &str) -> serde_json::Value { + s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).unwrap() +} + fn add(s: &mut Session, path: &str) { let id = s.catalog.alloc_photo_id(); let p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); @@ -230,3 +234,78 @@ fn following_a_folder_reads_both_spellings_the_same_way() { crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c"); assert_eq!(s.filter.library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone"); } + +#[test] +fn choosing_a_folder_replaces_the_source_the_way_an_album_does() { + let mut s = session(); + s.execute("library.source", &json!({"kind": "picks"})).unwrap(); + assert_eq!(s.visible().len(), 0, "no picks"); + let r = source_folder(&mut s, "/pics/trip"); + assert_eq!(r["count"], 2, "the folder's photos, whatever was shown before"); + assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::LibraryFolder, Some("/pics/trip"))); + assert!(filter_chips(&s.filter, &s.catalog).is_empty(), "a source is not a filter chip"); + let state = s.execute("library.state", &json!({})).unwrap(); + assert_eq!((state["sourceLabel"].as_str(), state["libraryFolder"].as_str()), (Some("pics/trip"), Some("/pics/trip")), "agents see which folder"); + // the filter bar narrows it further, as it does an album + s.execute("library.filter", &json!({"rating": 1})).unwrap(); + assert_eq!(s.visible().len(), 0); + s.execute("library.clearFilter", &json!({})).unwrap(); + assert_eq!(s.visible().len(), 2); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + assert_eq!(s.visible().len(), 3, "back to everything"); +} + +#[test] +fn the_folder_source_takes_a_library_folder_path_only() { + let mut s = session(); + for params in [ + json!({"kind": "libraryFolder"}), + json!({"kind": "libraryFolder", "path": 5}), + json!({"kind": "libraryFolder", "path": " "}), + json!({"kind": "libraryFolder", "path": "."}), + ] { + assert!(s.execute("library.source", ¶ms).is_err(), "{params}"); + } + assert_eq!(s.source, LibrarySource::All, "refused, nothing changed"); + assert_eq!(source_folder(&mut s, "/elsewhere")["count"], 0, "a folder with no photos shows nothing, not everything"); +} + +#[test] +fn removing_the_folder_being_shown_returns_to_all_photos() { + let mut s = session(); + source_folder(&mut s, "/pics/trip"); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::All, None)); + assert_eq!(s.visible().len(), 1); + // removing another folder leaves the source alone + let mut s = session(); + source_folder(&mut s, "/pics/home"); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert_eq!(s.source, LibrarySource::LibraryFolder); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn a_renamed_folder_stays_the_one_shown_and_undo_follows_it_back() { + let dir = Scratch::new("source-rename"); + std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap(); + let (trip, day1, renamed) = (dir.path("trip"), dir.path("trip/day1"), dir.path("holiday")); + let mut s = Session::new(); + add(&mut s, &format!("{trip}/a.jpg")); + add(&mut s, &format!("{day1}/b.jpg")); + source_folder(&mut s, &day1); + s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap(); + assert_eq!(s.library_folder.as_deref(), Some(format!("{renamed}/day1").as_str()), "the subfolder follows its parent"); + assert_eq!(s.visible().len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.library_folder.as_deref(), Some(day1.as_str())); + assert_eq!(s.visible().len(), 1); +} + +#[test] +fn the_shown_folder_follows_reading_both_spellings_the_same_way() { + let mut s = Session::new(); + s.library_folder = Some("/a/x/../b/sub".into()); + crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c"); + assert_eq!(s.library_folder.as_deref(), Some("/a/c/sub")); +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 45a56b2e..91ae195a 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -212,6 +212,23 @@ fn profile_favorites_and_recent_survive_reopen() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn the_shown_library_folder_survives_reopen() { + let dir = temp_dir("libfolder"); + let mut s = open(&dir, true); + s.execute("library.source", &json!({"kind": "libraryFolder", "path": "/pics/trip"})).unwrap(); + s.save_view(); + drop(s); + let s = open(&dir, false); + assert_eq!((s.source, s.library_folder.as_deref()), (crate::LibrarySource::LibraryFolder, Some("/pics/trip"))); + drop(s); + // a view file that names the source but no folder opens on everything, never an empty grid + std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "libraryFolder"}}"#).unwrap(); + let s = open(&dir, false); + assert_eq!((s.source, s.library_folder), (crate::LibrarySource::All, None)); + let _ = std::fs::remove_dir_all(&dir); +} + /// Smart albums with a rule set: all / any / none, nested groups, validation, live updates. #[test] fn smart_album_rule_sets() { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 1b0f0ca4..18149929 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -19,6 +19,9 @@ pub enum LibrarySource { Folder, /// Photos whose original file can't be found (`library.missing`). Missing, + /// A folder the library's photos were imported from ([`crate::Session::library_folder`]), + /// and the folders inside it (`library.folders`). + LibraryFolder, } /// The folder a [`LibrarySource::Folder`] view shows. @@ -49,7 +52,7 @@ impl LibrarySource { LibrarySource::RecentlyDeleted => f.deleted = true, LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick), // the folder itself is filled in by the session (it holds the path) - LibrarySource::Folder | LibrarySource::Missing => {} + LibrarySource::Folder | LibrarySource::Missing | LibrarySource::LibraryFolder => {} } f } @@ -62,6 +65,7 @@ impl LibrarySource { LibrarySource::RecentlyDeleted => "Recently Deleted".into(), LibrarySource::Picks => "Picks".into(), LibrarySource::Folder => "Folder".into(), + LibrarySource::LibraryFolder => "Folder".into(), LibrarySource::Missing => "Missing Photos".into(), } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 98763184..7ed26d56 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -216,6 +216,15 @@ pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcra if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() } } +/// What the grid is titled for the session's source: [`source_label`], or a library folder's last +/// two names (`photos/travel`; the folder's name is the user's and stays verbatim). +pub fn source_title(session: &lightcraft_engine::Session) -> String { + match (session.source, session.library_folder.as_deref()) { + (lightcraft_engine::LibrarySource::LibraryFolder, Some(path)) => lightcraft_catalog::folders::folder_label(path), + (source, _) => source_label(source, &session.catalog), + } +} + /// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full /// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the /// catalog's own wording; every other language formats it with its `*-formats.json` date diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7dc07602..bb7b27f0 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -150,7 +150,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { folder_header(app, ui, hr, &b, &ids, local, &cnt) } None => { - let title = crate::i18n::source_label(app.session.source, &app.session.catalog); + let title = crate::i18n::source_title(&app.session); ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text); ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim); } @@ -164,11 +164,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !chips.is_empty() { let body = match total { Some(n) if n > 0 => { - crate::i18n::tr_format!( - "{n} photos in {} are hidden by the filters above", - crate::i18n::source_label(app.session.source, &app.session.catalog), - n = n - ) + crate::i18n::tr_format!("{n} photos in {} are hidden by the filters above", crate::i18n::source_title(&app.session), n = n) } _ => "Remove a filter above, or choose Clear all".to_string(), }; diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 884d346a..be47fa21 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -733,8 +733,9 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig } /// "Folders": where on disk the library's photos were imported from, with photo counts (see -/// `lightcraft_catalog::folders`). A click shows the photos imported from that folder and the -/// folders inside it, the triangle opens a level. Only folders holding imported photos are +/// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a +/// Local folder: its photos and those of the folders inside it fill the grid. The triangle opens +/// a level. Only folders holding imported photos are /// listed; every folder on disk is under Local. fn folders_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { let tree = app.caches.folder_tree(&app.session.catalog); @@ -774,10 +775,11 @@ fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> String { "…".to_string() } -/// Whenever the chosen folder changes (a click, an agent, a rename or its undo), open the rows +/// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows /// above it so it is on screen; folding one by hand afterwards sticks until the choice changes. fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) { - let chosen = app.session.filter.library_folder.clone().filter(|c| !lightcraft_catalog::query::folder_key(c).is_empty()); + 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 now = chosen.as_deref().map(lightcraft_catalog::query::folder_key); if ui.data(|d| d.get_temp::>(seen)) == Some(now.clone()) { @@ -802,11 +804,15 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], for n in nodes { let key = lightcraft_catalog::query::folder_key(&n.path); let open_id = egui::Id::new(("libfolder-open", key.clone())); - // a disk starts open: its folders are what the section is for - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(n.volume); + // a disk starts open, and so does a folder that holds nothing itself and leads to one + // folder (`Users` → `me`): the first row where the library branches is what you look for + let leads_on = n.own == 0 && n.children.len() == 1; + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(n.volume || leads_on); // a row whose path would cover other disks' photos too only opens and closes let selectable = n.selectable; - let sel = selectable && app.session.filter.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); + let sel = selectable + && app.session.source == LibrarySource::LibraryFolder + && app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path)); let name = if selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() }; // room for the name: between the icon and the count let font = t.font(13.5); @@ -837,12 +843,8 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], let resp = resp.on_hover_text(tip); if resp.clicked() && !toggled { if selectable { - // the library's folder, not a mix with the Local folder being browsed - if app.session.source == LibrarySource::Folder { - let _ = app.run("library.source", json!({"kind": "all"})); - } - let v = if sel { serde_json::Value::Null } else { json!(n.path) }; - let _ = app.run("library.filter", json!({"libraryFolder": v})); + // a source like an album or a Local folder: it replaces what the grid showed + let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); } else { toggled = true; } diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index e5134290..585a3e95 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -241,9 +241,11 @@ fn folders_section_lists_where_photos_were_imported_from_and_narrows_the_grid() assert_eq!(h.app.session.visible().len(), 3); click(&mut h, "source:libfolder:/pics/trip"); assert_eq!(h.app.session.visible().len(), 2, "only the photos imported from that folder"); - assert_eq!(h.app.session.filter.library_folder.as_deref(), Some("/pics/trip")); - click(&mut h, "source:libfolder:/pics/trip"); - assert_eq!(h.app.session.visible().len(), 3, "choosing it again shows everything"); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::LibraryFolder); + assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/trip")); + assert_eq!(h.app.session.filter, lightcraft_catalog::Filter::default(), "a source, not a filter"); + click(&mut h, "source:all"); + assert_eq!(h.app.session.visible().len(), 3, "All Photos shows everything again"); // the section folds like the others click(&mut h, "sidebarSection:folders"); assert!(!has(&h, "source:libfolder:/pics"), "folded"); @@ -259,7 +261,7 @@ fn disks_are_rows_of_their_own() { assert!(has(&h, "source:libfolder:/") && has(&h, "source:libfolder:/Volumes/nas"), "both disks are listed"); assert!(has(&h, "source:libfolder:/Volumes/nas/p"), "an open disk shows its folders"); click(&mut h, "source:libfolder:/"); - assert_eq!(h.app.session.filter.library_folder, None, "the startup disk's path would cover every disk"); + assert_eq!(h.app.session.library_folder, None, "the startup disk's path would cover every disk"); assert!(!has(&h, "source:libfolder:/Users/me"), "a click on it folds it instead"); click(&mut h, "source:libfolder:/"); assert!(has(&h, "source:libfolder:/Users/me")); @@ -267,15 +269,42 @@ fn disks_are_rows_of_their_own() { assert_eq!(h.app.session.visible().len(), 2, "everything on the nas"); } -/// Choosing a folder while Local is the source shows the library's folder, not a mix. +/// A folder loads into the grid the way an album, a Local folder or Picks does: whatever was +/// shown before is replaced, and the folder's own row is highlighted. #[test] -fn choosing_a_library_folder_leaves_a_local_folder_view() { +fn choosing_a_library_folder_replaces_whatever_was_shown() { + use lightcraft_engine::LibrarySource; let mut h = folders_app(&["/pics/trip/a.jpg", "/pics/home/b.jpg"]); - h.app.session.source = lightcraft_engine::LibrarySource::Folder; click(&mut h, "libraryFolderToggle:/pics"); + for before in [LibrarySource::Picks, LibrarySource::Folder, LibrarySource::RecentlyDeleted, LibrarySource::Missing] { + h.app.session.source = before; + click(&mut h, "source:libfolder:/pics/trip"); + assert_eq!(h.app.session.source, LibrarySource::LibraryFolder, "from {before:?}"); + assert_eq!(h.app.session.visible().len(), 1); + } click(&mut h, "source:libfolder:/pics/trip"); - assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); - assert_eq!(h.app.session.visible().len(), 1); + assert_eq!(h.app.session.source, LibrarySource::LibraryFolder, "choosing it again keeps it, as an album does"); + click(&mut h, "source:libfolder:/pics/home"); + assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/home")); +} + +#[test] +fn the_grid_is_titled_after_the_folder() { + let mut h = folders_app(&["/Volumes/tokyo/photos/travel/a.jpg"]); + h.app.session.source = lightcraft_engine::LibrarySource::LibraryFolder; + h.app.session.library_folder = Some("/Volumes/tokyo/photos/travel".into()); + assert_eq!(crate::i18n::source_title(&h.app.session), "photos/travel"); + h.app.session.source = lightcraft_engine::LibrarySource::All; + assert_eq!(crate::i18n::source_title(&h.app.session), "All Photos"); +} + +/// A folder that holds nothing itself and leads to one folder starts open: the tree opens down to +/// where the library branches. +#[test] +fn a_chain_of_single_folders_starts_open() { + let h = folders_app(&["/Users/me/Pictures/2024/a.jpg", "/Users/me/Pictures/2025/b.jpg"]); + assert!(has(&h, "source:libfolder:/Users/me/Pictures"), "Users and me opened by themselves"); + assert!(!has(&h, "source:libfolder:/Users/me/Pictures/2024"), "the branching folder waits to be opened"); } /// Removing a folder from the library asks first, then moves its photos to Recently Deleted. @@ -310,7 +339,7 @@ fn a_folder_that_holds_other_disks_only_opens() { let mut h = folders_app(&["/Volumes/1.jpg", "/Volumes/tokyo/x.jpg"]); assert!(has(&h, "source:libfolder:/Volumes")); click(&mut h, "source:libfolder:/Volumes"); - assert_eq!(h.app.session.filter.library_folder, None, "choosing it would show the tokyo disk too"); + assert_eq!(h.app.session.library_folder, None, "choosing it would show the tokyo disk too"); click(&mut h, "source:libfolder:/Volumes/tokyo"); assert_eq!(h.app.session.visible().len(), 1, "a disk's own row does choose"); } @@ -320,7 +349,8 @@ fn a_folder_that_holds_other_disks_only_opens() { fn the_chosen_folder_is_always_in_view() { let mut h = folders_app(&["/pics/trip/day1/a.jpg", "/pics/trip/b.jpg", "/pics/home/c.jpg"]); assert!(!has(&h, "source:libfolder:/pics/trip/day1"), "folded to begin with"); - h.app.session.filter.library_folder = Some("/pics/trip/day1".into()); + h.app.session.source = lightcraft_engine::LibrarySource::LibraryFolder; + h.app.session.library_folder = Some("/pics/trip/day1".into()); h.step(); h.step(); assert!(has(&h, "source:libfolder:/pics/trip/day1"), "the rows above it opened"); @@ -337,7 +367,8 @@ fn a_renamed_folder_keeps_its_place_in_the_tree() { std::fs::create_dir_all(base.join("pics/home")).unwrap(); let b = base.to_string_lossy().to_string(); let mut h = folders_app(&[&format!("{b}/pics/trip/a.jpg"), &format!("{b}/pics/home/b.jpg")]); - h.app.session.filter.library_folder = Some(format!("{b}/pics/trip")); + h.app.session.source = lightcraft_engine::LibrarySource::LibraryFolder; + h.app.session.library_folder = Some(format!("{b}/pics/trip")); h.step(); h.step(); assert!(has(&h, &format!("source:libfolder:{b}/pics/trip"))); diff --git a/docs/parity.md b/docs/parity.md index 23582ca3..206e2c3e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -718,7 +718,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning; no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); levels that hold nothing and lead to one folder fold into one row (`Users/me/Pictures`); long names are cut at the end with the full path in the tooltip; click chooses a folder and the folders inside it (click again clears; chip “Folder: pics/trip”), and its row is always opened into view, also after a rename or move and their undo. A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; long names are cut at the end with the full path in the tooltip; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view, also after a rename or move and their undo. A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here | | 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; rules incl. File Path — the whole string, either separator, case-insensitive), 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), 30 fields (rating, flag, label, type, edits, keywords, any text, filename, file path, format, title, caption, camera, lens, location, creator, copyright, copyright status, 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 | ⬜ | | |