Folders: every folder its own node; choosing one loads it like an album

- catalog: a row is exactly one folder (no folding of single-child levels);
  the UI starts a folder that holds nothing and leads to one folder open
- engine: a library folder is a source (LibrarySource::LibraryFolder with
  Session::library_folder, like Local's browse): library.source
  {kind: libraryFolder, path}, kept with the library's view, shown by
  library.state, followed through folder.rename / folder.move and undo,
  dropped back to All Photos when its folder is removed
- ui: clicking a folder replaces whatever the grid showed (Picks, Recently
  Deleted, a Local folder…) instead of narrowing it with a filter chip; the
  grid is titled after the folder

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford 2026-10-08 07:57:41 -04:00
parent 82e827b9d9
commit 0732edc64f
No known key found for this signature in database
15 changed files with 269 additions and 71 deletions

View File

@ -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<String, Level>) -> Option<FolderNode> {
let l = levels.get(key)?;
let mut children: Vec<FolderNode> = l.children.iter().filter_map(|k| build(k, levels)).collect();
sort(&mut children);
let count = l.own + children.iter().map(|c| c.count).sum::<usize>();
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 })
}

View File

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

View File

@ -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<String>, 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.

View File

@ -251,7 +251,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
}
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<CommandSpec> {
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()}))

View File

@ -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<CommandSpec> {
}))
}),
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,

View File

@ -134,6 +134,9 @@ pub struct Session {
pub clipboard: Option<Value>,
/// The folder on disk the [`LibrarySource::Folder`] view browses.
pub browse: Option<Browse>,
/// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported
/// from it and from the folders inside it.
pub library_folder: Option<String>,
/// Copied metadata (`photo.copyMetadata`): photo.setMeta params.
pub meta_clipboard: Option<Value>,
/// 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));
}

View File

@ -102,6 +102,8 @@ struct PresetsFile {
struct ViewFile {
source: LibrarySource,
browse: Option<crate::Browse>,
/// The folder a `libraryFolder` source shows.
library_folder: Option<String>,
// 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::<ViewFile>(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<u8> {
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()
}

View File

@ -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", &params).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"));
}

View File

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

View File

@ -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(),
}
}

View File

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

View File

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

View File

@ -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::<Option<String>>(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;
}

View File

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

View File

@ -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/<host>`), NFS (`/Network/Servers/<host>`), 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/<host>`), NFS (`/Network/Servers/<host>`), 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 | ⬜ | | |