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:
parent
82e827b9d9
commit
0732edc64f
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()}))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
|
|
@ -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 | ⬜ | | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue